41 Commits
Author SHA1 Message Date
nils e150b81190 rename skeuo to desktop 2025-10-28 10:41:56 +01:00
nils 94578475ea appconfig 2025-10-28 02:52:41 +01:00
nils 92c491b742 auth tests 2025-10-28 02:27:10 +01:00
nils eb93a2131f refactor 2025-10-28 02:05:38 +01:00
nils 1e177580c9 stuff 2025-10-28 01:38:19 +01:00
nils 7df9a6415a refactor 2025-10-28 01:18:35 +01:00
nils 1179f4fcd4 frontend tweaks, preview workspace 2025-10-28 00:43:54 +01:00
nils 709d32050e backend tracing 2025-10-28 00:30:45 +01:00
nils b9d84fa72b remove download endpoint 2025-10-28 00:17:57 +01:00
nils 7abc10acde backend 2025-10-28 00:01:40 +01:00
nils 9be0e3b5c4 frontend 2025-10-28 00:01:16 +01:00
nils ed0f0fb759 backend 2025-10-27 22:45:16 +01:00
nils 2d80515ad5 backend 2025-10-27 22:25:43 +01:00
nils 50125f4659 document updates 2025-10-27 22:06:33 +01:00
nils a6da34740f drag thumbs 2025-10-27 21:20:54 +01:00
nils 7ed06ccdcf drag 2025-10-27 21:06:29 +01:00
nils db07e0debb increase icon size in grid view 2025-10-27 20:55:29 +01:00
nils a0be094cbd table 2025-10-27 20:53:51 +01:00
nils 7366d2e16b frontend: tags 2025-10-27 20:29:21 +01:00
nils 82aa8948cf tenants 2025-10-27 11:32:22 +01:00
nils 84a3a9a5b5 skeuo aspect ratio 2025-10-27 01:55:37 +01:00
nils b260065245 frontend: folders 2025-10-27 01:31:06 +01:00
nils 264316eb29 Merge remote-tracking branch 'origin/frontend'
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18m23s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 18m2s
2025-10-27 00:22:00 +01:00
nils c625c80958 folders
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-27 00:20:53 +01:00
nils 6a04d29eed metadata 2025-10-27 00:17:15 +01:00
nils 10e28cc5e2 previewzoomoverlay 2025-10-27 00:14:10 +01:00
nils 970678986a sidebar shadow 2025-10-26 23:15:12 +01:00
nils 40e45f3a01 previewzoomoverlay 2025-10-26 21:01:48 +01:00
nils 518b79bec6 stuff 2025-10-26 20:29:43 +01:00
nils 01fb4e308a cleanup 2025-10-26 19:46:32 +01:00
nils b6300ffa30 panel actions 2025-10-26 19:14:35 +01:00
nils b163a07e84 lazy-thumbnails 2025-10-26 10:36:47 +01:00
nils 42a3a53314 panning 2025-10-26 02:52:26 +02:00
nils 4a06fa82eb cache warming 2025-10-26 02:19:19 +02:00
nils 80238bb7a1 zoom 2025-10-26 02:05:31 +02:00
nils dcbd46531e preview zoom 2025-10-26 01:45:37 +02:00
nils 0864b39336 panel actions 2025-10-26 01:04:21 +02:00
nils 2a0c96bb4c detail panel 2025-10-26 00:28:40 +02:00
nils c53213a357 cleanup 2025-10-25 23:49:51 +02:00
nils dc633783e0 frontend 2025-10-24 15:13:37 +02:00
nils e33ab71fac move to sidebar 2025-10-24 14:07:09 +02:00
59 changed files with 9693 additions and 4387 deletions
+42
View File
@@ -611,6 +611,7 @@ dependencies = [
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dotenv", "dotenv",
"envy",
"futures-util", "futures-util",
"hex", "hex",
"http-body-util", "http-body-util",
@@ -625,6 +626,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"serde", "serde",
"serde-aux",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
@@ -1175,6 +1177,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -2160,6 +2171,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.2" version = "0.5.2"
@@ -2904,6 +2924,28 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-aux"
version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
dependencies = [
"chrono",
"serde",
"serde-value",
"serde_json",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
+2
View File
@@ -25,6 +25,8 @@ aws-credential-types = "1.2"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
envy = "0.4"
serde-aux = "4.4"
# Utilities # Utilities
tracing = "0.1" tracing = "0.1"
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS folders_tenant_parent_name_unique_idx;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
ON folders (
tenant_id,
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
@@ -0,0 +1,16 @@
-- Revert correspondent uniqueness to global name
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
ALTER TABLE correspondents ADD CONSTRAINT correspondents_name_unique UNIQUE (name);
-- Revert tag uniqueness to global label
DROP INDEX IF EXISTS tags_tenant_label_unique;
ALTER TABLE tags ADD CONSTRAINT tags_label_key UNIQUE (label);
-- Revert document filename uniqueness to global folder scope
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,20 @@
-- Ensure document filenames are unique per tenant + folder
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
ON documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
-- Ensure tag labels are unique per tenant
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_label_key;
DROP INDEX IF EXISTS tags_tenant_label_unique;
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags (tenant_id, label);
-- Ensure correspondent names are unique per tenant
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_name_unique;
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
CREATE UNIQUE INDEX correspondents_tenant_name_unique ON correspondents (tenant_id, name);
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
DROP COLUMN IF EXISTS operations_summary;
@@ -0,0 +1,7 @@
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
UPDATE document_correspondents SET role = 'other';
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id, role);
@@ -0,0 +1,24 @@
WITH ranked AS (
SELECT
document_id,
correspondent_id,
role,
assigned_at,
assigned_by,
tenant_id,
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
FROM document_correspondents
)
DELETE FROM document_correspondents dc
USING ranked r
WHERE dc.document_id = r.document_id
AND dc.correspondent_id = r.correspondent_id
AND dc.role = r.role
AND dc.tenant_id = r.tenant_id
AND r.rn > 1;
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents DROP COLUMN role;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id);
+96 -77
View File
@@ -1,35 +1,60 @@
use std::env;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use url::Url; use url::Url;
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_bool_from_anything;
use crate::db::DEFAULT_MAX_POOL_SIZE; use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug)] #[derive(Clone, Debug, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub database_url: String, pub database_url: String,
#[serde(default = "default_database_max_pool_size")]
pub database_max_pool_size: u32, pub database_max_pool_size: u32,
#[serde(default = "default_server_host")]
pub server_host: String, pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16, pub server_port: u16,
#[serde(default = "default_webdav_host")]
pub webdav_host: String, pub webdav_host: String,
#[serde(default = "default_webdav_port")]
pub webdav_port: u16, pub webdav_port: u16,
pub jwt_secret: String, pub jwt_secret: String,
#[serde(default = "default_jwt_issuer")]
pub jwt_issuer: String, pub jwt_issuer: String,
#[serde(default = "default_jwt_audience")]
pub jwt_audience: String, pub jwt_audience: String,
#[serde(default = "default_jwt_expiry_minutes")]
pub jwt_expiry_minutes: i64, pub jwt_expiry_minutes: i64,
#[serde(default = "default_download_token_audience")]
pub download_token_audience: String, pub download_token_audience: String,
#[serde(default = "default_download_token_expiry_minutes")]
pub download_token_expiry_minutes: i64, pub download_token_expiry_minutes: i64,
#[serde(default = "default_refresh_token_expiry_days")]
pub refresh_token_expiry_days: i64, pub refresh_token_expiry_days: i64,
#[serde(
default = "default_refresh_cookie_secure",
deserialize_with = "deserialize_bool_from_anything"
)]
pub refresh_cookie_secure: bool, pub refresh_cookie_secure: bool,
#[serde(default)]
pub refresh_cookie_domain: Option<String>, pub refresh_cookie_domain: Option<String>,
#[serde(default)]
pub cors_allowed_origin: Option<String>, pub cors_allowed_origin: Option<String>,
#[serde(default)]
pub aws_endpoint_url: Option<String>, pub aws_endpoint_url: Option<String>,
#[serde(default)]
pub aws_access_key_id: Option<String>, pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>, pub aws_secret_access_key: Option<String>,
#[serde(default = "default_aws_region")]
pub aws_region: String, pub aws_region: String,
pub s3_bucket: String, pub s3_bucket: String,
#[serde(default)]
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
#[serde(default)]
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
#[serde(default = "default_tenant_slug")]
pub default_tenant_slug: String, pub default_tenant_slug: String,
} }
@@ -49,80 +74,9 @@ impl AppConfig {
} }
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let config: AppConfig = envy::from_env()
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE") .context("failed to parse application configuration from environment")?;
.ok() Ok(config.normalize())
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "papercrate-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
quickwit_endpoint,
quickwit_index,
default_tenant_slug,
})
} }
pub fn redacted_database_url(&self) -> String { pub fn redacted_database_url(&self) -> String {
@@ -130,6 +84,71 @@ impl AppConfig {
} }
} }
impl AppConfig {
fn normalize(mut self) -> Self {
if self.webdav_host.is_empty() {
self.webdav_host = self.server_host.clone();
}
self
}
}
fn default_database_max_pool_size() -> u32 {
DEFAULT_MAX_POOL_SIZE
}
fn default_server_host() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
3000
}
fn default_webdav_host() -> String {
String::new()
}
fn default_webdav_port() -> u16 {
3001
}
fn default_jwt_issuer() -> String {
"papercrate".to_string()
}
fn default_jwt_audience() -> String {
"papercrate-clients".to_string()
}
fn default_jwt_expiry_minutes() -> i64 {
60
}
fn default_download_token_audience() -> String {
"papercrate-download".to_string()
}
fn default_download_token_expiry_minutes() -> i64 {
60
}
fn default_refresh_token_expiry_days() -> i64 {
30
}
fn default_refresh_cookie_secure() -> bool {
false
}
fn default_aws_region() -> String {
"us-east-1".to_string()
}
fn default_tenant_slug() -> String {
"admin".to_string()
}
fn redact_database_url(raw: &str) -> String { fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
+263
View File
@@ -0,0 +1,263 @@
use std::collections::HashMap;
use std::path::Path as FsPath;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions};
use crate::state::AppState;
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
}
#[derive(Serialize, ToSchema)]
pub struct DocumentAssetDetailResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
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, ToSchema)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub metadata: Value,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetResponse>,
pub download_path: String,
}
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub 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,
}
}
pub async fn load_asset_responses(
state: &AppState,
tenant_id: Uuid,
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db_for_tenant(tenant_id)?;
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::tenant_id.eq(tenant_id))
.order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
Ok(assets
.into_iter()
.map(|(asset, _)| to_asset_summary(asset))
.collect())
}
pub fn load_primary_assets(
state: &AppState,
tenant_id: Uuid,
documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
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 {
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_for_tenant(tenant_id)?;
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?;
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions {
version_map.insert(version.id, version);
}
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq_any(&version_ids))
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, _object) in assets {
let version_id = asset.document_version_id;
let response = to_asset_summary(asset);
assets_by_version
.entry(version_id)
.or_default()
.push(response);
}
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));
}
}
Ok(result)
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::collections::HashMap;
use chrono::Utc;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
use crate::schema::{correspondents, document_correspondents, documents};
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentCorrespondentResponse {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub assigned_at: String,
}
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
unique.sort_unstable();
unique.dedup();
if unique.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one correspondent",
));
}
Ok(unique)
}
pub fn insert_document_correspondents(
conn: &mut PgConnection,
tenant_id: Uuid,
document_id: Uuid,
user_id: Uuid,
correspondent_ids: &[Uuid],
) -> AppResult<usize> {
let ids = normalize_correspondent_ids(correspondent_ids)?;
let existing: Vec<Uuid> = correspondents::table
.filter(correspondents::id.eq_any(&ids))
.filter(correspondents::tenant_id.eq(tenant_id))
.select(correspondents::id)
.load(conn)?;
if existing.len() != ids.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
let new_rows: Vec<NewDocumentCorrespondent> = ids
.into_iter()
.map(|correspondent_id| NewDocumentCorrespondent {
document_id,
correspondent_id,
assigned_by: Some(user_id),
tenant_id,
})
.collect();
if new_rows.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?;
if inserted > 0 {
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(inserted)
}
pub fn load_correspondents_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
.inner_join(correspondents::table)
.filter(document_correspondents::document_id.eq_any(document_ids))
.order((
document_correspondents::document_id.asc(),
document_correspondents::assigned_at.asc(),
))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
for (assignment, correspondent) in rows {
map.entry(assignment.document_id)
.or_default()
.push(DocumentCorrespondentResponse {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
assigned_at: to_iso(assignment.assigned_at),
});
}
Ok(map)
}
+21
View File
@@ -0,0 +1,21 @@
use diesel::dsl::exists;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::AppResult;
use crate::schema::folders;
use crate::utils::validation::ensure_exists;
pub fn ensure_folder_exists_on_conn(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<()> {
let exists: bool = diesel::select(exists(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
ensure_exists(exists, "folder")
}
+52
View File
@@ -0,0 +1,52 @@
use serde_json::{map::Entry, Map, Value};
use crate::error::{AppError, AppResult};
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
let mut base = match existing {
Value::Object(map) => map,
Value::Null => Map::new(),
_ => {
return Err(AppError::bad_request(
"existing metadata is not an object; set replace=true to overwrite",
));
}
};
let incoming = match updates {
Value::Object(map) => map,
_ => {
return Err(AppError::bad_request(
"metadata value must be a JSON object when replace is false",
));
}
};
merge_metadata_maps(&mut base, incoming);
Ok(Value::Object(base))
}
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
for (key, value) in updates {
match target.entry(key) {
Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
match value {
Value::Object(update_map) => {
if let Value::Object(existing_map) = existing {
merge_metadata_maps(existing_map, update_map);
} else {
*existing = Value::Object(update_map);
}
}
other => {
*existing = other;
}
}
}
Entry::Vacant(entry) => {
entry.insert(value);
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod asset;
pub mod correspondents;
pub mod folders;
pub mod metadata;
pub mod search;
pub mod tags;
@@ -1,6 +1,14 @@
use serde_json::Value; use std::collections::HashSet;
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::{debug, error};
use uuid::Uuid; use uuid::Uuid;
pub const QUICKWIT_MAX_HITS: usize = 200;
pub fn build_quickwit_query(input: &str) -> Option<String> { pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input let tokens: Vec<String> = input
.split_whitespace() .split_whitespace()
@@ -38,6 +46,67 @@ pub fn escape_quickwit_token(token: &str) -> String {
escaped escaped
} }
pub async fn quickwit_search(
endpoint: &str,
index: &str,
tenant_id: Uuid,
query: &str,
) -> Result<Vec<Uuid>> {
let tenant_clause = format!("tenant_id:{}", tenant_id);
let quickwit_query = match build_quickwit_query(query) {
Some(q) => {
debug!(%query, quickwit_query = %q, "built quickwit search query");
format!("{} AND ({})", tenant_clause, q)
}
None => {
debug!(%query, "quickwit search skipped because query produced no tokens");
return Ok(vec![]);
}
};
let client = Client::new();
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
let payload = json!({
"query": quickwit_query,
"max_hits": QUICKWIT_MAX_HITS,
});
debug!(%url, payload = %payload, "sending quickwit search request");
let response = client.post(url).json(&payload).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(%status, body = %body, "quickwit search request failed");
return Err(anyhow!(
"quickwit search failed with status {status}: {body}"
));
}
let data: QuickwitSearchResponse = response.json().await?;
debug!("quickwit search response parsed successfully");
let QuickwitSearchResponse { hits } = data;
let total_hits = hits.len();
let mut seen = HashSet::new();
let mut doc_ids = Vec::with_capacity(total_hits);
for hit in hits {
if let Some(doc_id) = extract_document_id(&hit) {
if seen.insert(doc_id) {
doc_ids.push(doc_id);
}
}
}
debug!(
total_hits = total_hits,
unique_ids = doc_ids.len(),
"quickwit search completed"
);
Ok(doc_ids)
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> { pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] { for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) { if let Some(value) = hit.get(key) {
@@ -89,3 +158,9 @@ pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
None None
} }
#[derive(Deserialize)]
struct QuickwitSearchResponse {
#[serde(default)]
hits: Vec<Value>,
}
+80
View File
@@ -0,0 +1,80 @@
use std::collections::HashMap;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, NewDocumentTag, Tag};
use crate::schema::{document_tags, tags};
pub fn assign_tags(
conn: &mut PgConnection,
tenant_id: Uuid,
document: &Document,
raw_tag_ids: &[Uuid],
assigned_by: Option<Uuid>,
) -> AppResult<usize> {
if raw_tag_ids.is_empty() {
return Ok(0);
}
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
tag_ids.sort_unstable();
tag_ids.dedup();
if tag_ids.is_empty() {
return Ok(0);
}
let existing: Vec<Uuid> = tags::table
.filter(tags::id.eq_any(&tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.select(tags::id)
.load(conn)?;
if existing.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let new_tags: Vec<NewDocumentTag> = tag_ids
.into_iter()
.map(|tag_id| NewDocumentTag {
document_id: document.id,
tag_id,
assigned_by,
tenant_id,
})
.collect();
if new_tags.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_tags::table)
.values(&new_tags)
.on_conflict_do_nothing()
.execute(conn)?;
Ok(inserted)
}
pub fn load_tags_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, Tag)> = document_tags::table
.inner_join(tags::table)
.filter(document_tags::document_id.eq_any(document_ids))
.select((document_tags::document_id, tags::all_columns))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
+1
View File
@@ -1,6 +1,7 @@
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod documents;
pub mod error; pub mod error;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
+1 -5
View File
@@ -123,7 +123,6 @@ pub struct DocumentVersion {
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -137,7 +136,6 @@ pub struct NewDocumentVersion {
pub s3_key: String, pub s3_key: String,
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -283,11 +281,10 @@ pub struct NewCorrespondent {
#[diesel(table_name = document_correspondents)] #[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))] #[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))] #[diesel(primary_key(document_id, correspondent_id))]
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
@@ -298,7 +295,6 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent { pub struct NewDocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
+88 -35
View File
@@ -18,7 +18,6 @@ use uuid::Uuid;
doc::get_document, doc::get_document,
doc::update_document, doc::update_document,
doc::delete_document, doc::delete_document,
doc::download_document,
doc::download_with_token, doc::download_with_token,
doc::move_document, doc::move_document,
doc::assign_tags, doc::assign_tags,
@@ -57,13 +56,13 @@ use uuid::Uuid;
schemas::LoginResponseVariants, schemas::LoginResponseVariants,
schemas::DocumentResponse, schemas::DocumentResponse,
schemas::DocumentDetailResponse, schemas::DocumentDetailResponse,
schemas::DocumentVersion, schemas::DocumentVersionResponse,
schemas::DocumentVersionDetailResponse,
schemas::DocumentAssetSummary, schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail, schemas::DocumentAssetDetail,
schemas::DocumentAssetObject, schemas::DocumentAssetObject,
schemas::DocumentCorrespondent, schemas::DocumentCorrespondent,
schemas::DocumentTag, schemas::DocumentTag,
schemas::DocumentDownloadResponse,
schemas::UpdateDocumentRequest, schemas::UpdateDocumentRequest,
schemas::BulkMoveDocumentsRequest, schemas::BulkMoveDocumentsRequest,
schemas::BulkMoveDocumentsResponse, schemas::BulkMoveDocumentsResponse,
@@ -77,7 +76,6 @@ use uuid::Uuid;
schemas::BulkCorrespondentsResponse, schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction, schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest, schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest, schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse, schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams, schemas::DocumentAssetRequestParams,
@@ -96,6 +94,7 @@ use uuid::Uuid;
schemas::TagCatalogEntry, schemas::TagCatalogEntry,
schemas::CreateTagRequest, schemas::CreateTagRequest,
schemas::UpdateTagRequest, schemas::UpdateTagRequest,
schemas::CorrespondentUsage,
schemas::CorrespondentCatalogEntry, schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest, schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest, schemas::UpdateCorrespondentRequest,
@@ -240,12 +239,34 @@ mod doc {
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/documents/{id}/download", path = "/api/documents/{id}/versions",
params(("id" = Uuid, Path, description = "Document ID")), params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)), responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
tag = "Documents" tag = "Documents"
)] )]
pub(super) fn download_document() {} pub(super) fn list_document_versions() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub(super) fn get_document_version() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = RestoreDocumentRequest,
responses((status = 204, description = "Document restored")),
tag = "Documents"
)]
pub(super) fn restore_document() {}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -330,8 +351,7 @@ mod doc {
path = "/api/documents/{id}/correspondents/{correspondent_id}", path = "/api/documents/{id}/correspondents/{correspondent_id}",
params( params(
("id" = Uuid, Path, description = "Document ID"), ("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID"), ("correspondent_id" = Uuid, Path, description = "Correspondent ID")
RemoveCorrespondentParams
), ),
responses((status = 204, description = "Correspondent removed")), responses((status = 204, description = "Correspondent removed")),
tag = "Documents" tag = "Documents"
@@ -539,6 +559,7 @@ pub mod schemas {
pub access_token: String, pub access_token: String,
pub token_type: String, pub token_type: String,
pub expires_in: i64, pub expires_in: i64,
pub tenant: TenantSnippet,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -547,9 +568,15 @@ pub mod schemas {
pub slug: String, pub slug: String,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet {
pub id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse { pub struct TenantSelectionResponse {
pub selection_token: String, pub access_token: String,
pub tenants: Vec<TenantSummary>, pub tenants: Vec<TenantSummary>,
} }
@@ -569,11 +596,26 @@ pub mod schemas {
#[into_params(parameter_in = Query)] #[into_params(parameter_in = Query)]
pub struct DocumentListQuery { pub struct DocumentListQuery {
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub include_deleted: Option<bool>, #[schema(nullable)]
pub include_descendants: Option<bool>, pub include_descendants: Option<bool>,
pub query: Option<String>, pub query: Option<String>,
pub tags: Option<String>, pub tags: Option<String>,
pub correspondents: Option<String>, pub correspondents: Option<String>,
#[serde(default = "default_document_status_filter")]
#[schema(default = "active")]
pub status: DocumentStatusFilter,
}
fn default_document_status_filter() -> DocumentStatusFilter {
DocumentStatusFilter::Active
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum DocumentStatusFilter {
Active,
Deleted,
All,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -618,17 +660,21 @@ pub mod schemas {
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersion { pub struct DocumentVersionResponse {
pub id: Uuid, pub id: Uuid,
pub version_number: i32, pub version_number: i32,
pub checksum: String,
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String,
pub created_at: String, pub created_at: String,
pub metadata: Value, pub metadata: Value,
#[schema(nullable)] }
pub operations_summary: Option<Value>,
#[schema(nullable)] #[derive(Serialize, Deserialize, ToSchema)]
pub assets: Option<Vec<DocumentAssetSummary>>, pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetSummary>,
pub download_path: String, pub download_path: String,
} }
@@ -636,7 +682,6 @@ pub mod schemas {
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub role: String,
pub metadata: Value, pub metadata: Value,
pub assigned_at: String, pub assigned_at: String,
} }
@@ -659,10 +704,10 @@ pub mod schemas {
pub issued_at: Option<String>, pub issued_at: Option<String>,
pub metadata: Value, pub metadata: Value,
pub tags: Vec<DocumentTag>, pub tags: Vec<DocumentTag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondent>,
#[schema(nullable)] #[schema(nullable)]
pub correspondents: Option<Vec<DocumentCorrespondent>>, pub current_version: Option<DocumentVersionDetailResponse>,
#[schema(nullable)]
pub current_version: Option<DocumentVersion>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -671,19 +716,27 @@ pub mod schemas {
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentDownloadResponse { pub struct DocumentMetadataUpdate {
pub url: String, pub value: Value,
pub expires_in: u64, #[serde(default)]
pub filename: String, #[schema(default = false)]
pub replace: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct RestoreDocumentRequest {
#[schema(nullable)] #[schema(nullable)]
pub content_type: Option<String>, pub folder_id: Option<Uuid>,
pub size_bytes: i64,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateDocumentRequest { pub struct UpdateDocumentRequest {
#[schema(nullable)] #[schema(nullable)]
pub title: Option<String>, pub title: Option<String>,
#[schema(nullable, value_type = Option<String>)]
pub issued_at: Option<Value>,
#[schema(nullable)]
pub metadata: Option<DocumentMetadataUpdate>,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -732,7 +785,6 @@ pub mod schemas {
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentAssignment { pub struct CorrespondentAssignment {
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
@@ -767,12 +819,6 @@ pub mod schemas {
pub removed: usize, pub removed: usize,
} }
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct RemoveCorrespondentParams {
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeRequest { pub struct ReanalyzeRequest {
pub document_ids: Vec<Uuid>, pub document_ids: Vec<Uuid>,
@@ -926,12 +972,19 @@ pub mod schemas {
pub color: Option<Option<String>>, pub color: Option<Option<String>>,
} }
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentUsage {
pub total: i64,
}
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentCatalogEntry { pub struct CorrespondentCatalogEntry {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub metadata: Value, pub metadata: Value,
pub role_counts: Value, pub created_at: String,
pub updated_at: String,
pub usage: CorrespondentUsage,
} }
#[derive(Serialize, Deserialize, ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
+74 -10
View File
@@ -43,6 +43,7 @@ pub struct LoginResponse {
pub access_token: String, pub access_token: String,
pub token_type: String, pub token_type: String,
pub expires_in: i64, pub expires_in: i64,
pub tenant: TenantSnippet,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -51,12 +52,23 @@ pub struct TenantSummary {
pub slug: String, pub slug: String,
} }
#[derive(Serialize)]
pub struct TenantSnippet {
pub id: Uuid,
pub slug: String,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct TenantSelectionResponse { pub struct TenantSelectionResponse {
pub selection_token: String, pub access_token: String,
pub tenants: Vec<TenantSummary>, pub tenants: Vec<TenantSummary>,
} }
#[derive(Serialize)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct TenantSelectionRequest { pub struct TenantSelectionRequest {
pub tenant_id: Uuid, pub tenant_id: Uuid,
@@ -68,9 +80,15 @@ pub async fn login(
) -> AppResult<Response> { ) -> AppResult<Response> {
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
let user: User = dsl::users let user: Option<User> = dsl::users
.filter(dsl::username.eq(&payload.username)) .filter(dsl::username.eq(&payload.username))
.first(&mut conn)?; .first(&mut conn)
.optional()?;
let user = match user {
Some(user) => user,
None => return Err(AppError::unauthorized()),
};
let valid = password::verify_password(&payload.password, &user.password_hash) let valid = password::verify_password(&payload.password, &user.password_hash)
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
@@ -121,7 +139,7 @@ pub async fn login(
.collect(); .collect();
let response = Json(TenantSelectionResponse { let response = Json(TenantSelectionResponse {
selection_token, access_token: selection_token,
tenants, tenants,
}) })
.into_response(); .into_response();
@@ -174,15 +192,19 @@ pub async fn select_tenant(
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>, Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let claims = state let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
.jwt Ok(claims) => claims.sub,
.verify_tenant_selector_token(bearer.token()) Err(_) => state
.map_err(|_| AppError::unauthorized())?; .jwt
.verify_token(bearer.token())
.map(|claims| claims.sub)
.map_err(|_| AppError::unauthorized())?,
};
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(claims.sub)) .filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) .filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants) .inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id) .select(memberships_dsl::id)
@@ -194,7 +216,7 @@ pub async fn select_tenant(
} }
let user: User = dsl::users let user: User = dsl::users
.find(claims.sub) .find(user_id)
.first(&mut conn) .first(&mut conn)
.map_err(AppError::from)?; .map_err(AppError::from)?;
@@ -250,6 +272,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
pub async fn list_tenants(
State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
) -> AppResult<Json<TenantListResponse>> {
let bearer = auth.ok_or_else(AppError::unauthorized)?;
let token = bearer.token();
let user_id = match state.jwt.verify_token(token) {
Ok(claims) => claims.sub,
Err(_) => {
let claims = state
.jwt
.verify_tenant_selector_token(token)
.map_err(|_| AppError::unauthorized())?;
claims.sub
}
};
let mut conn = state.db_unscoped()?;
let tenants = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user_id))
.select((tenant_dsl::id, tenant_dsl::slug))
.load::<(Uuid, String)>(&mut conn)?
.into_iter()
.map(|(id, slug)| TenantSnippet { id, slug })
.collect();
Ok(Json(TenantListResponse { tenants }))
}
fn issue_session( fn issue_session(
state: &AppState, state: &AppState,
conn: &mut PgConnection, conn: &mut PgConnection,
@@ -262,6 +316,12 @@ fn issue_session(
.generate_token(user.id, tenant_id, &user.username) .generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?; .map_err(AppError::from)?;
let tenant_slug: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::slug)
.first(conn)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token(); let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value); let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
@@ -283,6 +343,10 @@ fn issue_session(
access_token, access_token,
token_type: "Bearer".to_string(), token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60, expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: tenant_id,
slug: tenant_slug,
},
}) })
.into_response(); .into_response();
+17 -41
View File
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::HashMap;
use axum::{extract::Path, http::StatusCode, Json}; use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc; use chrono::Utc;
@@ -21,8 +21,6 @@ use crate::{
#[derive(Serialize)] #[derive(Serialize)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(( .group_by(document_correspondents::correspondent_id)
document_correspondents::correspondent_id, .select((document_correspondents::correspondent_id, count_star()))
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?; .load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new(); let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
for (correspondent_id, role, count) in usage_rows { for (correspondent_id, count) in usage_rows {
usage_map usage_map.insert(correspondent_id, count);
.entry(correspondent_id)
.or_default()
.insert(role, count);
} }
let mut response = Vec::with_capacity(correspondents_list.len()); let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list { for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default(); let total = usage_map.remove(&correspondent.id).unwrap_or(0);
response.push(build_summary(correspondent, role_counts)); response.push(build_summary(correspondent, total));
} }
response.into_json() response.into_json()
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
.first(&mut conn) .first(&mut conn)
.one()?; .one()?;
build_summary(correspondent, BTreeMap::new()).into_json() build_summary(correspondent, 0).into_json()
} }
pub async fn update_correspondent( pub async fn update_correspondent(
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
no_content() no_content()
} }
fn build_summary( fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary { CorrespondentSummary {
id: correspondent.id, id: correspondent.id,
name: correspondent.name, name: correspondent.name,
metadata: correspondent.metadata, metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at), created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at), updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage { usage: CorrespondentUsage { total },
total,
by_role: role_counts,
},
} }
} }
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid, tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> { ) -> AppResult<i64> {
let rows: Vec<(String, i64)> = document_correspondents::table let total: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role) .select(count_star())
.select((document_correspondents::role, count_star())) .get_result(conn)?;
.load(conn)?;
let mut map = BTreeMap::new(); Ok(total)
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
} }
File diff suppressed because it is too large Load Diff
-120
View File
@@ -1,120 +0,0 @@
use std::path::Path as FsPath;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::state::AppState;
use crate::utils::time::to_iso;
use super::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
DocumentVersionResponse,
};
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(
version: DocumentVersion,
include_operations_summary: bool,
) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
operations_summary: if include_operations_summary {
Some(version.operations_summary)
} else {
None
},
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub 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,
}
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
@@ -1,61 +0,0 @@
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use super::CorrespondentAssignmentInput;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
pub fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
pub fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
+82 -19
View File
@@ -15,9 +15,10 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
use super::documents::{ use super::documents::{to_document_response, DocumentResponse};
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, use crate::documents::{
to_document_response, DocumentResponse, asset::load_primary_assets, correspondents::load_correspondents_for_documents,
tags::load_tags_for_documents,
}; };
use crate::utils::{ use crate::utils::{
json::{classify_nullable, NullableValue}, json::{classify_nullable, NullableValue},
@@ -109,16 +110,16 @@ pub async fn ensure_folder_path(
let existing: Option<Folder> = if let Some(parent_id) = current_parent { let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
} else { } else {
folders::table folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(name)) .filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn) .first(conn)
.optional()? .optional()?
}; };
@@ -133,13 +134,32 @@ pub async fn ensure_folder_path(
tenant_id, tenant_id,
}; };
diesel::insert_into(folders::table) let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder) .values(&new_folder)
.execute(conn)?; .on_conflict_do_nothing()
.returning(folders::id)
.get_result(conn)
.optional()?;
folders::table.find(new_folder.id).first(conn)? if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?
} else if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)?
}
}; };
current_parent = Some(folder.id); current_parent = Some(folder.id);
last_folder = Some(folder); last_folder = Some(folder);
} }
@@ -164,18 +184,61 @@ pub async fn create_folder(
return Err(AppError::bad_request("name must not be empty")); return Err(AppError::bad_request("name must not be empty"));
} }
let new_folder = NewFolder { let name = payload.name.trim();
id: Uuid::new_v4(),
name: payload.name.trim().to_string(), let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
parent_id: payload.parent_id, folders::table
tenant_id, .filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
}; };
diesel::insert_into(folders::table) let folder: Folder = if let Some(folder) = existing {
.values(&new_folder) folder
.execute(&mut conn)?; } else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: payload.parent_id,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(&mut conn)
.optional()?;
if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?
} else if let Some(parent_id) = payload.parent_id {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)?
}
};
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
Ok(Json(FolderResponse { Ok(Json(FolderResponse {
folder: folder_to_info(folder), folder: folder_to_info(folder),
})) }))
@@ -247,7 +310,7 @@ pub async fn list_folder_contents(
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn); drop(conn);
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?; let primary_versions = load_primary_assets(&state, tenant_id, &docs)?;
let mut documents = Vec::with_capacity(doc_ids.len()); let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
+17 -2
View File
@@ -7,7 +7,10 @@ use axum::{
Router, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::{
cors::{AllowOrigin, CorsLayer},
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
};
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState}; use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
@@ -54,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/refresh", post(auth::refresh)) .route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant)) .route("/select-tenant", post(auth::select_tenant))
.route("/tenants", get(auth::list_tenants))
.route("/me", get(auth::me)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
@@ -78,12 +82,17 @@ pub fn create_router(state: AppState) -> Router<()> {
.delete(documents::delete_document) .delete(documents::delete_document)
.patch(documents::update_document), .patch(documents::update_document),
) )
.route("/:id/download", get(documents::download_document))
.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),
) )
.route("/:id/folder", patch(documents::move_document)) .route("/:id/folder", patch(documents::move_document))
.route("/:id/versions", get(documents::list_document_versions))
.route(
"/:id/versions/:version_id",
get(documents::get_document_version),
)
.route("/:id/restore", post(documents::restore_document))
.route("/:id/tags", post(documents::assign_tags)) .route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag)) .route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route( .route(
@@ -153,4 +162,10 @@ pub fn create_router(state: AppState) -> Router<()> {
.with_state(state) .with_state(state)
.layer(cors) .layer(cors)
.layer(DefaultBodyLimit::max(1024 * 1024 * 512)) .layer(DefaultBodyLimit::max(1024 * 1024 * 512))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
)
} }
+1 -4
View File
@@ -37,11 +37,9 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
document_correspondents (document_id, correspondent_id, role) { document_correspondents (document_id, correspondent_id) {
document_id -> Uuid, document_id -> Uuid,
correspondent_id -> Uuid, correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid, tenant_id -> Uuid,
@@ -69,7 +67,6 @@ diesel::table! {
#[max_length = 64] #[max_length = 64]
checksum -> Varchar, checksum -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid, tenant_id -> Uuid,
} }
+2 -28
View File
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use diesel::prelude::*; use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::json;
use tokio::task; use tokio::task;
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -104,7 +104,7 @@ fn analyze_document(
let tenant_id = document.tenant_id; let tenant_id = document.tenant_id;
let (supported, reason) = determine_thumbnail_support(&document); let (supported, _reason) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document); let ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table let existing_ocr: Option<DocumentAsset> = document_assets::table
@@ -117,32 +117,6 @@ fn analyze_document(
let skip_ocr = existing_ocr.is_some() && !payload.force; let skip_ocr = existing_ocr.is_some() && !payload.force;
let mut summary_map = match version.operations_summary {
Value::Object(map) => map,
_ => Map::new(),
};
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
if let Some(reason) = reason {
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
} else {
summary_map.remove("thumbnail_reason");
}
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
if ocr_supported {
summary_map.remove("ocr_reason");
} else {
summary_map.insert(
"ocr_reason".to_string(),
Value::String("document is not a PDF".into()),
);
}
diesel::update(document_versions::table.find(version.id))
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if supported { if supported {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
+267 -4
View File
@@ -1,15 +1,49 @@
mod common; mod common;
use anyhow::Result; use anyhow::{anyhow, Context, Result};
use axum::http::StatusCode; use axum::body::Body;
use axum::http::{header::SET_COOKIE, StatusCode};
use backend::models::NewUserMembership;
use backend::schema::{tenants, user_memberships};
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
} }
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
}
#[derive(Deserialize)]
struct LoginTenant {
slug: String,
}
#[derive(Deserialize)]
struct LoginResponse {
access_token: String,
tenant: LoginTenant,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantSummary {
tenant_id: Uuid,
slug: String,
}
#[tokio::test] #[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> { async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
let password = "s3cret"; let password = "s3cret";
app.insert_user("alice", password, "admin").await?; app.insert_user("alice", password, "admin").await?;
let token = app.login_token("alice", password).await?; let (login, _) = login_with_session(&app, "alice", password).await?;
let response = app.get("/api/auth/me", Some(&token)).await?; let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?; let user: AuthenticatedUser = serde_json::from_slice(&body)?;
@@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> {
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn login_rejects_unknown_user() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "ghost", "password": "nope" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "unauthorized");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "valid";
app.insert_user("robin", password, "admin").await?;
let payload = json!({ "username": "robin", "password": "wrong" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "unauthorized");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn refresh_rotates_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rotate";
app.insert_user("rita", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
let response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let new_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
assert_eq!(refreshed.tenant.slug, login.tenant.slug);
let me_response = app
.get("/api/auth/me", Some(&refreshed.access_token))
.await?;
assert_eq!(me_response.status(), StatusCode::OK);
let retry = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
// new cookie should differ from old to avoid reuse
assert_ne!(new_cookie, refresh_cookie);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn logout_revokes_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "logout";
app.insert_user("logan", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
let response = app
.post_json_with_cookie(
"/api/auth/logout",
&json!({}),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let cleared_cookie = extract_refresh_cookie(response.headers())?;
assert!(cleared_cookie.ends_with("="));
let after_logout = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn me_requires_authentication() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let unauthenticated = app.get("/api/auth/me", None).await?;
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "multipass";
let user_id = app.insert_user("multipass", password, "admin").await?;
let secondary_slug = "secondary".to_string();
let slug_for_insert = secondary_slug.clone();
let secondary_id = Uuid::new_v4();
app.with_conn(move |conn| {
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(secondary_id),
tenants::slug.eq(&slug_for_insert),
))
.execute(conn)?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id,
tenant_id: secondary_id,
role: "admin".to_string(),
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.execute(conn)?;
Ok(())
})
.await?;
let payload = json!({ "username": "multipass", "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
assert!(selection.tenants.len() >= 2);
let secondary = selection
.tenants
.iter()
.find(|tenant| tenant.slug == secondary_slug)
.map(|t| t.tenant_id)
.context("secondary tenant missing from selection")?;
let select_response = app
.post_json(
"/api/auth/select-tenant",
&json!({ "tenant_id": secondary }),
Some(&selection.access_token),
)
.await?;
assert_eq!(select_response.status(), StatusCode::OK);
let session_cookie = extract_refresh_cookie(select_response.headers())?;
let select_body = body_to_vec(select_response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(login.tenant.slug, secondary_slug);
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(me_response.status(), StatusCode::OK);
let refresh_response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
.await?;
assert_eq!(refresh_response.status(), StatusCode::OK);
app.cleanup().await?;
Ok(())
}
async fn login_with_session(
app: &TestApp,
username: &str,
password: &str,
) -> Result<(LoginResponse, String)> {
let payload = json!({ "username": username, "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
ensure_status(&response, StatusCode::OK)?;
let refresh_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&body)
.map_err(|_| anyhow!("expected login response with session"))?;
Ok((login, refresh_cookie))
}
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
let header_value = headers
.get(SET_COOKIE)
.context("missing set-cookie header")?
.to_str()
.context("invalid set-cookie header")?;
let cookie = header_value
.split(';')
.next()
.context("set-cookie missing cookie value")?
.to_string();
Ok(cookie)
}
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
if response.status() == expected {
Ok(())
} else {
Err(anyhow!(
"unexpected status: got {}, expected {}",
response.status(),
expected
))
}
}
+101 -10
View File
@@ -6,7 +6,7 @@ use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result}; use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use axum::body::Body; use axum::body::Body;
use axum::http::{Method, Request, StatusCode}; use axum::http::{header, Method, Request, StatusCode};
use axum::Router; use axum::Router;
use backend::auth::jwt::JwtService; use backend::auth::jwt::JwtService;
use backend::config::AppConfig; use backend::config::AppConfig;
@@ -328,7 +328,7 @@ impl TestApp {
#[derive(Deserialize)] #[derive(Deserialize)]
struct TenantSelectionResponse { struct TenantSelectionResponse {
selection_token: String, access_token: String,
tenants: Vec<TenantSummary>, tenants: Vec<TenantSummary>,
} }
@@ -350,7 +350,7 @@ impl TestApp {
&SelectTenantPayload { &SelectTenantPayload {
tenant_id: target_tenant, tenant_id: target_tenant,
}, },
Some(&selection.selection_token), Some(&selection.access_token),
) )
.await?; .await?;
@@ -396,6 +396,16 @@ impl TestApp {
path: &str, path: &str,
payload: &T, payload: &T,
token: Option<&str>, token: Option<&str>,
) -> Result<hyper::Response<Body>> {
self.post_json_with_cookie(path, payload, token, None).await
}
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
cookie: Option<&str>,
) -> Result<hyper::Response<Body>> { ) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?; let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder() let mut builder = Request::builder()
@@ -405,6 +415,9 @@ impl TestApp {
if let Some(token) = token { if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}")); builder = builder.header("authorization", format!("Bearer {token}"));
} }
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let request = builder.body(Body::from(body))?; let request = builder.body(Body::from(body))?;
Ok(self Ok(self
.router .router
@@ -479,14 +492,14 @@ impl TestApp {
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
token: &str, token: &str,
) -> Result<hyper::Response<Body>> { ) -> Result<hyper::Response<Body>> {
self.upload_document_with_options( let extras = UploadExtras::empty();
self.upload_document_with_extras(
path, path,
filename, filename,
content_type, content_type,
data, data,
folder_id, folder_id,
None, extras,
None,
token, token,
) )
.await .await
@@ -502,6 +515,36 @@ impl TestApp {
title: Option<&str>, title: Option<&str>,
metadata_json: Option<&str>, metadata_json: Option<&str>,
token: &str, token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras {
title,
metadata_json,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
};
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_extras(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
extras: UploadExtras<'_>,
token: &str,
) -> Result<hyper::Response<Body>> { ) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4()); let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new(); let mut body = Vec::new();
@@ -524,20 +567,46 @@ impl TestApp {
body.extend(b"\r\n"); body.extend(b"\r\n");
} }
if let Some(title_value) = title { if let Some(title_value) = extras.title {
body.extend(format!("--{boundary}\r\n").as_bytes()); body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n"); body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
body.extend(title_value.as_bytes()); body.extend(title_value.as_bytes());
body.extend(b"\r\n"); body.extend(b"\r\n");
} }
if let Some(metadata_value) = metadata_json { if let Some(metadata_value) = extras.metadata_json {
body.extend(format!("--{boundary}\r\n").as_bytes()); body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n"); body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
body.extend(metadata_value.as_bytes()); body.extend(metadata_value.as_bytes());
body.extend(b"\r\n"); body.extend(b"\r\n");
} }
if let Some(tag_ids_value) = extras.tag_ids_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
body.extend(tag_ids_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(correspondents_value) = extras.correspondents_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
body.extend(correspondents_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(issued_at_value) = extras.issued_at {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
body.extend(issued_at_value.as_bytes());
body.extend(b"\r\n");
}
if extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes()); body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder() let builder = Request::builder()
@@ -558,7 +627,7 @@ impl TestApp {
.expect("infallible response")) .expect("infallible response"))
} }
async fn with_conn<F, T>(&self, f: F) -> Result<T> pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
where where
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static, F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
T: Send + 'static, T: Send + 'static,
@@ -575,6 +644,28 @@ impl TestApp {
} }
} }
pub struct UploadExtras<'a> {
pub title: Option<&'a str>,
pub metadata_json: Option<&'a str>,
pub tag_ids_json: Option<&'a str>,
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: bool,
}
impl<'a> UploadExtras<'a> {
pub fn empty() -> Self {
Self {
title: None,
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
}
}
}
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> { pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
DB_LOCK.lock().await DB_LOCK.lock().await
} }
@@ -625,7 +716,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
Ok(()) Ok(())
} }
fn hash_password(password: &str) -> Result<String> { pub fn hash_password(password: &str) -> Result<String> {
use argon2::password_hash::{PasswordHasher, SaltString}; use argon2::password_hash::{PasswordHasher, SaltString};
use argon2::Argon2; use argon2::Argon2;
+276
View File
@@ -0,0 +1,276 @@
mod common;
use anyhow::Result;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentSummary,
}
#[derive(Deserialize)]
struct DocumentSummary {
id: Uuid,
title: String,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentSummary>,
}
#[derive(Deserialize)]
struct DocumentCorrespondentSummary {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
struct TestContext {
app: TestApp,
token: String,
document_ids: Vec<Uuid>,
sender_id: Uuid,
receiver_id: Uuid,
}
impl TestContext {
const SENDER_NAME: &'static str = "Acme Corp";
const RECEIVER_NAME: &'static str = "Bank Ltd";
async fn new(prefix: &str) -> Result<Self> {
let app = TestApp::new().await?;
let username = format!("{prefix}_user");
let password = format!("{prefix}_pw");
app.insert_user(&username, &password, "admin").await?;
let token = app.login_token(&username, &password).await?;
let first_id =
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
let second_id =
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
Ok(Self {
app,
token,
document_ids: vec![first_id, second_id],
sender_id,
receiver_id,
})
}
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
self.assign_with_action(correspondent_ids, None).await
}
async fn assign_with_action(
&self,
correspondent_ids: &[Uuid],
action: Option<&str>,
) -> Result<BulkCorrespondentResult> {
let assignments: Vec<_> = correspondent_ids
.iter()
.map(|id| json!({ "correspondent_id": id }))
.collect();
let mut payload = json!({
"document_ids": self.document_ids,
"assignments": assignments,
});
if let Some(action) = action {
if let Some(obj) = payload.as_object_mut() {
obj.insert("action".to_string(), json!(action));
}
}
let response = self
.app
.post_json(
"/api/documents/bulk/correspondents",
&payload,
Some(&self.token),
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn fetch_correspondents(
&self,
document_id: Uuid,
) -> Result<Vec<DocumentCorrespondentSummary>> {
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
Ok(detail.document.correspondents)
}
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
create_correspondent(&self.app, &self.token, name).await
}
}
#[tokio::test]
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_add").await?;
let result = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(result.assigned, 4);
assert_eq!(result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::SENDER_NAME));
assert!(names.contains(&TestContext::RECEIVER_NAME));
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.sender_id));
assert!(ids.contains(&context.receiver_id));
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_idempotent").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let repeat = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(repeat.assigned, 0);
assert_eq!(repeat.removed, 0);
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_remove").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let removal = context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
assert_eq!(removal.assigned, 0);
assert_eq!(removal.removed, 2);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 1);
let entry = &correspondents[0];
assert_eq!(entry.id, context.receiver_id);
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_append").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
let charlie_name = "Charlie";
let charlie_id = context.create_correspondent(charlie_name).await?;
let add_result = context.assign(&[charlie_id]).await?;
assert_eq!(add_result.assigned, 2);
assert_eq!(add_result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 2);
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.receiver_id));
assert!(ids.contains(&charlie_id));
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::RECEIVER_NAME));
assert!(names.contains(&charlie_name));
}
context.app.cleanup().await?;
Ok(())
}
async fn upload_document(
app: &TestApp,
token: &str,
filename: &str,
contents: &[u8],
) -> Result<Uuid> {
let response = app
.upload_document(
"/api/documents",
filename,
"text/plain",
contents,
None,
token,
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
Ok(detail.document.id)
}
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
let response = app
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn fetch_document_detail(
app: &TestApp,
token: &str,
document_id: Uuid,
) -> Result<DocumentDetail> {
let response = app
.get(&format!("/api/documents/{document_id}"), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
File diff suppressed because it is too large Load Diff
+114 -2
View File
@@ -232,14 +232,14 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
let first_resp = app let first_resp = app
.post_json("/api/folders/path", &base_path, Some(&token)) .post_json("/api/folders/path", &base_path, Some(&token))
.await?; .await?;
assert_eq!(first_resp.status(), StatusCode::OK); assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?; let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app let second_resp = app
.post_json("/api/folders/path", &base_path, Some(&token)) .post_json("/api/folders/path", &base_path, Some(&token))
.await?; .await?;
assert_eq!(second_resp.status(), StatusCode::OK); assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?; let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_folder.folder.id, first_folder.folder.id); assert_eq!(second_folder.folder.id, first_folder.folder.id);
@@ -293,6 +293,118 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn create_folder_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "idempotent";
app.insert_user("folders-idem", password, "admin").await?;
let token = app.login_token("folders-idem", password).await?;
let payload = CreateFolder {
name: "Archive",
parent_id: None,
};
let first_resp = app
.post_json("/api/folders", &payload, Some(&token))
.await?;
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders", &payload, Some(&token))
.await?;
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(first_folder.folder.id, second_folder.folder.id);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let occurrences = root
.subfolders
.iter()
.filter(|folder| folder.id == first_folder.folder.id)
.count();
assert_eq!(occurrences, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_folder_path_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", password, "admin").await?;
let token = app.login_token("path-admin", password).await?;
let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"];
let payload = EnsureFolderPath {
parent_id: None,
segments: &segments,
};
let first_resp = app
.post_json("/api/folders/path", &payload, Some(&token))
.await?;
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &payload, Some(&token))
.await?;
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(first_folder.folder.id, second_folder.folder.id);
// Verify intermediate folders are not duplicated
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let root_occurrences = root
.subfolders
.iter()
.filter(|folder| folder.name == segments[0])
.count();
assert_eq!(root_occurrences, 1);
let level_one = root
.subfolders
.iter()
.find(|folder| folder.name == segments[0])
.map(|folder| folder.id)
.expect("root segment not created");
let level_one_contents = app
.get(
&format!("/api/folders/{}/contents", level_one),
Some(&token),
)
.await?;
let level_one_body = body_to_vec(level_one_contents.into_body()).await?;
let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?;
let level_one_occurrences = level_one_folders
.subfolders
.iter()
.filter(|folder| folder.name == segments[1])
.count();
assert_eq!(level_one_occurrences, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn folder_rename_updates_name_and_child_paths() -> Result<()> { async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
+103 -7
View File
@@ -2,11 +2,23 @@ mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp}; use backend::models::{NewUser, NewUserMembership, Tag};
use backend::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
users::dsl as users_dsl,
};
use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct DocumentDetail { struct DocumentDetail {
document: DocumentInfo, document: DocumentInfo,
@@ -61,12 +73,6 @@ async fn tag_assignment_flow() -> Result<()> {
let upload_body = body_to_vec(upload.into_body()).await?; let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
let create_tag = app let create_tag = app
.post_json( .post_json(
"/api/tags", "/api/tags",
@@ -172,3 +178,93 @@ async fn tag_assignment_flow() -> Result<()> {
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn tags_are_isolated_between_tenants() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password_a = "tenant-a";
app.insert_user("alice", password_a, "admin").await?;
let token_a = app.login_token("alice", password_a).await?;
let shared_label = "Shared Label";
let create_a = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: shared_label,
color: Some("#123456"),
},
Some(&token_a),
)
.await?;
assert_eq!(create_a.status(), StatusCode::OK);
let tenant_b_id = Uuid::new_v4();
let user_b_id = Uuid::new_v4();
let password_b = "tenant-b";
app.with_conn(move |conn| {
let storage_root = format!("test-tenants/{tenant_b_id}/");
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(tenant_b_id),
tenants_dsl::slug.eq("tenant-b"),
tenants_dsl::storage_root.eq(Some(storage_root)),
))
.execute(conn)?;
let password_hash = hash_password(password_b)?;
let new_user = NewUser {
id: user_b_id,
username: "bob".to_string(),
password_hash,
};
diesel::insert_into(users_dsl::users)
.values(&new_user)
.execute(conn)?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user_b_id,
tenant_id: tenant_b_id,
role: "admin".to_string(),
};
diesel::insert_into(memberships_dsl::user_memberships)
.values(&membership)
.execute(conn)?;
Ok::<_, anyhow::Error>(())
})
.await?;
let token_b = app.login_token("bob", password_b).await?;
let create_b = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: shared_label,
color: Some("#654321"),
},
Some(&token_b),
)
.await?;
assert_eq!(create_b.status(), StatusCode::OK);
app.with_conn(move |conn| {
let tags: Vec<Tag> = tags_dsl::tags
.filter(tags_dsl::label.eq(shared_label))
.order(tags_dsl::tenant_id.asc())
.load(conn)?;
assert_eq!(tags.len(), 2);
assert_ne!(tags[0].tenant_id, tags[1].tenant_id);
Ok::<_, anyhow::Error>(())
})
.await?;
Ok(())
}
+11 -9
View File
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
Authentication Authentication
-------------- --------------
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). - POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). - POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie. - POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
- GET /api/auth/me - Return the authenticated principal payload. - GET /api/auth/me - Return the authenticated principal payload.
@@ -16,22 +16,24 @@ Health
Documents Documents
--------- ---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. - GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata. - GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document. - POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
- POST /api/documents/bulk/move - Move multiple documents to a target folder. - POST /api/documents/bulk/move - Move multiple documents to a target folder.
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents. - POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs. - POST /api/documents/bulk/correspondents - Bulk correspondent actions. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents.
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents. - POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
- GET /api/documents/:id - Retrieve metadata and current version details for a document. - GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title). - PATCH /api/documents/:id - Update document metadata (currently title).
- DELETE /api/documents/:id - Soft-delete a document. - DELETE /api/documents/:id - Soft-delete a document.
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
- PATCH /api/documents/:id/folder - Move a document to another folder. - PATCH /api/documents/:id/folder - Move a document to another folder.
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
- GET /api/documents/:id/versions - List version history for a document.
- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version.
- POST /api/documents/:id/tags - Assign one or more tags to a document. - POST /api/documents/:id/tags - Assign one or more tags to a document.
- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document. - DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document.
- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`. - POST /api/documents/:id/correspondents - Assign correspondents (`assignments[]` with `correspondent_id`; optional `replace=true` overwrites existing assignments).
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string). - DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment.
Document Assets Document Assets
--------------- ---------------
@@ -62,7 +64,7 @@ Tags
Correspondents Correspondents
-------------- --------------
- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`). - GET /api/correspondents - List correspondents with usage totals.
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON). - POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
- PATCH /api/correspondents/:id - Update name and/or metadata. - PATCH /api/correspondents/:id - Update name and/or metadata.
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document. - DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
+10
View File
@@ -8,6 +8,7 @@
"name": "papercrate-frontend", "name": "papercrate-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0", "@tabler/icons-react": "3.11.0",
"axios": "1.7.7", "axios": "1.7.7",
"react": "18.3.1", "react": "18.3.1",
@@ -1852,6 +1853,15 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+3 -2
View File
@@ -9,6 +9,7 @@
"lint": "echo \"No linting configured\"" "lint": "echo \"No linting configured\""
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8",
"@tabler/icons-react": "3.11.0", "@tabler/icons-react": "3.11.0",
"axios": "1.7.7", "axios": "1.7.7",
"react": "18.3.1", "react": "18.3.1",
@@ -19,6 +20,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",
@@ -26,7 +28,6 @@
"style-loader": "4.0.0", "style-loader": "4.0.0",
"webpack": "5.95.0", "webpack": "5.95.0",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0", "webpack-dev-server": "5.1.0"
"@svgr/webpack": "8.1.0"
} }
} }
@@ -6,6 +6,20 @@
min-height: 0; min-height: 0;
} }
.skeuo-item {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
width: auto;
cursor: grab;
touch-action: none;
transform-origin: center center;
transition: box-shadow 0.16s ease;
outline: none;
will-change: transform;
}
.skeuo-shell { .skeuo-shell {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -13,57 +27,6 @@
grid-column: 2 / -1; grid-column: 2 / -1;
min-height: 0; min-height: 0;
position: relative; position: relative;
background-color: var(--surface-subtle);
}
.skeuo-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem 0.5rem;
background-color: var(--surface-subtle);
}
.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 { .skeuo-canvas {
@@ -84,29 +47,14 @@
font-size: 0.95rem; 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 { .skeuo-item__body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
transition: width 0.28s ease, height 0.28s ease; justify-content: center;
flex-grow: 1;
} }
.skeuo-item:focus-visible { .skeuo-item:focus-visible {
@@ -119,13 +67,9 @@
transition: none; transition: none;
} }
.skeuo-item.is-zoomed {
cursor: pointer;
}
.skeuo-item.is-tag-target .skeuo-item__card { .skeuo-item.is-tag-target .skeuo-item__card {
outline: 1em dashed var(--accent); outline: 0.35rem dashed var(--accent);
outline-offset: 1.41em; outline-offset: 0.35rem;
} }
.skeuo-item.is-tag-pending .skeuo-item__card { .skeuo-item.is-tag-pending .skeuo-item__card {
@@ -154,50 +98,39 @@
transition: transform 0.28s ease; transition: transform 0.28s ease;
} }
.skeuo-tag {
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding: 0.28rem 0.7rem;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg);
font-size: 1rem;
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 { .tag-chip--draggable {
font-size: 0.75rem; user-select: none;
padding: 0.18rem 0.55rem;
pointer-events: auto; pointer-events: auto;
cursor: grab; cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease; transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.18);
} }
.skeuo-tag.is-drag-hidden { .tag-chip--draggable:active {
cursor: grabbing;
}
.tag-chip--draggable.is-drag-hidden {
opacity: 0.4; opacity: 0.4;
pointer-events: none; pointer-events: none;
} }
.skeuo-tag span { .skeuo-item__tags .tag-chip {
display: block; font-size: 0.85rem;
pointer-events: none; padding: 0.18rem 0.55rem;
user-select: none; gap: 0.3rem;
} }
.skeuo-card__nav { .skeuo-card__nav {
position: absolute; position: absolute;
bottom: calc(3em * 0.707 * var(--nav-scale)); bottom: 1.8rem;
left: 50%; left: 50%;
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1))); transform: translateX(-50%);
transform-origin: center; transform-origin: center;
display: flex; display: flex;
gap: 2rem; gap: 1.5rem;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -212,10 +145,10 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 3.3em; width: 2.4em;
height: 3.3em; height: 2.4em;
padding: 0.36em; padding: 0.25em;
border-radius: 3.3em; border-radius: 50%;
border: none; border: none;
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
color: #fff; color: #fff;
@@ -242,8 +175,8 @@
height: 100%; height: 100%;
} }
.skeuo-item__tags .skeuo-tag.is-tear-pending { .skeuo-item__tags .tag-chip--tear-pending {
opacity: 0.4; opacity: 0.35;
} }
body.skeuo-cursor-remove, body.skeuo-cursor-remove,
@@ -261,9 +194,17 @@ body.skeuo-cursor-remove * {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); width: 100%;
height: 100%;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
overflow: hidden; overflow: hidden;
--nav-scale: 1; }
.skeuo-item__card img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
} }
.skeuo-item__card--empty { .skeuo-item__card--empty {
@@ -305,6 +246,7 @@ body.skeuo-cursor-remove * {
color: #3b3b3b; color: #3b3b3b;
max-width: 90%; max-width: 90%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap; word-break: break-word;
white-space: normal;
} }
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
export default CORRESPONDENT_ROLES;
@@ -107,22 +107,15 @@ function CorrespondentsPanel({
return '0'; return '0';
} }
const total = typeof usage.total === 'number' ? usage.total : 0; const total = typeof usage.total === 'number' ? usage.total : 0;
const entries = usage.by_role ? Object.entries(usage.by_role) : []; return total.toString();
if (!entries.length) {
return total.toString();
}
const roleSummary = entries
.map(([role, count]) => `${role}: ${count}`)
.join(', ');
return `${total} (${roleSummary})`;
}, []); }, []);
return ( return (
<section className="correspondents-panel column"> <section className="correspondents-panel">
<div className="column-header"> <div className="panel-section__header">
<div className="column-header__titles"> <div className="panel-section__titles">
<h2>Correspondents</h2> <h2>Correspondents</h2>
<div className="column-subtitle">{correspondents.length} total</div> <div className="panel-section__subtitle">{correspondents.length} total</div>
</div> </div>
<div className="header-actions correspondents-actions"> <div className="header-actions correspondents-actions">
<form className="correspondents-actions__form" onSubmit={handleCreate}> <form className="correspondents-actions__form" onSubmit={handleCreate}>
@@ -147,7 +140,7 @@ function CorrespondentsPanel({
</button> </button>
</div> </div>
</div> </div>
<div className="column-body tags-panel__body"> <div className="panel-section__body tags-panel__body">
{correspondents.length === 0 ? ( {correspondents.length === 0 ? (
<div className="empty-state">No correspondents created yet.</div> <div className="empty-state">No correspondents created yet.</div>
) : ( ) : (
+17
View File
@@ -0,0 +1,17 @@
import React, { createContext, useContext } from 'react';
const DesktopContext = createContext(null);
export const DesktopProvider = ({ value, children }) => (
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
);
export const useDesktopContext = () => {
const context = useContext(DesktopContext);
if (!context) {
throw new Error('useDesktopContext must be used within a DesktopProvider');
}
return context;
};
export default DesktopContext;
+17
View File
@@ -0,0 +1,17 @@
export const preventAll = (event) => {
if (!event) {
return;
}
try {
event.preventDefault();
} catch (
// eslint-disable-next-line no-empty
error
) {}
try {
event.stopPropagation();
} catch (
// eslint-disable-next-line no-empty
error
) {}
};
+8
View File
@@ -0,0 +1,8 @@
export const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
+248
View File
@@ -0,0 +1,248 @@
import { useCallback, useRef } from 'react';
import { useDesktopContext } from './context';
import { preventAll } from './events';
import { clamp, formatTransform } from './math';
const useDocumentDrag = () => {
const {
layoutRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
syncLayoutSnapshot,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings,
} = useDesktopContext();
const dragStateRef = useRef(null);
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const finishDrag = useCallback(
(pointerId) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== pointerId) {
return;
}
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot();
},
[setDraggingId, syncLayoutSnapshot],
);
const handlePointerDown = useCallback(
(event, docId) => {
if (debugDrag) {
console.log(
'[skeuo] handlePointerDown fired for doc',
docId,
'button',
event.button,
'pointerType',
event.pointerType,
'pointerId',
event.pointerId,
);
}
preventAll(event);
const entry = layoutRef.current.get(docId) || null;
const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
const normalizedBaseScale =
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2;
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
layoutRef.current.set(docId, { ...entry, centerX, centerY });
}
bringToFront(docId);
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = {
docId,
pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
moved: false,
locked: false,
width: docWidth,
height: docHeight,
dragScale: 1,
baseScale: normalizedBaseScale,
capturedTarget,
};
setDraggingId(docId);
},
[
bringToFront,
canvasPadding,
documentLookup,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
setDraggingId,
debugDrag,
],
);
const handlePointerMove = useCallback(
(event) => {
const state = dragStateRef.current;
if (!state) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId);
}
return;
}
if (state.pointerId !== event.pointerId) {
if (debugDrag) {
console.log(
'[skeuo] handlePointerMove: pointer mismatch expected',
state.pointerId,
'got',
event.pointerId,
);
}
return;
}
preventAll(event);
if (state.locked) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId);
}
return;
}
const entry = layoutRef.current.get(state.docId);
if (!entry) {
return;
}
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
const prevCenterX = typeof entry.centerX === 'number' ? entry.centerX : state.originCenterX;
const prevCenterY = typeof entry.centerY === 'number' ? entry.centerY : state.originCenterY;
if (Math.abs(clampedCenterX - prevCenterX) < 0.5 && Math.abs(clampedCenterY - prevCenterY) < 0.5) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId);
}
return;
}
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
layoutRef.current.set(state.docId, updated);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
clampedCenterX - state.width / 2,
clampedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
}
state.moved = true;
if (debugDrag) {
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
}
recalcVisibleDocIds();
},
[
canvasPadding,
canvasSize.height,
canvasSize.width,
defaultCanvasHeight,
defaultCanvasWidth,
itemRefs,
layoutRef,
recalcVisibleDocIds,
debugDrag,
],
);
const handlePointerUp = useCallback(
(event) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) {
const moved = Boolean(state.moved);
const docId = state.docId;
const shouldOpen = !moved && event.detail >= 2;
finishDrag(event.pointerId);
if (shouldOpen) {
const originInfo = {
rotation: state.rotation || 0,
scale: state.baseScale || 1,
width: state.width,
height: state.height,
};
openOverlayForDoc(docId, originInfo);
}
return;
}
finishDrag(event.pointerId);
},
[finishDrag, openOverlayForDoc],
);
const handlePointerCancel = useCallback(
(event) => {
finishDrag(event.pointerId);
},
[finishDrag],
);
return {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
};
};
export default useDocumentDrag;
+429 -137
View File
@@ -1,16 +1,23 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import {
DownloadIcon,
EditIcon,
ArrowLeftIcon,
ArrowRightIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
TextScanIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { CORRESPONDENT_ROLES } from '../constants/correspondents'; import PreviewZoomOverlay from './PreviewZoomOverlay';
const MAX_PREVIEW_STACK_ITEMS = 15; const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase();
const derivePreviewOrientation = (metadata) => { const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width); const width = Number(metadata?.width);
const height = Number(metadata?.height); const height = Number(metadata?.height);
@@ -20,56 +27,41 @@ const derivePreviewOrientation = (metadata) => {
return 'landscape'; return 'landscape';
}; };
const formatRoleLabel = (role) => {
const normalized = normalizeRole(role);
if (!normalized) return 'Other';
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
};
const compareCorrespondents = (a, b) => {
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
if (rankedA !== rankedB) {
return rankedA - rankedB;
}
return (a.name || '').localeCompare(b.name || '');
};
const sortCorrespondents = (entries = []) => const sortCorrespondents = (entries = []) =>
entries entries
.map((entry) => ({ .map((entry) => ({
id: entry.id, id: entry.id,
name: entry.name || '', name: entry.name || '',
role: normalizeRole(entry.role), count: entry.count,
})) }))
.sort(compareCorrespondents); .sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => ( const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
<div className="correspondent-list"> <div className="correspondent-list">
{entries.length ? ( {entries.length ? (
entries.map((entry) => ( entries.map((entry, index) => {
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill"> const key = entry.id ?? `${entry.name}-${index}`;
<span className="correspondent-pill__label"> return (
<strong>{formatRoleLabel(entry.role)}</strong> <span key={key} className="correspondent-pill">
<span> <span className="correspondent-pill__label">
{entry.name} <span>
{showCount && entry.count ? ` (${entry.count})` : ''} {entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
</span>
</span> </span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name}`}
>
×
</button>
) : null}
</span> </span>
{onRemove ? ( );
<button })
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
>
×
</button>
) : null}
</span>
))
) : ( ) : (
<span className="meta">No correspondents yet.</span> <span className="meta">No correspondents yet.</span>
)} )}
@@ -95,11 +87,18 @@ const TagSection = ({
tags.map((tag) => { tags.map((tag) => {
const key = tag.id ?? tag.label; const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color); const style = getTagColorStyle(tag.color);
const removable = Boolean(onRemove);
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
return ( return (
<span key={key} className="tag-pill" style={style || undefined}> <span key={key} className={className} style={style || undefined}>
{tag.label}{' '} <span className="tag-chip__label">{tag.label}</span>
{onRemove ? ( {removable ? (
<button type="button" onClick={() => onRemove(tag)}> <button
type="button"
className="tag-chip__remove"
onClick={() => onRemove(tag)}
aria-label={`Remove tag ${tag.label}`}
>
× ×
</button> </button>
) : null} ) : null}
@@ -157,11 +156,9 @@ const CorrespondentSection = ({
event.preventDefault(); event.preventDefault();
const form = event.currentTarget; const form = event.currentTarget;
const nameInput = form.elements.correspondent; const nameInput = form.elements.correspondent;
const roleSelect = form.elements.role;
const value = nameInput.value.trim(); const value = nameInput.value.trim();
const role = roleSelect.value;
if (!value) return; if (!value) return;
onAdd({ name: value, role, input: nameInput }); onAdd({ name: value, input: nameInput });
form.reset(); form.reset();
}} }}
> >
@@ -170,13 +167,6 @@ const CorrespondentSection = ({
placeholder={addPlaceholder} placeholder={addPlaceholder}
list={datalistId} list={datalistId}
/> />
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
{CORRESPONDENT_ROLES.map((role) => (
<option key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
<button type="submit">{addButtonLabel}</button> <button type="submit">{addButtonLabel}</button>
{datalistId ? ( {datalistId ? (
<datalist id={datalistId}> <datalist id={datalistId}>
@@ -208,6 +198,7 @@ const PreviewStack = ({
emptyMessage = 'Preview unavailable', emptyMessage = 'Preview unavailable',
onItemActivate, onItemActivate,
onOpenPreview, onOpenPreview,
onZoomPreview,
activeItemId = null, activeItemId = null,
}) => { }) => {
if (!items.length) { if (!items.length) {
@@ -240,7 +231,11 @@ const PreviewStack = ({
zIndex: preparedItems.length - index, zIndex: preparedItems.length - index,
transform, transform,
}} }}
aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined} aria-hidden={
hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview
? 'true'
: undefined
}
> >
<img <img
src={entry.url} src={entry.url}
@@ -248,19 +243,31 @@ const PreviewStack = ({
className="preview-stack__image" className="preview-stack__image"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (isFront && onOpenPreview) { if (isFront) {
onOpenPreview(entry.id); if (onZoomPreview) {
onZoomPreview(entry);
} else if (onOpenPreview) {
onOpenPreview(entry.id);
} else if (onItemActivate) {
onItemActivate(entry.id);
}
} else if (onItemActivate) { } else if (onItemActivate) {
onItemActivate(entry.id); onItemActivate(entry.id);
} }
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (!onItemActivate && !onOpenPreview) return; if (!onItemActivate && !onOpenPreview && !onZoomPreview) return;
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isFront && onOpenPreview) { if (isFront) {
onOpenPreview(entry.id); if (onZoomPreview) {
onZoomPreview(entry);
} else if (onOpenPreview) {
onOpenPreview(entry.id);
} else {
onItemActivate?.(entry.id);
}
} else { } else {
onItemActivate?.(entry.id); onItemActivate?.(entry.id);
} }
@@ -297,9 +304,26 @@ const DetailPanel = ({
onCorrespondentAdd, onCorrespondentAdd,
onCorrespondentRemove, onCorrespondentRemove,
resolveApiPath, resolveApiPath,
onFolderNavigate = null,
resolveFolderPath = null,
onClose = () => {},
}) => { }) => {
const selectedCount = selectedDocuments.length; const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
const singleDocId = singleDoc?.id || null;
const selectionKey = useMemo(
() => selectedDocuments.map((doc) => doc?.id ?? '').join('|'),
[selectedDocuments],
);
const singleDownloadHref = useMemo(() => {
if (!singleDoc) return null;
const downloadPath = singleDoc.current_version?.download_path;
if (!downloadPath || !resolveApiPath) {
return null;
}
return resolveApiPath(downloadPath);
}, [singleDoc, resolveApiPath]);
const [titleEditDocId, setTitleEditDocId] = useState(null); const [titleEditDocId, setTitleEditDocId] = useState(null);
const [titleDraft, setTitleDraft] = useState(''); const [titleDraft, setTitleDraft] = useState('');
@@ -309,6 +333,7 @@ const DetailPanel = ({
const [ocrUrl, setOcrUrl] = useState(null); const [ocrUrl, setOcrUrl] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false); const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null); const [ocrError, setOcrError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null);
useEffect(() => { useEffect(() => {
if (!singleDoc) { if (!singleDoc) {
@@ -338,6 +363,10 @@ const DetailPanel = ({
setOcrUrl(null); setOcrUrl(null);
}, [singleDoc?.id]); }, [singleDoc?.id]);
useEffect(() => {
setZoomedPreview(null);
}, [selectionKey]);
const startTitleEdit = useCallback(() => { const startTitleEdit = useCallback(() => {
if (!singleDoc) return; if (!singleDoc) return;
setTitleEditDocId(singleDoc.id); setTitleEditDocId(singleDoc.id);
@@ -500,6 +529,7 @@ const DetailPanel = ({
}, [selectedDocuments]); }, [selectedDocuments]);
const stackTopDocument = stackDocuments[0] || null; const stackTopDocument = stackDocuments[0] || null;
const stackTopDocId = stackTopDocument?.id || null;
const stackPreviewNavigator = useAssetNavigator({ const stackPreviewNavigator = useAssetNavigator({
document: stackTopDocument, document: stackTopDocument,
assetType: 'preview', assetType: 'preview',
@@ -623,6 +653,20 @@ const DetailPanel = ({
return sortCorrespondents(singleDoc.correspondents || []); return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]); }, [singleDoc]);
const singleFolderPath = useMemo(() => {
if (!singleDoc?.folder_id) {
return null;
}
if (typeof resolveFolderPath !== 'function') {
return null;
}
const segments = resolveFolderPath(singleDoc.folder_id);
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
return null;
}
return segments;
}, [singleDoc?.folder_id, resolveFolderPath]);
const bulkCorrespondents = useMemo(() => { const bulkCorrespondents = useMemo(() => {
if (selectedDocuments.length <= 1) { if (selectedDocuments.length <= 1) {
const doc = selectedDocuments[0]; const doc = selectedDocuments[0];
@@ -634,17 +678,14 @@ const DetailPanel = ({
if (!doc?.id) return; if (!doc?.id) return;
(doc.correspondents || []).forEach((entry) => { (doc.correspondents || []).forEach((entry) => {
if (!entry?.id) return; if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role); if (!map.has(entry.id)) {
const key = `${entry.id}:${normalizedRole}`; map.set(entry.id, {
if (!map.has(key)) {
map.set(key, {
id: entry.id, id: entry.id,
name: entry.name || '', name: entry.name || '',
role: normalizedRole,
documentIds: new Set(), documentIds: new Set(),
}); });
} }
map.get(key).documentIds.add(doc.id); map.get(entry.id).documentIds.add(doc.id);
}); });
}); });
@@ -652,23 +693,20 @@ const DetailPanel = ({
.map((entry) => ({ .map((entry) => ({
id: entry.id, id: entry.id,
name: entry.name, name: entry.name,
role: entry.role,
documentIds: [...entry.documentIds], documentIds: [...entry.documentIds],
count: entry.documentIds.size, count: entry.documentIds.size,
})) }))
.sort(compareCorrespondents); .sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}, [selectedDocuments]); }, [selectedDocuments]);
const handleBulkCorrespondentRemove = useCallback( const handleBulkCorrespondentRemove = useCallback(
(entry) => { (entry) => {
if (!entry?.id) return; if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
if (onBulkCorrespondentRemove) { if (onBulkCorrespondentRemove) {
return onBulkCorrespondentRemove({ return onBulkCorrespondentRemove({
assignments: [ assignments: [
{ {
correspondent_id: entry.id, correspondent_id: entry.id,
role: normalizedRole,
}, },
], ],
documentIds: entry.documentIds, documentIds: entry.documentIds,
@@ -679,11 +717,7 @@ const DetailPanel = ({
const targets = entry.documentIds && entry.documentIds.length const targets = entry.documentIds && entry.documentIds.length
? entry.documentIds ? entry.documentIds
: selectedDocuments : selectedDocuments
.filter((doc) => .filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
(doc.correspondents || []).some(
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
),
)
.map((doc) => doc.id); .map((doc) => doc.id);
return Promise.all( return Promise.all(
@@ -691,23 +725,185 @@ const DetailPanel = ({
onCorrespondentRemove({ onCorrespondentRemove({
documentId, documentId,
correspondentId: entry.id, correspondentId: entry.id,
role: normalizedRole,
}), }),
), ),
).catch(() => {}); ).catch(() => {});
}, },
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], [onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
); );
const openZoomPreview = useCallback((config) => {
if (!config) return;
setZoomedPreview({
mode: config.mode,
docId: config.docId ?? null,
});
}, []);
const closeZoomPreview = useCallback(() => {
setZoomedPreview(null);
}, []);
const handleSingleZoom = useCallback(
(entry) => {
if (!singleHasPreview) return;
const targetId = entry?.id ?? singleDocId;
if (!targetId) return;
openZoomPreview({ mode: 'single', docId: targetId });
},
[openZoomPreview, singleHasPreview, singleDocId],
);
const handleStackZoom = useCallback(
(entry) => {
if (!stackTopDocId || entry?.id !== stackTopDocId) return;
if (!topHasPreview) return;
openZoomPreview({ mode: 'stack', docId: stackTopDocId });
},
[openZoomPreview, stackTopDocId, topHasPreview],
);
const zoomDisplay = useMemo(() => {
if (!zoomedPreview) {
return null;
}
if (
zoomedPreview.mode === 'single' &&
singleDocId &&
singleDoc &&
singleHasPreview &&
zoomedPreview.docId === singleDocId
) {
return {
url: singlePreviewNavigator.currentUrl,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
canGoPrev:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
canGoNext:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
goPrev: singlePreviewNavigator.goPrev,
goNext: singlePreviewNavigator.goNext,
};
}
if (
zoomedPreview.mode === 'stack' &&
stackTopDocId &&
stackTopDocument &&
zoomedPreview.docId === stackTopDocId &&
topHasPreview
) {
return {
url: stackPreviewNavigator.currentUrl,
alt: stackTopDocument.title || stackTopDocument.original_name || 'Document preview',
canGoPrev:
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev),
canGoNext:
topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext),
goPrev: stackPreviewNavigator.goPrev,
goNext: stackPreviewNavigator.goNext,
};
}
return null;
}, [
zoomedPreview,
singleDoc,
singleDocId,
singleHasPreview,
singlePreviewNavigator.currentUrl,
singlePreviewNavigator.canGoPrev,
singlePreviewNavigator.canGoNext,
singlePreviewNavigator.goPrev,
singlePreviewNavigator.goNext,
singleEffectiveCardinality,
stackTopDocument,
stackTopDocId,
topHasPreview,
stackPreviewNavigator.currentUrl,
stackPreviewNavigator.canGoPrev,
stackPreviewNavigator.canGoNext,
stackPreviewNavigator.goPrev,
stackPreviewNavigator.goNext,
topEffectiveCardinality,
]);
useEffect(() => {
if (zoomedPreview && !zoomDisplay) {
setZoomedPreview(null);
}
}, [zoomedPreview, zoomDisplay]);
useEffect(() => {
if (typeof ensureAssetUrl !== 'function') {
return;
}
const warmNavigator = (navigator) => {
const {
documentId,
asset,
ordinal,
canGoPrev,
canGoNext,
cardinality,
} = navigator;
if (!documentId || !asset || !Number.isFinite(ordinal)) {
return;
}
const requests = [];
if (canGoPrev) {
const prevOrdinal = Math.max(1, ordinal - 1);
if (!cardinality || prevOrdinal <= cardinality) {
requests.push(
ensureAssetUrl(documentId, asset, {
start: prevOrdinal,
limit: 1,
objectOrdinal: prevOrdinal,
}),
);
}
}
if (canGoNext) {
const nextOrdinal = ordinal + 1;
if (!cardinality || nextOrdinal <= cardinality) {
requests.push(
ensureAssetUrl(documentId, asset, {
start: nextOrdinal,
limit: 1,
objectOrdinal: nextOrdinal,
}),
);
}
}
requests.forEach((promise) => promise?.catch?.(() => {}));
};
warmNavigator(singlePreviewNavigator);
warmNavigator(stackPreviewNavigator);
}, [
ensureAssetUrl,
singlePreviewNavigator.documentId,
singlePreviewNavigator.asset,
singlePreviewNavigator.ordinal,
singlePreviewNavigator.canGoPrev,
singlePreviewNavigator.canGoNext,
stackPreviewNavigator.documentId,
stackPreviewNavigator.asset,
stackPreviewNavigator.ordinal,
stackPreviewNavigator.canGoPrev,
stackPreviewNavigator.canGoNext,
]);
const renderSingle = () => { const renderSingle = () => {
if (!singleDoc) { if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>; return <p className="meta">Select a document to view metadata, tags and actions.</p>;
} }
const displayName = singleDoc.title || singleDoc.original_name; const displayName = singleDoc.title || singleDoc.original_name;
const downloadHref = singleDoc.current_version?.download_path
? resolveApiPath?.(singleDoc.current_version.download_path)
: null;
const isEditingTitle = titleEditDocId === singleDoc.id; const isEditingTitle = titleEditDocId === singleDoc.id;
const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0; const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—'; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
@@ -743,6 +939,7 @@ const DetailPanel = ({
emptyMessage="Preview loading…" emptyMessage="Preview loading…"
onItemActivate={handlePreviewActivate} onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
onZoomPreview={handleSingleZoom}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
@@ -852,49 +1049,58 @@ const DetailPanel = ({
<strong>Pages:</strong> {pageCountValue} <strong>Pages:</strong> {pageCountValue}
</div> </div>
) : null} ) : null}
{singleFolderPath?.length ? (
<div>
<strong>Folder:</strong>{' '}
<span className="detail-folder-path">
{singleFolderPath.map((segment, index) => {
const label = segment?.name || '…';
const targetId = segment?.id || null;
const key = `${targetId || label}-${index}`;
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
const href = !isClickable
? null
: targetId === 'root'
? '/documents'
: `/documents/folder/${targetId}`;
return (
<React.Fragment key={key}>
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
{isClickable ? (
<a
href={href}
className="detail-folder-path__link"
onClick={(event) => {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
event.stopPropagation();
onFolderNavigate(targetId);
}}
>
{label}
</a>
) : (
<span className="detail-folder-path__segment">{label}</span>
)}
</React.Fragment>
);
})}
</span>
</div>
) : null}
<div> <div>
<strong>Original filename:</strong>{' '} <strong>Original filename:</strong>{' '}
{singleDoc.original_name} {singleDoc.original_name}
</div> </div>
</div> </div>
<div className="detail-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={() => onOpenPreview(singleDoc.id)}
>
Open preview
</button>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(singleDoc.id)}
>
Re-run analysis
</button>
</div>
{hasOcrAsset ? (
<div className="detail-ocr-trigger">
<button type="button" className="secondary" onClick={openOcrModal}>
View OCR text
</button>
</div>
) : null}
<TagSection <TagSection
title="Tags" title="Tags"
tags={tagsForDoc.map((tag) => ({ tags={tagsForDoc.map((tag) => ({
@@ -914,14 +1120,12 @@ const DetailPanel = ({
onCorrespondentRemove?.({ onCorrespondentRemove?.({
documentId: singleDoc.id, documentId: singleDoc.id,
correspondentId: entry.id, correspondentId: entry.id,
role: entry.role,
}) })
} }
onAdd={({ name, role, input }) => onAdd={({ name, input }) =>
onCorrespondentAdd?.({ onCorrespondentAdd?.({
document: singleDoc, document: singleDoc,
name, name,
role,
input, input,
}) })
} }
@@ -931,7 +1135,7 @@ const DetailPanel = ({
{metadata && ( {metadata && (
<div> <div>
<dt>Metadata</dt> <dt>Metadata</dt>
<pre>{JSON.stringify(metadata, null, 2)}</pre> <pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
</div> </div>
)} )}
{hasOcrAsset && ocrOpen {hasOcrAsset && ocrOpen
@@ -1002,6 +1206,7 @@ const DetailPanel = ({
emptyMessage="No previews available." emptyMessage="No previews available."
onItemActivate={handlePreviewActivate} onItemActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview} onOpenPreview={onOpenPreview}
onZoomPreview={handleStackZoom}
activeItemId={activePreviewId} activeItemId={activePreviewId}
/> />
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
@@ -1065,8 +1270,8 @@ const DetailPanel = ({
title="Correspondents" title="Correspondents"
entries={bulkCorrespondents} entries={bulkCorrespondents}
onRemove={handleBulkCorrespondentRemove} onRemove={handleBulkCorrespondentRemove}
onAdd={({ name, role, input }) => onAdd={({ name, input }) =>
onBulkCorrespondentAdd?.({ name, role, input }) onBulkCorrespondentAdd?.({ name, input })
} }
addPlaceholder="Add correspondent to selection" addPlaceholder="Add correspondent to selection"
datalistId="correspondent-catalog-bulk" datalistId="correspondent-catalog-bulk"
@@ -1074,23 +1279,110 @@ const DetailPanel = ({
showCount showCount
className="bulk-correspondents" className="bulk-correspondents"
/> />
<button
type="button"
className="secondary"
onClick={() => onBulkReanalyze?.()}
>
Re-analyze selection
</button>
</> </>
); );
}; };
const isBulkSelection = selectedCount > 1;
const showOcrAction = Boolean(singleDoc && hasOcrAsset);
return ( return (
<aside className="detail-panel column"> <>
<div className="column-body scrollable"> <aside className="detail-panel panel">
<div className="panel-header">
<div className="panel-actions">
<button
type="button"
className="icon-button ghost"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
<div className="spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onBulkReanalyze();
}}
aria-label="Re-run analysis for selection"
title="Re-run analysis for selection"
>
<AnalyzeIcon />
</button>
) : null}
{singleDoc && singleDownloadHref ? (
<a
className="icon-button"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
onClick={(event) => event.stopPropagation()}
>
<DownloadIcon />
</a>
) : null}
{showOcrAction ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
openOcrModal();
}}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onRegenerateThumbnails(singleDoc.id);
}}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
) : null}
</div>
</div>
<div className="panel-body">
{selectedCount <= 1 ? renderSingle() : renderBulk()} {selectedCount <= 1 ? renderSingle() : renderBulk()}
</div> </div>
</aside> </aside>
<PreviewZoomOverlay
open={Boolean(zoomDisplay?.url)}
display={zoomDisplay}
onClose={closeZoomPreview}
/>
</>
); );
}; };
+262
View File
@@ -0,0 +1,262 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
const noop = () => {};
const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};
const ensureDocumentRoot = () => {
if (typeof document === 'undefined') {
return null;
}
return document.body;
};
const PreviewZoomOverlay = ({
open = false,
display = null,
onClose = noop,
}) => {
const portalTarget = ensureDocumentRoot();
const [isNativeScale, setIsNativeScale] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
const scrollRef = useRef(null);
const imageRef = useRef(null);
const focusRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => {
setIsNativeScale(false);
setNaturalSize({ width: null, height: null });
focusRef.current = null;
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollEl.scrollLeft = 0;
scrollEl.scrollTop = 0;
}
}, [open]);
useEffect(() => {
if (!open || !isNativeScale) {
return;
}
const scrollEl = scrollRef.current;
const imageEl = imageRef.current;
if (!scrollEl || !imageEl) {
return;
}
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
if (!(imageWidth > 0 && imageHeight > 0)) {
return;
}
const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 };
const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth);
const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight);
const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2;
const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2;
scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft);
scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop);
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
useEffect(() => {
if (!open) {
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
}
previouslyFocusedRef.current = null;
return undefined;
}
if (typeof document !== 'undefined') {
const active = document.activeElement;
if (active && typeof active.focus === 'function') {
previouslyFocusedRef.current = active;
} else {
previouslyFocusedRef.current = null;
}
}
const scrollEl = scrollRef.current;
if (!scrollEl) {
return undefined;
}
const frame = requestAnimationFrame(() => {
scrollEl.focus();
});
return () => {
cancelAnimationFrame(frame);
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
previouslyFocusedRef.current = null;
}
};
}, [open]);
const handleKeyDown = (event) => {
event.stopPropagation();
if (!open) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'ArrowLeft') {
if (display?.canGoPrev && display?.goPrev) {
event.preventDefault();
display.goPrev();
}
return;
}
if (event.key === 'ArrowRight') {
if (display?.canGoNext && display?.goNext) {
event.preventDefault();
display.goNext();
}
}
};
if (!open || !display?.url || !portalTarget) {
return null;
}
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
const stageClassName = [
'preview-zoom__stage',
]
.filter(Boolean)
.join(' ');
const containerClassName = [
'preview-zoom__scroll',
isNativeScale ? 'preview-zoom__scroll--native' : '',
]
.filter(Boolean)
.join(' ');
const imageStyle = isNativeScale
? {
cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
maxWidth: 'none',
maxHeight: 'none',
}
: {
cursor: 'zoom-in',
maxWidth: '95vw',
maxHeight: '95vh',
};
return createPortal(
(
<div
className="preview-zoom-backdrop"
role="dialog"
aria-modal="true"
aria-label="Enlarged document preview"
onClick={onClose}
>
<div
className={stageClassName}
onClick={(event) => event.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div
className={containerClassName}
ref={scrollRef}
tabIndex={-1}
>
<img
src={display.url}
alt={display.alt || 'Document preview'}
className="preview-zoom__image"
ref={imageRef}
draggable={false}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth || null,
height: event.currentTarget.naturalHeight || null,
});
}}
onClick={(event) => {
event.stopPropagation();
if (!isNativeScale) {
const img = imageRef.current;
if (img) {
const rect = img.getBoundingClientRect();
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
focusRef.current = {
xRatio: clamp(xRatio, 0, 1),
yRatio: clamp(yRatio, 0, 1),
};
} else {
focusRef.current = null;
}
} else {
focusRef.current = null;
}
setIsNativeScale((current) => !current);
}}
style={imageStyle}
/>
</div>
{navVisible ? (
<div className="preview-zoom__nav">
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (display?.canGoPrev && display?.goPrev) {
display.goPrev();
}
}}
aria-label="Previous preview"
disabled={!display?.canGoPrev}
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => {
event.stopPropagation();
if (display?.canGoNext && display?.goNext) {
display.goNext();
}
}}
aria-label="Next preview"
disabled={!display?.canGoNext}
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
</div>
),
portalTarget,
);
};
export default PreviewZoomOverlay;
+498 -141
View File
@@ -1,10 +1,22 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } 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, FolderIcon } from '../ui/icons'; import DetailPanel from '../detail/DetailPanel';
import {
DownloadIcon,
EditIcon,
ViewListIcon,
ViewGridIcon,
FolderIcon,
TrashIcon,
RefreshIcon,
FolderPlusIcon,
MinusVerticalIcon,
ArrowUpIcon,
} from '../ui/icons';
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_ICON_SIZE = 144;
const DEFAULT_GRID_TITLE_SIZE = '11px'; const DEFAULT_GRID_TITLE_SIZE = '11px';
const LIST_ICON_SIZE = 48; const LIST_ICON_SIZE = 48;
@@ -13,13 +25,95 @@ const getPageCount = (doc) =>
? doc.current_version.metadata.page_count ? doc.current_version.metadata.page_count
: null; : null;
const resolveCorrespondents = (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry, index) => {
if (!entry) return;
const id = entry.id ?? entry.correspondent_id ?? null;
const name = (entry.name || entry.label || entry.slug || '').trim();
if (!name) return;
if (id && seen.has(id)) {
return;
}
if (id) {
seen.add(id);
}
results.push({
id,
name,
key: id ?? `${name}-${index}`,
});
});
return results;
};
// Detects when an element becomes visible within a scroll container.
const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(false);
}, [resetKey]);
const rootNode = rootRef?.current || null;
useEffect(() => {
if (isVisible) {
return undefined;
}
const element = targetRef.current;
if (!element) {
return undefined;
}
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{
root: rootNode,
rootMargin: '200px 0px',
threshold: 0.01,
},
);
observer.observe(element);
return () => observer.disconnect();
}, [isVisible, rootNode, resetKey]);
return { ref: targetRef, isVisible };
};
const DocumentThumbnailImage = ({ const DocumentThumbnailImage = ({
document, document,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
alt, alt,
maxSize = LIST_ICON_SIZE, maxSize = LIST_ICON_SIZE,
scrollRootRef = null,
}) => { }) => {
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo( const thumbnailAsset = useMemo(
() => getAssetFromVersion(document?.current_version, 'thumbnail'), () => getAssetFromVersion(document?.current_version, 'thumbnail'),
@@ -45,14 +139,15 @@ const DocumentThumbnailImage = ({
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width], [dimensions.height, dimensions.width],
); );
const url = useMemo( const url = useMemo(() => {
() => if (!isVisible) {
resolveDocumentAssetUrl(document, 'thumbnail', { return null;
ensureAssetUrl, }
getAsset: getDocumentAsset, return resolveDocumentAssetUrl(document, 'thumbnail', {
}), ensureAssetUrl,
[document, ensureAssetUrl, getDocumentAsset], getAsset: getDocumentAsset,
); });
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
const pageCount = getPageCount(document); const pageCount = getPageCount(document);
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
@@ -61,14 +156,35 @@ const DocumentThumbnailImage = ({
innerClasses.push('document-thumbnail-inner--multipage'); innerClasses.push('document-thumbnail-inner--multipage');
} }
const aspectRatio = useMemo(() => {
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
return assetWidth / assetHeight;
}
return null;
}, [assetWidth, assetHeight]);
useEffect(() => {
const node = visibilityRef.current;
if (!node) {
return;
}
if (aspectRatio) {
node.dataset.thumbnailAspect = String(aspectRatio);
} else {
delete node.dataset.thumbnailAspect;
}
}, [aspectRatio]);
return ( return (
<div className="document-thumbnail-wrapper"> <div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}> <div className={innerClasses.join(' ')} style={innerStyle}>
{url ? ( {url ? (
<img <img
src={url} src={url}
alt={alt || ''} alt={alt || ''}
className="document-thumbnail" className="document-thumbnail"
loading="lazy"
decoding="async"
draggable={false} draggable={false}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
/> />
@@ -113,6 +229,7 @@ const DocumentsTable = ({
onFolderRename, onFolderRename,
onDocumentRename, onDocumentRename,
tagLookupById, tagLookupById,
activeCorrespondentIds = [],
onDocumentListFocus, onDocumentListFocus,
onDocumentListKeyDown, onDocumentListKeyDown,
onFocusedRowChange, onFocusedRowChange,
@@ -120,11 +237,13 @@ const DocumentsTable = ({
getDocumentAsset = () => null, getDocumentAsset = () => null,
getDownloadHref, getDownloadHref,
onTagClick, onTagClick,
onCorrespondentClick,
isSearchLoading = false, isSearchLoading = false,
onDocumentTagDrop, onDocumentTagDrop,
viewMode = 'list', viewMode = 'list',
onViewModeChange, onViewModeChange,
onClearSelection, onClearSelection,
showHeader = true,
}) => { }) => {
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents; const rows = showingSearchResults ? searchResults : documents;
@@ -141,7 +260,24 @@ const DocumentsTable = ({
() => new Set(draggingDocumentIds || []), () => new Set(draggingDocumentIds || []),
[draggingDocumentIds], [draggingDocumentIds],
); );
const activeCorrespondentIdSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const suppressDocumentClickRef = useRef(false);
const [, forceVisibilityTick] = useState(0);
const lastScrollNodeRef = useRef(null);
const assignScrollRef = useCallback((node) => {
if (lastScrollNodeRef.current === node) {
return;
}
lastScrollNodeRef.current = node;
scrollRef.current = node;
if (node) {
forceVisibilityTick((value) => value + 1);
}
}, []);
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const gridIconSize = DEFAULT_GRID_ICON_SIZE; const gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback( const handleSetViewMode = useCallback(
@@ -156,6 +292,11 @@ const DocumentsTable = ({
}, },
[onViewModeChange], [onViewModeChange],
); );
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [viewMode]);
const isTagDragEvent = useCallback((event) => { const isTagDragEvent = useCallback((event) => {
const types = Array.from(event.dataTransfer?.types || []); const types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type)); return TAG_MIME_TYPES.some((type) => types.includes(type));
@@ -263,14 +404,77 @@ const DocumentsTable = ({
[isTagDragEvent, onDocumentTagDrop], [isTagDragEvent, onDocumentTagDrop],
); );
const handleGridBackgroundClick = useCallback( const handleDocumentClick = useCallback(
(event) => { (documentId, event) => {
if (event.target !== event.currentTarget) { if (suppressDocumentClickRef.current) {
return; return;
} }
onClearSelection?.(); onDocumentRowClick?.(documentId, event);
}, },
[onClearSelection], [onDocumentRowClick],
);
const handleDocumentDragStartLocal = useCallback(
(event, doc) => {
suppressDocumentClickRef.current = true;
onDocumentDragStart?.(event, doc);
},
[onDocumentDragStart],
);
const handleDocumentDragEndLocal = useCallback(
(event) => {
onDocumentDragEnd?.(event);
requestAnimationFrame(() => {
suppressDocumentClickRef.current = false;
});
},
[onDocumentDragEnd],
);
const renderCorrespondentLinks = useCallback(
(correspondents) =>
correspondents.map((correspondent, index) => {
const isActive = correspondent.id != null && activeCorrespondentIdSet.has(correspondent.id);
const hasClickHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
const classNames = ['doc-correspondent-link'];
if (isActive) classNames.push('is-active');
if (!hasClickHandler) classNames.push('is-static');
const isLast = index === correspondents.length - 1;
const label = isLast
? `${correspondent.name}:${String.fromCharCode(160)}`
: correspondent.name;
return (
<React.Fragment key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}>
<button
type="button"
className={classNames.join(' ')}
aria-disabled={hasClickHandler ? undefined : true}
onClick={(event) => {
if (!hasClickHandler) {
return;
}
event.stopPropagation();
onCorrespondentClick(correspondent.id, correspondent);
}}
onKeyDown={(event) => {
if (!hasClickHandler) {
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
>
{label}
</button>
{!isLast ? (
<span className="doc-correspondent-link__separator">, </span>
) : null}
</React.Fragment>
);
}),
[activeCorrespondentIdSet, onCorrespondentClick],
); );
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0; const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
@@ -282,73 +486,53 @@ const DocumentsTable = ({
return ( return (
<section <section
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`} className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
> >
<div className="column-header"> {showHeader ? (
<div className="column-header__titles"> <div className="panel-section__header">
<nav className="breadcrumb" aria-label="Folder breadcrumbs"> <div className="panel-section__titles">
{breadcrumbs.map((crumb, index) => { <h2>{currentFolderName}</h2>
const isLast = index === breadcrumbs.length - 1; {showingSearchResults && (
return ( <div className="panel-section__subtitle">Search results</div>
<span key={crumb.id} className="breadcrumb-item"> )}
{isLast ? ( </div>
<span className="breadcrumb-current">{crumb.name}</span> <div className="header-actions">
) : ( <div className="view-toggle" role="group" aria-label="Change view">
<a <button
href="#" type="button"
onClick={(event) => { className={`view-toggle__button${isGridView ? '' : ' active'}`}
event.preventDefault(); onClick={() => handleSetViewMode('list')}
onFolderSelect(crumb.id); aria-pressed={!isGridView}
}} title="List view"
> >
{crumb.name} <ViewListIcon className="view-toggle__icon" size={18} />
</a> </button>
)} <button
{!isLast && <span className="breadcrumb-separator"></span>} type="button"
</span> className={`view-toggle__button${isGridView ? ' active' : ''}`}
); onClick={() => handleSetViewMode('grid')}
})} aria-pressed={isGridView}
</nav> title="Icons view"
{showingSearchResults && ( >
<div className="column-subtitle">Search results</div> <ViewGridIcon className="view-toggle__icon" size={18} />
)} </button>
</div> </div>
<div className="header-actions">
<div className="view-toggle" role="group" aria-label="Change view">
<button <button
type="button" type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`} onClick={onRequestCreateFolder}
onClick={() => handleSetViewMode('list')} disabled={creatingFolder}
aria-pressed={!isGridView}
title="List view"
> >
<ViewListIcon className="view-toggle__icon" size={18} /> {creatingFolder ? 'Creating…' : 'New folder'}
</button> </button>
<button <button className="secondary" onClick={onRefresh}>
type="button" Refresh
className={`view-toggle__button${isGridView ? ' active' : ''}`} </button>
onClick={() => handleSetViewMode('grid')} <button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
aria-pressed={isGridView} Desk View
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button> </button>
</div> </div>
<button
type="button"
onClick={onRequestCreateFolder}
disabled={creatingFolder}
>
{creatingFolder ? 'Creating…' : 'New folder'}
</button>
<button className="secondary" onClick={onRefresh}>
Refresh
</button>
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
Desk View
</button>
</div> </div>
</div> ) : null}
{showDefaultEmptyState && ( {showDefaultEmptyState && (
<div className="empty-state"> <div className="empty-state">
Drop files anywhere or onto a folder to upload documents. Drop files anywhere or onto a folder to upload documents.
@@ -359,9 +543,9 @@ const DocumentsTable = ({
No documents match the current filters. No documents match the current filters.
</div> </div>
)} )}
<div className="column-body"> <div className="panel-section__body">
<div <div
ref={scrollRef} ref={assignScrollRef}
className="documents-scroll" className="documents-scroll"
onFocus={(event) => { onFocus={(event) => {
if (event.target === scrollRef.current) { if (event.target === scrollRef.current) {
@@ -376,13 +560,22 @@ const DocumentsTable = ({
onDocumentListKeyDown(event); onDocumentListKeyDown(event);
} }
}} }}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
aria-activedescendant={isGridView ? undefined : activeDescendantId} aria-activedescendant={isGridView ? undefined : activeDescendantId}
> >
{showDefaultEmptyState ? null : isGridView ? ( {showDefaultEmptyState ? null : isGridView ? (
<div <div
className="documents-grid" className="documents-grid"
role="list" role="list"
onClick={handleGridBackgroundClick} onClick={(event) => {
if (event.target === event.currentTarget) {
onClearSelection?.();
}
}}
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }} style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
> >
{!showingSearchResults && {!showingSearchResults &&
@@ -449,6 +642,7 @@ const DocumentsTable = ({
const tagList = Array.isArray(doc.tags) ? doc.tags : []; const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3); const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0; const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
const cardClasses = ['document-card', 'document']; const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected'); if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging'); if (isDraggingDoc) cardClasses.push('is-dragging');
@@ -459,11 +653,11 @@ const DocumentsTable = ({
role="listitem" role="listitem"
id={`document-card-${doc.id}`} id={`document-card-${doc.id}`}
data-doc-id={doc.id} data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => handleDocumentClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
onDragStart={(event) => onDocumentDragStart(event, doc)} onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
onDragEnd={onDocumentDragEnd} onDragEnd={handleDocumentDragEndLocal}
onDragOver={(event) => handleDocumentTagDragOver(event)} onDragOver={(event) => handleDocumentTagDragOver(event)}
onDragOverCapture={(event) => handleDocumentTagDragOver(event)} onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
onDragLeave={handleDocumentTagDragLeave} onDragLeave={handleDocumentTagDragLeave}
@@ -477,13 +671,19 @@ const DocumentsTable = ({
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`} alt={`Thumbnail for ${doc.title || doc.original_name}`}
maxSize={gridIconSize} maxSize={gridIconSize}
scrollRootRef={scrollRef}
/> />
<div className="document-card__meta"> <div className="document-card__meta">
<div <div
className="document-card__title" className="document-card__title"
title={doc.title || doc.original_name} title={doc.title || doc.original_name}
> >
{doc.title || doc.original_name} {correspondents.length > 0 ? (
<span className="doc-correspondents">
{renderCorrespondentLinks(correspondents)}
</span>
) : null}
<span>{doc.title || doc.original_name}</span>
</div> </div>
{visibleTags.length > 0 && ( {visibleTags.length > 0 && (
<div className="document-card__tags"> <div className="document-card__tags">
@@ -556,8 +756,7 @@ const DocumentsTable = ({
<tr> <tr>
<th className="thumb-column">Preview</th> <th className="thumb-column">Preview</th>
<th>Name</th> <th>Name</th>
<th>Type</th> <th>Issued</th>
<th>Updated</th>
<th className="actions-column">Actions</th> <th className="actions-column">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -619,13 +818,19 @@ const DocumentsTable = ({
<td className="doc-list__name"> <td className="doc-list__name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span>{folder.name}</span> <span>{folder.name}</span>
{folder.id !== 'root' && ( </div>
</td>
<td></td>
<td className="actions">
<div className="action-buttons">
{folder.id !== 'root' && onFolderRename && (
<button <button
type="button" type="button"
className="icon-button ghost doc-list__icon-button" className="icon-button ghost"
title="Rename"
aria-label={`Rename folder ${folder.name}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!onFolderRename) return;
const nextName = window.prompt('Rename folder', folder.name || ''); const nextName = window.prompt('Rename folder', folder.name || '');
if (!nextName) { if (!nextName) {
return; return;
@@ -636,28 +841,24 @@ const DocumentsTable = ({
} }
onFolderRename(folder.id, trimmed); onFolderRename(folder.id, trimmed);
}} }}
title="Rename folder"
aria-label={`Rename folder ${folder.name}`}
> >
<EditIcon className="doc-list__icon" size={16} /> <EditIcon className="icon-inline" />
</button> </button>
)} )}
<button
type="button"
className="icon-button danger"
title="Delete"
aria-label={`Delete folder ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
onFolderDelete(folder.id);
}}
>
<TrashIcon className="icon-inline" />
</button>
</div> </div>
</td> </td>
<td>Folder</td>
<td></td>
<td className="actions">
<button
type="button"
className="icon-button danger"
onClick={(event) => {
event.stopPropagation();
onFolderDelete(folder.id);
}}
>
Delete
</button>
</td>
</tr> </tr>
); );
})} })}
@@ -668,6 +869,7 @@ const DocumentsTable = ({
if (isSelected) rowClasses.push('selected'); if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging'); if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null; const downloadHref = getDownloadHref?.(doc) || null;
const correspondents = resolveCorrespondents(doc);
return ( return (
<tr <tr
@@ -675,11 +877,11 @@ const DocumentsTable = ({
className={rowClasses.join(' ')} className={rowClasses.join(' ')}
id={`document-row-${doc.id}`} id={`document-row-${doc.id}`}
data-doc-id={doc.id} data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => handleDocumentClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
onDragStart={(event) => onDocumentDragStart(event, doc)} onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
onDragEnd={onDocumentDragEnd} onDragEnd={handleDocumentDragEndLocal}
onDragOver={handleDocumentTagDragOver} onDragOver={handleDocumentTagDragOver}
onDragLeave={handleDocumentTagDragLeave} onDragLeave={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)} onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
@@ -690,36 +892,20 @@ 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}`}
scrollRootRef={scrollRef}
/> />
</td> </td>
<td className="doc-list__name"> <td className="doc-list__name">
<div className="doc-name"> <div className="doc-name">
<div className="doc-list__name-content"> <div className="doc-list__name-content">
<span className="doc-name__title">{doc.title || doc.original_name}</span> <span className="doc-name__title">
<button {correspondents.length > 0 ? (
type="button" <span className="doc-correspondents">
className="icon-button ghost doc-list__icon-button" {renderCorrespondentLinks(correspondents)}
onClick={(event) => { </span>
event.stopPropagation(); ) : null}
if (!onDocumentRename) return; <span>{doc.title || doc.original_name}</span>
const nextName = window.prompt( </span>
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
title="Rename document"
aria-label={`Rename document ${doc.title || doc.original_name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
</div> </div>
{(doc.tags || []).length > 0 && ( {(doc.tags || []).length > 0 && (
<div className="doc-name__tags"> <div className="doc-name__tags">
@@ -780,39 +966,74 @@ const DocumentsTable = ({
)} )}
</div> </div>
</td> </td>
<td>{doc.content_type || 'Document'}</td>
<td> <td>
{doc.updated_at {(() => {
? new Date(doc.updated_at).toLocaleString() const issuedAt = doc.issued_at || doc.updated_at || null;
: '—'} if (!issuedAt) {
return '—';
}
const timestamp = Date.parse(issuedAt);
if (Number.isNaN(timestamp)) {
return '—';
}
return new Date(timestamp).toLocaleDateString();
})()}
</td> </td>
<td className="actions"> <td className="actions">
<div className="action-buttons"> <div className="action-buttons">
{onDocumentRename && (
<button
type="button"
className="icon-button ghost"
title="Rename"
aria-label={`Rename document ${doc.title || doc.original_name}`}
onClick={(event) => {
event.stopPropagation();
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
>
<EditIcon className="icon-inline" />
</button>
)}
{downloadHref ? ( {downloadHref ? (
<a <a
className="button-link with-icon" className="icon-button"
href={downloadHref} href={downloadHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="Download"
aria-label="Download document"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()} onAuxClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()}
> >
<DownloadIcon className="icon-inline" /> <DownloadIcon className="icon-inline" />
<span>Download</span>
</a> </a>
) : ( ) : (
<span className="meta">No download</span> <span className="meta">No download</span>
)} )}
<button <button
type="button" type="button"
className="danger" className="icon-button danger"
title="Delete"
aria-label="Delete document"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onDocumentDelete?.(doc.id); onDocumentDelete?.(doc.id);
}} }}
> >
Delete <TrashIcon className="icon-inline" />
</button> </button>
</div> </div>
</td> </td>
@@ -838,3 +1059,139 @@ const DocumentsTable = ({
export default DocumentsTable; export default DocumentsTable;
export { DocumentThumbnailImage }; export { DocumentThumbnailImage };
export const createDocumentsTableHeaderActions = ({
viewMode,
onViewModeChange,
onRequestCreateFolder,
creatingFolder,
onRefresh,
onShowSkeuoWorkspace,
}) => {
const isGridView = viewMode === 'grid';
return (
<>
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isGridView ? '' : ' active'}`}
onClick={() => onViewModeChange?.('list')}
aria-pressed={!isGridView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
</div>
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
<button
type="button"
className="icon-button ghost"
onClick={onRequestCreateFolder}
disabled={creatingFolder}
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
>
<FolderPlusIcon />
</button>
<button
type="button"
className="icon-button ghost"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
Desk View
</button>
</>
);
};
export const createDocumentsSurface = ({
tableProps,
parentBreadcrumb,
onNavigateParent,
renderSidebarToggle,
detailProps,
}) => {
const {
currentFolderName,
searchResults,
viewMode,
onViewModeChange,
onRequestCreateFolder,
creatingFolder,
onRefresh,
onShowSkeuoWorkspace,
} = tableProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
const subtitle = Array.isArray(searchResults)
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
: null;
const actions = createDocumentsTableHeaderActions({
viewMode,
onViewModeChange,
onRequestCreateFolder,
creatingFolder,
onRefresh,
onShowSkeuoWorkspace,
});
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const parentControl = parentBreadcrumb
? (
<button
type="button"
className="icon-button ghost"
onClick={onNavigateParent}
aria-label="Go to parent folder"
title="Go to parent folder"
>
<ArrowUpIcon />
</button>
)
: null;
const leading = sidebarToggle || parentControl
? (
<>
{sidebarToggle}
{parentControl}
</>
)
: null;
const detail = (() => {
if (!detailProps) {
return null;
}
const count = detailProps.selectedDocuments?.length || 0;
if (!count) {
return null;
}
return <DetailPanel {...detailProps} />;
})();
return {
key: 'documents',
variant: 'documents',
header: { title, subtitle, leading, actions },
content: <DocumentsTable {...tableProps} showHeader={false} />,
detail,
};
};
+1050 -320
View File
File diff suppressed because it is too large Load Diff
+190 -56
View File
@@ -1,13 +1,11 @@
import React from 'react'; import React, { useMemo } from 'react';
import { DownloadIcon } from '../ui/icons'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
const PreviewWorkspace = ({ const PreviewWorkspace = ({
document, document,
previewEntry, previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => { }) => {
if (!document) { if (!document) {
return null; return null;
@@ -15,59 +13,63 @@ const PreviewWorkspace = ({
const title = document.title || document.original_name || 'Document'; const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; 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 sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata = const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null; document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
const folderName = document.folder_path || document.folder_name || null;
const issuedAt = document.issued_at || document.current_version?.issued_at || null;
const createdAt = document.created_at || null;
const updatedAt = document.updated_at || null;
const tags = Array.isArray(document.tags) ? document.tags : [];
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
const metadataSummary = useMemo(() => {
const rows = [];
if (mime) rows.push(['Type', mime]);
if (sizeLabel) rows.push(['Size', sizeLabel]);
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
if (folderName) rows.push(['Folder', folderName]);
if (tags.length) {
rows.push(['Tags', tags.map((tag) => tag.label || tag.name || tag.slug).filter(Boolean).join(', ')]);
}
if (correspondents.length) {
rows.push([
'Correspondents',
correspondents
.map((entry) => entry.name || entry.label || entry.slug)
.filter(Boolean)
.join(', '),
]);
}
return rows;
}, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]);
return ( return (
<section className="preview-workspace"> <section className="preview-workspace">
<header className="preview-workspace__header"> <aside className="preview-workspace__sidebar">
<div className="preview-workspace__meta"> <div className="preview-workspace__info">
<button {metadataSummary.length ? (
type="button" <dl className="preview-workspace__summary">
className="secondary" {metadataSummary.map(([label, value]) => (
onClick={() => onClose(document.folder_id ?? 'root')} <div className="preview-workspace__summary-row" key={label}>
> <dt>{label}</dt>
Back <dd>{value || '—'}</dd>
</button> </div>
<div> ))}
<h2>{title}</h2> </dl>
<span className="meta"> ) : null}
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div> </div>
<div className="preview-workspace__actions"> {metadata ? (
<a <section className="preview-workspace__metadata">
className="button-link with-icon" <h4>Metadata payload</h4>
href={downloadHref || '#'} <pre>{JSON.stringify(metadata, null, 2)}</pre>
target="_blank" </section>
rel="noopener noreferrer" ) : null}
aria-disabled={!downloadHref} </aside>
onClick={(event) => { <div className="preview-workspace__viewer">
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 ? ( {!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div> <div className="preview-workspace__message">Loading preview</div>
) : ( ) : (
@@ -78,15 +80,147 @@ const PreviewWorkspace = ({
/> />
)} )}
</div> </div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section> </section>
); );
}; };
export default PreviewWorkspace; export default PreviewWorkspace;
export const createPreviewWorkspaceHeaderActions = ({
document,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
}) => {
if (!document) {
return null;
}
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const ocrAsset = getDocumentAsset(document, 'ocr-text');
const hasOcr = Boolean(ocrAsset);
const handleOcrClick = async () => {
if (!ocrAsset) {
return;
}
try {
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
const entry = ensured || ocrAsset;
const url = entry?.url
|| resolveDocumentAssetUrl(document, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url) {
throw new Error('OCR text URL unavailable.');
}
window.open(url, '_blank', 'noopener,noreferrer');
} catch (error) {
notifyApiError(error, 'Unable to open OCR text.');
}
};
return (
<>
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
{hasOcr ? (
<button
type="button"
className="icon-button ghost"
onClick={handleOcrClick}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
<button
type="button"
className="icon-button ghost"
onClick={() => onRegenerate(document.id)}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
</>
);
};
export const createPreviewSurface = ({
document,
previewEntry,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
onClose,
renderSidebarToggle,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document preview';
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const closeButton = onClose
? (
<button
type="button"
className="icon-button ghost"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
: null;
const leading = sidebarToggle || closeButton
? (
<>
{sidebarToggle}
{closeButton}
</>
)
: null;
const header = {
title,
subtitle: null,
leading,
actions: createPreviewWorkspaceHeaderActions({
document,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
}),
};
return {
key: 'preview',
variant: 'preview',
header,
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
supportsDetail: false,
};
};
+8 -27
View File
@@ -1,18 +1,13 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => { const DocumentViewerRoute = () => {
const { const {
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData, ensurePreviewData,
notifyApiError, notifyApiError,
resolveApiPath,
} = useAppShell(); } = useAppShell();
const { documentId } = useParams(); const { documentId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,29 +39,15 @@ const DocumentViewerRoute = () => {
}; };
}, [documentId, ensurePreviewData, notifyApiError, navigate]); }, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady = if (!documentId) {
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId; return <Navigate to="/documents" replace />;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
} }
return ( if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
<main className="preview-main"> return <div className="preview-workspace__message">Loading preview</div>;
<PreviewWorkspace }
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry} return <Navigate to="/documents" replace />;
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}; };
export default DocumentViewerRoute; export default DocumentViewerRoute;
+237 -42
View File
@@ -1,5 +1,13 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons'; import {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
ChevronsLeftIcon,
LogoutIcon,
ChevronDownIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
@@ -20,9 +28,9 @@ const FolderNode = ({
draggingFolderId, draggingFolderId,
}) => { }) => {
const isRoot = node.id === 'root'; const isRoot = node.id === 'root';
const hasChildren = node.children.length > 0; const hasChildren = Boolean(node.hasChildren);
const canToggle = !isRoot && (hasChildren || !node.loaded); const canToggle = !isRoot && (hasChildren || !node.loaded);
const showChevron = !isRoot && hasChildren; const showChevron = !isRoot && (hasChildren || !node.loaded);
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null; const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
const canDrag = !isRoot; const canDrag = !isRoot;
const isDragging = draggingFolderId === node.id; const isDragging = draggingFolderId === node.id;
@@ -136,6 +144,23 @@ const Sidebar = ({
correspondents = [], correspondents = [],
activeCorrespondentIds = [], activeCorrespondentIds = [],
onToggleCorrespondentFilter, onToggleCorrespondentFilter,
onManageTags,
onManageCorrespondents,
searchQuery = '',
onSearchChange,
onSearchSubmit,
onSearchClear,
isFilterActive,
appStatus,
loading,
previewActive,
onLogout,
status,
onCollapse,
tenantSlug,
tenants = [],
activeTenantId = null,
onSelectTenant,
}) => { }) => {
const sortedCorrespondents = useMemo( const sortedCorrespondents = useMemo(
() => () =>
@@ -150,6 +175,79 @@ const Sidebar = ({
); );
const handleToggleTag = onToggleTagFilter || (() => {}); const handleToggleTag = onToggleTagFilter || (() => {});
const activeTagSet = new Set(activeTagIds); const activeTagSet = new Set(activeTagIds);
const handleManageTags = onManageTags || (() => {});
const handleManageCorrespondents = onManageCorrespondents || (() => {});
const handleSearchInputChange = useCallback(
(event) => {
onSearchChange?.(event.target.value);
},
[onSearchChange],
);
const handleSearchFormSubmit = useCallback(
(event) => {
event.preventDefault();
onSearchSubmit?.();
},
[onSearchSubmit],
);
const handleSearchClear = useCallback(() => {
onSearchClear?.();
}, [onSearchClear]);
const tenantButtonRef = useRef(null);
const tenantMenuRef = useRef(null);
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
const toggleTenantMenu = useCallback(() => {
const next = !tenantMenuOpen;
setTenantMenuOpen(next);
if (next && tenants.length === 0 && onSelectTenant) {
onSelectTenant(null, { refreshOnly: true });
}
}, [tenantMenuOpen, tenants.length, onSelectTenant]);
useEffect(() => {
if (!tenantMenuOpen) {
return undefined;
}
const handlePointer = (event) => {
const menuNode = tenantMenuRef.current;
const buttonNode = tenantButtonRef.current;
if (!menuNode) return;
if (menuNode.contains(event.target)) return;
if (buttonNode && buttonNode.contains(event.target)) return;
setTenantMenuOpen(false);
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setTenantMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('keydown', handleKeyDown);
};
}, [tenantMenuOpen]);
const handleTenantSelect = useCallback(
(tenant) => {
const targetId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
if (!targetId) {
return;
}
setTenantMenuOpen(false);
onSelectTenant?.(tenant);
},
[onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
setTenantMenuOpen(false);
onLogout?.();
}, [onLogout]);
const renderNodes = useCallback( const renderNodes = useCallback(
(ids, depth) => (ids, depth) =>
@@ -193,30 +291,127 @@ const Sidebar = ({
); );
const rootNode = folderNodes.get('root'); const rootNode = folderNodes.get('root');
return ( return (
<aside className="sidebar column"> <aside className="sidebar">
<div className="sidebar-section sidebar-section--folders"> <div className="panel-header sidebar__header">
<div className="sidebar-section__header"> <div className="panel-actions">
<h3>Folders</h3> <button
type="button"
className={`sidebar__title-button${tenantMenuOpen ? ' is-open' : ''}`}
onClick={toggleTenantMenu}
aria-haspopup="true"
aria-expanded={tenantMenuOpen}
ref={tenantButtonRef}
>
<span className="sidebar__title">
Papercrate
{tenantSlug ? <span className="sidebar__tenant"> / {tenantSlug}</span> : null}
</span>
<ChevronDownIcon
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
size={16}
/>
</button>
{tenantMenuOpen ? (
<div className="menu" ref={tenantMenuRef} role="menu">
<div className="menu__list">
{tenants.length === 0 ? (
<span className="menu__empty">No tenants available.</span>
) : (
tenants.map((tenant) => {
const tenantId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
const isActive = tenantId === activeTenantId;
return (
<button
key={tenantId || tenant?.slug || tenant?.name}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__label">
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
</span>
{isActive ? <span className="menu__active-indicator">Active</span> : null}
</button>
);
})
)}
</div>
<div className="menu__footer">
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
) : null}
<div className="spacer" />
{onCollapse ? (
<button
type="button"
className="icon-button ghost"
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<ChevronsLeftIcon />
</button>
) : null}
</div> </div>
<ul className="folder-tree">
{rootNode && renderNodes([rootNode.id], 0)}
</ul>
</div> </div>
<div className="sidebar-section"> <div className="panel-body sidebar__body">
<div className="sidebar-section__header"> {status && (
<h3>Tags</h3> <div className="sidebar__status">
<span className="meta">{tags.length}</span> <div className={`status-banner ${status.variant}`}>{status.message}</div>
</div>
)}
{onSearchChange && (
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
<input
type="search"
value={searchQuery}
onChange={handleSearchInputChange}
placeholder="Search documents"
aria-label="Search documents"
/>
{isFilterActive && (
<button type="button" onClick={handleSearchClear}>
Clear
</button>
)}
</form>
)}
<div className="sidebar-section sidebar-section--folders">
<div className="sidebar-section__header">
<h3>Folders</h3>
</div>
<ul className="folder-tree">
{rootNode && renderNodes([rootNode.id], 0)}
</ul>
</div> </div>
<div <div className="sidebar-section">
className={`sidebar-tag-cloud${ <div className="sidebar-section__header">
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : '' <button
}`} type="button"
role="list" className="sidebar-section__title"
> onClick={handleManageTags}
{tags.length ? ( >
tags.map((tag) => { <h3>Tags</h3>
<span className="meta">{tags.length}</span>
</button>
</div>
<div
className={`sidebar-tag-cloud${
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
}`}
role="list"
>
{tags.map((tag) => {
const isActive = activeTagSet.has(tag.id); const isActive = activeTagSet.has(tag.id);
const style = getTagColorStyle(tag.color); const style = getTagColorStyle(tag.color);
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`; const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
@@ -249,20 +444,22 @@ const Sidebar = ({
{tag.label} {tag.label}
</button> </button>
); );
}) })}
) : ( </div>
<span className="meta">No tags yet</span>
)}
</div> </div>
</div> <div className="sidebar-section">
<div className="sidebar-section"> <div className="sidebar-section__header">
<div className="sidebar-section__header"> <button
<h3>Correspondents</h3> type="button"
<span className="meta">{correspondents.length}</span> className="sidebar-section__title"
</div> onClick={handleManageCorrespondents}
<ul className="sidebar-correspondent-list"> >
{sortedCorrespondents.length ? ( <h3>Correspondents</h3>
sortedCorrespondents.map((correspondent) => { <span className="meta">{correspondents.length}</span>
</button>
</div>
<ul className="sidebar-correspondent-list">
{sortedCorrespondents.map((correspondent) => {
const isActive = activeCorrespondentSet.has(correspondent.id); const isActive = activeCorrespondentSet.has(correspondent.id);
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
const label = correspondent.name || 'Unnamed'; const label = correspondent.name || 'Unnamed';
@@ -287,11 +484,9 @@ const Sidebar = ({
</span> </span>
</li> </li>
); );
}) })}
) : ( </ul>
<li className="meta">No correspondents yet</li> </div>
)}
</ul>
</div> </div>
</aside> </aside>
); );
File diff suppressed because it is too large Load Diff
+959 -413
View File
File diff suppressed because it is too large Load Diff
+115 -17
View File
@@ -1,12 +1,26 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors'; import {
getTagColorStyle,
HEX_COLOR_PATTERN,
generateRandomTagColor,
} from '../utils/colors';
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) { function TagsPanel({
tags,
onRefresh,
onCreateTag,
onUpdateTag,
onDeleteTag,
onNotify,
}) {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState(''); const [draftLabel, setDraftLabel] = useState('');
const [draftColor, setDraftColor] = useState(''); const [draftColor, setDraftColor] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState(null); const [deletingId, setDeletingId] = useState(null);
const [createLabel, setCreateLabel] = useState('');
const [createColor, setCreateColor] = useState('');
const [creating, setCreating] = useState(false);
const startEdit = useCallback((tag) => { const startEdit = useCallback((tag) => {
setEditingId(tag.id); setEditingId(tag.id);
@@ -98,25 +112,101 @@ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
[onDeleteTag, editingId, cancelEdit, onNotify], [onDeleteTag, editingId, cancelEdit, onNotify],
); );
const handleCreate = useCallback(
async (event) => {
event.preventDefault();
if (typeof onCreateTag !== 'function') {
return;
}
const trimmedLabel = createLabel.trim();
if (!trimmedLabel) {
onNotify?.('Tag label cannot be empty.', 'error');
return;
}
const trimmedColor = createColor.trim();
const colorPattern = /^#([0-9a-fA-F]{6})$/;
if (trimmedColor && !colorPattern.test(trimmedColor)) {
onNotify?.('Colors must use the #RRGGBB format.', 'error');
return;
}
setCreating(true);
try {
await onCreateTag({
label: trimmedLabel,
color: trimmedColor ? trimmedColor : null,
});
setCreateLabel('');
setCreateColor('');
} catch (createError) {
const message = createError?.message || 'Failed to create tag.';
onNotify?.(message, 'error');
} finally {
setCreating(false);
}
},
[createLabel, createColor, onCreateTag, onNotify],
);
return ( return (
<section className="tags-panel column"> <section className="tags-panel">
<div className="column-header"> <div className="panel-section__header">
<div className="column-header__titles"> <div className="panel-section__titles">
<h2>Tags</h2> <h2>Tags</h2>
<div className="column-subtitle">{tags.length} total</div> <div className="panel-section__subtitle">{tags.length} total</div>
</div> </div>
<div className="header-actions"> <div className="header-actions tags-actions">
<form className="tags-actions__form" onSubmit={handleCreate}>
<input
type="text"
placeholder="New tag label"
value={createLabel}
onChange={(event) => setCreateLabel(event.target.value)}
disabled={creating}
/>
<input
type="color"
className="tags-table__color-picker"
value={createColor || '#3366ff'}
onChange={(event) => setCreateColor(event.target.value)}
disabled={creating}
aria-label="Tag color (optional)"
/>
<button
type="button"
className="secondary"
onClick={() => setCreateColor(generateRandomTagColor())}
disabled={creating}
>
Random color
</button>
{createColor && (
<button
type="button"
className="secondary"
onClick={() => setCreateColor('')}
disabled={creating}
>
Clear
</button>
)}
<button type="submit" disabled={creating || !createLabel.trim()}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>
<button <button
className="secondary" className="secondary"
type="button" type="button"
onClick={onRefresh} onClick={onRefresh}
disabled={saving || Boolean(deletingId)} disabled={saving || creating || Boolean(deletingId)}
> >
Refresh Refresh
</button> </button>
</div> </div>
</div> </div>
<div className="column-body tags-panel__body"> <div className="panel-section__body tags-panel__body">
{tags.length === 0 ? ( {tags.length === 0 ? (
<div className="empty-state">No tags created yet.</div> <div className="empty-state">No tags created yet.</div>
) : ( ) : (
@@ -166,14 +256,22 @@ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
className="tags-table__color-picker" className="tags-table__color-picker"
value={colorPickerValue} value={colorPickerValue}
onChange={(event) => setDraftColor(event.target.value)} onChange={(event) => setDraftColor(event.target.value)}
disabled={saving || deletingId === tag.id} disabled={saving || deletingId === tag.id}
aria-label="Pick tag color" aria-label="Pick tag color"
/> />
{draftColor && ( <button
<button type="button"
type="button" className="secondary"
className="secondary" onClick={() => setDraftColor(generateRandomTagColor())}
onClick={() => setDraftColor('')} disabled={saving || deletingId === tag.id}
>
Random color
</button>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving || deletingId === tag.id} disabled={saving || deletingId === tag.id}
> >
Clear Clear
+130
View File
@@ -9,6 +9,18 @@ import {
IconLayoutGrid, IconLayoutGrid,
IconArrowLeft, IconArrowLeft,
IconArrowRight, IconArrowRight,
IconArrowUp,
IconChevronsRight,
IconChevronsLeft,
IconAnalyze,
IconWindowMaximize,
IconTextScan2,
IconFolderPlus,
IconRefresh,
IconMinusVertical,
IconLogout,
IconChevronDown,
IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; import FolderSvg from '../assets/folder.svg';
@@ -120,6 +132,105 @@ export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest
/> />
); );
export const ChevronsLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronsLeft
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronsRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconFolderPlus
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconRefresh
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowUpIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowUp
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconMinusVertical
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconX
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconAnalyze
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconWindowMaximize
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const LogoutIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLogout
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ChevronDownIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconChevronDown
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -130,4 +241,23 @@ export default {
DownloadIcon, DownloadIcon,
ViewListIcon, ViewListIcon,
ViewGridIcon, ViewGridIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
AnalyzeIcon,
WindowMaximizeIcon,
FolderPlusIcon,
RefreshIcon,
ArrowUpIcon,
MinusVerticalIcon,
LogoutIcon,
ChevronDownIcon,
}; };
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconTextScan2
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
+205 -8
View File
@@ -2,6 +2,8 @@ const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const clamp01 = (value) => Math.min(1, Math.max(0, value)); const clamp01 = (value) => Math.min(1, Math.max(0, value));
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
const gammaEncode = (channel) => const gammaEncode = (channel) =>
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055; channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
@@ -77,6 +79,37 @@ const hslToHex = (h, s, l) => {
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`; return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
}; };
const rgbToHsl = ({ r, g, b }) => {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let hue = 0;
if (delta !== 0) {
if (max === rn) {
hue = ((gn - bn) / delta) % 6;
} else if (max === gn) {
hue = (bn - rn) / delta + 2;
} else {
hue = (rn - gn) / delta + 4;
}
hue *= 60;
if (hue < 0) hue += 360;
}
const lightness = (max + min) / 2;
let saturation = 0;
if (delta !== 0) {
saturation = delta / (1 - Math.abs(2 * lightness - 1));
}
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
};
export const hexToRgb = (input) => { export const hexToRgb = (input) => {
if (!input) return null; if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim()); const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -102,20 +135,184 @@ export const relativeLuminance = ({ r, g, b }) => {
return 0.2126 * red + 0.7152 * green + 0.0722 * blue; return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}; };
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => { const contrastRatio = (lumA, lumB) => {
const rgb = hexToRgb(hex); const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
if (!rgb) return dark; return (lighter + 0.05) / (darker + 0.05);
const luminance = relativeLuminance(rgb); };
return luminance > 0.6 ? light : dark;
const parseCandidateColor = (candidate) => {
const rgb = hexToRgb(candidate);
if (!rgb) return null;
return {
hex: rgb.hex,
luminance: relativeLuminance(rgb),
};
};
const contrastForPair = (backgroundHex, textHex) => {
const background = hexToRgb(backgroundHex);
const text = hexToRgb(textHex);
if (!background || !text) {
return 0;
}
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
};
export const getReadableTextColor = (
hex,
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' } = {},
) => {
const background = hexToRgb(hex);
if (!background) return fallback;
const backgroundLuminance = relativeLuminance(background);
const backgroundHsl = rgbToHsl(background);
const hueShift = 180;
const textHue = (backgroundHsl.h + hueShift) % 360;
const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85);
const minLightness = 0.05;
const maxLightness = 0.95;
const sampleCount = 24;
const buildCandidate = (lightness) => {
const light = clampRange(lightness, minLightness, maxLightness);
const hexValue = hslToHex(textHue, targetSaturation, light);
return parseCandidateColor(hexValue);
};
const candidates = new Map();
for (let index = 0; index < sampleCount; index += 1) {
const t = index / (sampleCount - 1);
const candidateLightness = minLightness + t * (maxLightness - minLightness);
const candidate = buildCandidate(candidateLightness);
if (candidate) {
candidates.set(candidate.hex, candidate);
}
}
[light, dark].forEach((preset) => {
const parsed = parseCandidateColor(preset);
if (parsed) {
candidates.set(parsed.hex, parsed);
}
});
if (candidates.size === 0) {
return fallback;
}
let best = null;
let bestRatio = -Infinity;
candidates.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
const MIN_CONTRAST = 4.5;
if (bestRatio < MIN_CONTRAST) {
const extremeLight = buildCandidate(maxLightness);
const extremeDark = buildCandidate(minLightness);
const extremes = [extremeLight, extremeDark].filter(Boolean);
extremes.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
}
return best?.hex || fallback;
}; };
export const getTagColorStyle = (hex) => { export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex); const rgb = hexToRgb(hex);
if (!rgb) return null; if (!rgb) return null;
const baseHex = rgb.hex;
const baseHsl = rgbToHsl(rgb);
const baseText = getReadableTextColor(baseHex);
const baseRatio = contrastForPair(baseHex, baseText);
const TARGET_RATIO = 8;
const MIN_LIGHTNESS = 0.12;
const MAX_LIGHTNESS = 0.88;
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
const seen = new Map();
const registerCandidate = (lightness) => {
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
if (!seen.has(hexValue)) {
seen.set(hexValue, clamped);
}
};
registerCandidate(baseHsl.l);
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
let bestBackground = baseHex;
let bestText = baseText;
let bestRatio = baseRatio;
if (bestRatio >= TARGET_RATIO) {
return {
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
}
let compliantBackground = null;
let compliantText = null;
let compliantDelta = Infinity;
let compliantRatio = -Infinity;
seen.forEach((lightness, candidateHex) => {
const textHex = getReadableTextColor(candidateHex);
const ratio = contrastForPair(candidateHex, textHex);
const delta = Math.abs(lightness - baseHsl.l);
if (ratio > bestRatio) {
bestBackground = candidateHex;
bestText = textHex;
bestRatio = ratio;
}
if (ratio >= TARGET_RATIO) {
const deltaEpsilon = 0.0025;
const ratioEpsilon = 0.01;
const isCloser = delta + deltaEpsilon < compliantDelta;
const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon;
const improvesRatio = ratio > compliantRatio + ratioEpsilon;
if (
compliantBackground === null
|| isCloser
|| (isSimilarDistance && improvesRatio)
) {
compliantBackground = candidateHex;
compliantText = textHex;
compliantDelta = delta;
compliantRatio = ratio;
}
}
});
if (compliantBackground) {
return {
backgroundColor: compliantBackground,
borderColor: compliantBackground,
color: compliantText,
};
}
return { return {
backgroundColor: rgb.hex, backgroundColor: bestBackground,
borderColor: rgb.hex, borderColor: bestBackground,
color: getReadableTextColor(rgb.hex), color: bestText,
}; };
}; };