Compare commits
22
Commits
ai
...
cc37fdde9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc37fdde9a | ||
|
|
e351638e52 | ||
|
|
90a642c1c5 | ||
|
|
e150b81190 | ||
|
|
94578475ea | ||
|
|
92c491b742 | ||
|
|
eb93a2131f | ||
|
|
1e177580c9 | ||
|
|
7df9a6415a | ||
|
|
1179f4fcd4 | ||
|
|
709d32050e | ||
|
|
b9d84fa72b | ||
|
|
7abc10acde | ||
|
|
9be0e3b5c4 | ||
|
|
ed0f0fb759 | ||
|
|
2d80515ad5 | ||
|
|
50125f4659 | ||
|
|
a6da34740f | ||
|
|
7ed06ccdcf | ||
|
|
db07e0debb | ||
|
|
a0be094cbd | ||
|
|
7366d2e16b |
Generated
+42
@@ -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"
|
||||||
|
|||||||
@@ -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,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
@@ -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) => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-34
@@ -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"
|
||||||
@@ -576,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)]
|
||||||
@@ -625,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,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,
|
||||||
}
|
}
|
||||||
@@ -666,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)]
|
||||||
@@ -678,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)]
|
||||||
@@ -739,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)]
|
||||||
@@ -774,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>,
|
||||||
@@ -933,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)]
|
||||||
|
|||||||
@@ -80,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())?;
|
||||||
@@ -186,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)
|
||||||
@@ -206,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)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+398
-670
File diff suppressed because it is too large
Load Diff
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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},
|
||||||
@@ -309,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 {
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -79,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(
|
||||||
@@ -154,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)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)?)
|
||||||
|
}
|
||||||
+634
-344
File diff suppressed because it is too large
Load Diff
+9
-7
@@ -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 unless explicitly set to `false` without filters), `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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Skeuomorphic workspace styles */
|
/* Desktop workspace styles */
|
||||||
.skeuo-main {
|
.skeuo-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -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;
|
||||||
@@ -33,28 +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;
|
||||||
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item:focus-visible {
|
.skeuo-item:focus-visible {
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -98,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;
|
||||||
@@ -156,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;
|
||||||
@@ -186,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,
|
||||||
@@ -205,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 {
|
||||||
@@ -249,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
@@ -1,3 +0,0 @@
|
|||||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
|
||||||
|
|
||||||
export default CORRESPONDENT_ROLES;
|
|
||||||
@@ -107,14 +107,7 @@ 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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
};
|
||||||
@@ -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})`;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useDesktopContext } from './context';
|
||||||
|
import { preventAll } from './events';
|
||||||
|
import { clamp, formatTransform } from './math';
|
||||||
|
|
||||||
|
const DRAG_HYSTERESIS_PX = 4;
|
||||||
|
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
bringToFront(docId);
|
||||||
|
const entry = layoutRef.current.get(docId) || null;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!state.moved) {
|
||||||
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
|
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bringToFront(state.docId);
|
||||||
|
state.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if (state.moved) {
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = state.docId;
|
||||||
|
bringToFront(docId);
|
||||||
|
const originInfo = {
|
||||||
|
rotation: state.rotation || 0,
|
||||||
|
scale: state.baseScale || 1,
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
};
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
openOverlayForDoc(docId, originInfo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
},
|
||||||
|
[bringToFront, finishDrag, openOverlayForDoc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerCancel = useCallback(
|
||||||
|
(event) => {
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
},
|
||||||
|
[finishDrag],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlePointerDown,
|
||||||
|
handlePointerMove,
|
||||||
|
handlePointerUp,
|
||||||
|
handlePointerCancel,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentDrag;
|
||||||
@@ -14,13 +14,10 @@ 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';
|
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);
|
||||||
@@ -30,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>
|
||||||
)}
|
)}
|
||||||
@@ -105,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}
|
||||||
@@ -167,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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -180,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}>
|
||||||
@@ -698,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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -716,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,
|
||||||
@@ -743,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(
|
||||||
@@ -755,12 +725,11 @@ 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) => {
|
const openZoomPreview = useCallback((config) => {
|
||||||
@@ -1151,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1303,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"
|
||||||
@@ -1324,15 +1291,30 @@ const DetailPanel = ({
|
|||||||
<aside className="detail-panel panel">
|
<aside className="detail-panel panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div className="panel-actions">
|
<div className="panel-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button ghost"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close detail panel"
|
aria-label="Close detail panel"
|
||||||
title="Close detail panel"
|
title="Close detail panel"
|
||||||
>
|
>
|
||||||
<ChevronsRightIcon />
|
<ChevronsRightIcon />
|
||||||
</button>
|
</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" />
|
<div className="spacer" />
|
||||||
{isBulkSelection && onBulkReanalyze ? (
|
{isBulkSelection && onBulkReanalyze ? (
|
||||||
<button
|
<button
|
||||||
@@ -1361,21 +1343,6 @@ const DetailPanel = ({
|
|||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
{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}
|
|
||||||
{showOcrAction ? (
|
{showOcrAction ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,10 +25,68 @@ const PreviewZoomOverlay = ({
|
|||||||
const portalTarget = ensureDocumentRoot();
|
const portalTarget = ensureDocumentRoot();
|
||||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||||
|
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||||
|
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||||
|
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const imageRef = useRef(null);
|
const imageRef = useRef(null);
|
||||||
const focusRef = useRef(null);
|
const focusRef = useRef(null);
|
||||||
const previouslyFocusedRef = useRef(null);
|
const previouslyFocusedRef = useRef(null);
|
||||||
|
const visibilityTimerRef = useRef(null);
|
||||||
|
const displayTimerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (display?.url) {
|
||||||
|
setDisplaySnapshot(display);
|
||||||
|
}
|
||||||
|
}, [display]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibilityTimerRef.current) {
|
||||||
|
clearTimeout(visibilityTimerRef.current);
|
||||||
|
visibilityTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (displayTimerRef.current) {
|
||||||
|
cancelAnimationFrame(displayTimerRef.current);
|
||||||
|
displayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open && display?.url) {
|
||||||
|
setRenderBackdrop(true);
|
||||||
|
displayTimerRef.current = requestAnimationFrame(() => {
|
||||||
|
displayTimerRef.current = requestAnimationFrame(() => {
|
||||||
|
setBackdropVisible(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (displayTimerRef.current) {
|
||||||
|
cancelAnimationFrame(displayTimerRef.current);
|
||||||
|
displayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setBackdropVisible(false);
|
||||||
|
visibilityTimerRef.current = setTimeout(() => {
|
||||||
|
setRenderBackdrop(false);
|
||||||
|
}, 260);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (visibilityTimerRef.current) {
|
||||||
|
clearTimeout(visibilityTimerRef.current);
|
||||||
|
visibilityTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open, display?.url]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (visibilityTimerRef.current) {
|
||||||
|
clearTimeout(visibilityTimerRef.current);
|
||||||
|
}
|
||||||
|
if (displayTimerRef.current) {
|
||||||
|
cancelAnimationFrame(displayTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsNativeScale(false);
|
setIsNativeScale(false);
|
||||||
@@ -75,7 +133,7 @@ const PreviewZoomOverlay = ({
|
|||||||
previouslyFocusedRef.current.focus();
|
previouslyFocusedRef.current.focus();
|
||||||
}
|
}
|
||||||
previouslyFocusedRef.current = null;
|
previouslyFocusedRef.current = null;
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
@@ -86,24 +144,24 @@ const PreviewZoomOverlay = ({
|
|||||||
previouslyFocusedRef.current = null;
|
previouslyFocusedRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const scrollEl = scrollRef.current;
|
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||||
if (!scrollEl) {
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renderBackdrop || !activeDisplay?.url) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
scrollEl.focus();
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && typeof scrollEl.focus === 'function') {
|
||||||
|
scrollEl.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => cancelAnimationFrame(frame);
|
||||||
cancelAnimationFrame(frame);
|
}, [renderBackdrop, activeDisplay?.url]);
|
||||||
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
|
||||||
previouslyFocusedRef.current.focus();
|
|
||||||
previouslyFocusedRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -119,26 +177,27 @@ const PreviewZoomOverlay = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft') {
|
if (event.key === 'ArrowLeft') {
|
||||||
if (display?.canGoPrev && display?.goPrev) {
|
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
display.goPrev();
|
activeDisplay.goPrev();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowRight') {
|
if (event.key === 'ArrowRight') {
|
||||||
if (display?.canGoNext && display?.goNext) {
|
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
display.goNext();
|
activeDisplay.goNext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!open || !display?.url || !portalTarget) {
|
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
|
const effectiveDisplay = activeDisplay;
|
||||||
|
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||||
const stageClassName = [
|
const stageClassName = [
|
||||||
'preview-zoom__stage',
|
'preview-zoom__stage',
|
||||||
]
|
]
|
||||||
@@ -152,6 +211,13 @@ const PreviewZoomOverlay = ({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const backdropClassName = [
|
||||||
|
'preview-zoom-backdrop',
|
||||||
|
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
const imageStyle = isNativeScale
|
const imageStyle = isNativeScale
|
||||||
? {
|
? {
|
||||||
cursor: 'zoom-out',
|
cursor: 'zoom-out',
|
||||||
@@ -169,7 +235,7 @@ const PreviewZoomOverlay = ({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
(
|
(
|
||||||
<div
|
<div
|
||||||
className="preview-zoom-backdrop"
|
className={backdropClassName}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Enlarged document preview"
|
aria-label="Enlarged document preview"
|
||||||
@@ -186,8 +252,8 @@ const PreviewZoomOverlay = ({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={display.url}
|
src={effectiveDisplay.url}
|
||||||
alt={display.alt || 'Document preview'}
|
alt={effectiveDisplay.alt || 'Document preview'}
|
||||||
className="preview-zoom__image"
|
className="preview-zoom__image"
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -227,12 +293,12 @@ const PreviewZoomOverlay = ({
|
|||||||
className="preview-zoom__nav-button"
|
className="preview-zoom__nav-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (display?.canGoPrev && display?.goPrev) {
|
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||||
display.goPrev();
|
effectiveDisplay.goPrev();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Previous preview"
|
aria-label="Previous preview"
|
||||||
disabled={!display?.canGoPrev}
|
disabled={!effectiveDisplay?.canGoPrev}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon />
|
<ArrowLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -241,12 +307,12 @@ const PreviewZoomOverlay = ({
|
|||||||
className="preview-zoom__nav-button"
|
className="preview-zoom__nav-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (display?.canGoNext && display?.goNext) {
|
if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
|
||||||
display.goNext();
|
effectiveDisplay.goNext();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Next preview"
|
aria-label="Next preview"
|
||||||
disabled={!display?.canGoNext}
|
disabled={!effectiveDisplay?.canGoNext}
|
||||||
>
|
>
|
||||||
<ArrowRightIcon />
|
<ArrowRightIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } 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, TrashIcon } 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,6 +25,39 @@ 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.
|
// Detects when an element becomes visible within a scroll container.
|
||||||
const useLazyVisibility = (rootRef, resetKey) => {
|
const useLazyVisibility = (rootRef, resetKey) => {
|
||||||
const targetRef = useRef(null);
|
const targetRef = useRef(null);
|
||||||
@@ -111,6 +156,25 @@ 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" ref={visibilityRef}>
|
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||||
@@ -165,6 +229,7 @@ const DocumentsTable = ({
|
|||||||
onFolderRename,
|
onFolderRename,
|
||||||
onDocumentRename,
|
onDocumentRename,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
activeCorrespondentIds = [],
|
||||||
onDocumentListFocus,
|
onDocumentListFocus,
|
||||||
onDocumentListKeyDown,
|
onDocumentListKeyDown,
|
||||||
onFocusedRowChange,
|
onFocusedRowChange,
|
||||||
@@ -172,6 +237,7 @@ const DocumentsTable = ({
|
|||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
getDownloadHref,
|
getDownloadHref,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
|
onCorrespondentClick,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
viewMode = 'list',
|
viewMode = 'list',
|
||||||
@@ -194,7 +260,12 @@ 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 [, forceVisibilityTick] = useState(0);
|
||||||
const lastScrollNodeRef = useRef(null);
|
const lastScrollNodeRef = useRef(null);
|
||||||
const assignScrollRef = useCallback((node) => {
|
const assignScrollRef = useCallback((node) => {
|
||||||
@@ -333,6 +404,79 @@ const DocumentsTable = ({
|
|||||||
[isTagDragEvent, onDocumentTagDrop],
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDocumentClick = useCallback(
|
||||||
|
(documentId, event) => {
|
||||||
|
if (suppressDocumentClickRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDocumentRowClick?.(documentId, event);
|
||||||
|
},
|
||||||
|
[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;
|
||||||
const showListSearchEmptyState =
|
const showListSearchEmptyState =
|
||||||
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
||||||
@@ -498,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');
|
||||||
@@ -508,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}
|
||||||
@@ -533,7 +678,12 @@ const DocumentsTable = ({
|
|||||||
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">
|
||||||
@@ -606,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>
|
||||||
@@ -671,7 +820,6 @@ const DocumentsTable = ({
|
|||||||
<span>{folder.name}</span>
|
<span>{folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>Folder</td>
|
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td className="actions">
|
<td className="actions">
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
@@ -721,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
|
||||||
@@ -728,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)}
|
||||||
@@ -749,7 +898,14 @@ const DocumentsTable = ({
|
|||||||
<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">
|
||||||
|
{correspondents.length > 0 ? (
|
||||||
|
<span className="doc-correspondents">
|
||||||
|
{renderCorrespondentLinks(correspondents)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{doc.title || doc.original_name}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
<div className="doc-name__tags">
|
<div className="doc-name__tags">
|
||||||
@@ -810,11 +966,18 @@ 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">
|
||||||
@@ -896,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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+565
-348
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } 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;
|
||||||
@@ -149,6 +157,10 @@ const Sidebar = ({
|
|||||||
onLogout,
|
onLogout,
|
||||||
status,
|
status,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
|
tenantSlug,
|
||||||
|
tenants = [],
|
||||||
|
activeTenantId = null,
|
||||||
|
onSelectTenant,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -181,7 +193,59 @@ const Sidebar = ({
|
|||||||
const handleSearchClear = useCallback(() => {
|
const handleSearchClear = useCallback(() => {
|
||||||
onSearchClear?.();
|
onSearchClear?.();
|
||||||
}, [onSearchClear]);
|
}, [onSearchClear]);
|
||||||
const handleLogoutClick = useCallback(() => {
|
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?.();
|
||||||
}, [onLogout]);
|
}, [onLogout]);
|
||||||
|
|
||||||
@@ -227,17 +291,65 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rootNode = folderNodes.get('root');
|
const rootNode = folderNodes.get('root');
|
||||||
const hintText = appStatus === 'bootstrapping' && loading
|
|
||||||
? 'Loading your library…'
|
|
||||||
: previewActive
|
|
||||||
? 'Viewing document preview. Press ← Back to return to the library.'
|
|
||||||
: 'Drag files here to upload.';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="panel-header sidebar__header">
|
<div className="panel-header sidebar__header">
|
||||||
<div className="panel-actions">
|
<div className="panel-actions">
|
||||||
<h1 className="sidebar__title">Papercrate</h1>
|
<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" />
|
<div className="spacer" />
|
||||||
{onCollapse ? (
|
{onCollapse ? (
|
||||||
<button
|
<button
|
||||||
@@ -253,7 +365,6 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-body sidebar__body">
|
<div className="panel-body sidebar__body">
|
||||||
<span className="sidebar__hint">{hintText}</span>
|
|
||||||
{status && (
|
{status && (
|
||||||
<div className="sidebar__status">
|
<div className="sidebar__status">
|
||||||
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||||
@@ -376,11 +487,6 @@ const Sidebar = ({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar__footer">
|
|
||||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+352
-68
@@ -11,14 +11,13 @@
|
|||||||
--fg: oklch(0.32 0.005 calc(270deg + var(--warmth)));
|
--fg: oklch(0.32 0.005 calc(270deg + var(--warmth)));
|
||||||
--muted: oklch(0.54 0.008 calc(270deg + var(--warmth)));
|
--muted: oklch(0.54 0.008 calc(270deg + var(--warmth)));
|
||||||
--sidebar-fg: oklch(0.56 0.007 calc(270deg + var(--warmth)));
|
--sidebar-fg: oklch(0.56 0.007 calc(270deg + var(--warmth)));
|
||||||
--border: oklch(0.88 0.002 calc(270deg + var(--warmth)));
|
--border: oklch(0.92 0.002 calc(270deg + var(--warmth)));
|
||||||
|
|
||||||
/* --- Accent (primary) --- */
|
/* --- Accent (primary) --- */
|
||||||
--accent: oklch(0.61 0.20 calc(260deg + var(--warmth)));
|
--accent: oklch(0.61 0.2 calc(250deg + var(--warmth)));
|
||||||
--accent-hover: oklch(0.70 0.18 calc(260deg + var(--warmth)));
|
--accent-hover: oklch(0.70 0.02 calc(100deg + var(--warmth)));
|
||||||
--on-accent: oklch(1 0 0);
|
--on-accent: oklch(1 0 0);
|
||||||
|
|
||||||
/* --- Derived accent states --- */
|
|
||||||
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||||
--accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent);
|
--accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent);
|
||||||
--accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent);
|
--accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent);
|
||||||
@@ -27,6 +26,12 @@
|
|||||||
--accent-focus: color-mix(in oklch, var(--accent) 85%, transparent);
|
--accent-focus: color-mix(in oklch, var(--accent) 85%, transparent);
|
||||||
--surface-overlay: color-mix(in oklch, white 82%, transparent);
|
--surface-overlay: color-mix(in oklch, white 82%, transparent);
|
||||||
|
|
||||||
|
/* -- Selection --- */
|
||||||
|
--selection: oklch(0.61 0.01 calc(100deg + var(--warmth)));
|
||||||
|
|
||||||
|
/* --- Derived accent states --- */
|
||||||
|
--selection-soft: color-mix(in oklch, var(--selection) 12%, transparent);
|
||||||
|
|
||||||
/* --- Shadows & overlays --- */
|
/* --- Shadows & overlays --- */
|
||||||
--shadow-faint: color-mix(in oklch, black 6%, transparent);
|
--shadow-faint: color-mix(in oklch, black 6%, transparent);
|
||||||
--shadow-soft: color-mix(in oklch, black 12%, transparent);
|
--shadow-soft: color-mix(in oklch, black 12%, transparent);
|
||||||
@@ -46,11 +51,12 @@
|
|||||||
--danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent);
|
--danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent);
|
||||||
|
|
||||||
/* --- Explorer states --- */
|
/* --- Explorer states --- */
|
||||||
--row-hover-bg: color-mix(in oklch, var(--accent) 6%, transparent);
|
--row-hover-bg: color-mix(in oklch, var(--selection) 6%, transparent);
|
||||||
--row-active-bg: color-mix(in oklch, var(--accent) 12%, transparent);
|
--row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent);
|
||||||
--sidebar-hover-bg: color-mix(in oklch, var(--accent) 8%, transparent);
|
--sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent);
|
||||||
--sidebar-active-bg: color-mix(in oklch, var(--accent) 16%, transparent);
|
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
|
||||||
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
|
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
|
||||||
|
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
|
||||||
|
|
||||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -203,8 +209,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
.panel-actions button,
|
.panel-actions button,
|
||||||
.panel-actions a.icon-button {
|
.panel-actions a.icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -213,6 +219,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-actions .icon-button:hover:not([disabled]),
|
.panel-actions .icon-button:hover:not([disabled]),
|
||||||
@@ -237,13 +244,22 @@ button.danger:hover:not([disabled]) {
|
|||||||
.preview-zoom-backdrop {
|
.preview-zoom-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--overlay-backdrop);
|
background: rgba(15, 23, 42, 0);
|
||||||
|
transition: background 0.25s ease, opacity 0.25s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
z-index: 3000;
|
z-index: 3000;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-backdrop--visible {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--overlay-backdrop);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__stage {
|
.preview-zoom__stage {
|
||||||
@@ -444,6 +460,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
box-shadow: -12px 0 24px -12px var(--shadow-faint);
|
box-shadow: -12px 0 24px -12px var(--shadow-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-main:not(.documents-main--sidebar-collapsed) .main-content {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.main-content__header {
|
.main-content__header {
|
||||||
padding: 0.5rem 1.25rem;
|
padding: 0.5rem 1.25rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -537,9 +557,9 @@ button.danger:hover:not([disabled]) {
|
|||||||
.preview-workspace {
|
.preview-workspace {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 1.5rem;
|
||||||
gap: 0.75rem;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__header {
|
.preview-workspace__header {
|
||||||
@@ -598,6 +618,13 @@ button.danger:hover:not([disabled]) {
|
|||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-drag-preview__thumb--image {
|
||||||
|
background-color: #000;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
.document-drag-preview__thumb .document-thumbnail,
|
.document-drag-preview__thumb .document-thumbnail,
|
||||||
.document-drag-preview__thumb img {
|
.document-drag-preview__thumb img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -664,17 +691,84 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__body {
|
.preview-workspace__sidebar {
|
||||||
flex: 1;
|
flex: 0 0 20em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__info {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__summary {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__summary-row dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__summary-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__metadata {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__metadata h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__metadata pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-workspace__viewer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__object {
|
.preview-workspace__object {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-panel__body {
|
.tags-panel__body {
|
||||||
@@ -857,20 +951,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__message {
|
.preview-workspace__message {
|
||||||
color: var(--muted);
|
position: absolute;
|
||||||
font-size: 0.95rem;
|
top: 50%;
|
||||||
}
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
.preview-workspace__metadata {
|
color: #fff;
|
||||||
border-radius: 2px;
|
background: rgba(0, 0, 0, 0.65);
|
||||||
padding: 0.75rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--surface);
|
border-radius: 999px;
|
||||||
max-height: 180px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-workspace__metadata h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,9 +1132,140 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__hint {
|
.sidebar__tenant {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__header {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__title-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__title-button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__title-chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__title-chevron.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 1em;
|
||||||
|
right: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-muted, var(--border));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||||
|
min-width: 220px;
|
||||||
|
z-index: 20;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__list {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.menu__item,
|
||||||
|
.menu .menu__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.menu__item:hover,
|
||||||
|
.menu button.menu__item:focus-visible,
|
||||||
|
.menu .menu__item:hover,
|
||||||
|
.menu .menu__item:focus-visible {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.menu__item.active,
|
||||||
|
.menu .menu__item.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.menu__item:focus-visible,
|
||||||
|
.menu .menu__item:focus-visible {
|
||||||
|
outline: 2px solid var(--accent, #2563eb);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__active-indicator {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__empty {
|
||||||
|
display: block;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__footer {
|
||||||
|
border-top: 1px solid var(--border-muted, var(--border));
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__logout {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--danger, #d14343);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__logout:hover,
|
||||||
|
.menu__logout:focus-visible {
|
||||||
|
background: rgba(209, 67, 67, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__search {
|
.sidebar__search {
|
||||||
@@ -1101,6 +1320,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.sidebar-section__header {
|
.sidebar-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1135,9 +1355,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.sidebar-section__header h3 {
|
.sidebar-section__header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
@@ -1192,7 +1410,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
@@ -1205,7 +1423,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.sidebar-tag-pill:hover {
|
.sidebar-tag-pill:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
||||||
border-color: var(--accent-soft, rgba(59, 130, 246, 0.35));
|
border-color: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tag-pill:focus-visible {
|
.sidebar-tag-pill:focus-visible {
|
||||||
@@ -1214,8 +1432,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tag-pill.active {
|
.sidebar-tag-pill.active {
|
||||||
border-color: var(--accent);
|
border-color: var(--sidebar-active-pill-border);
|
||||||
box-shadow: 0 0 0 1.5px var(--accent);
|
box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
|
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
|
||||||
@@ -1255,8 +1473,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-correspondent-item.active {
|
.sidebar-correspondent-item.active {
|
||||||
background: var(--accent-soft, rgba(59, 130, 246, 0.18));
|
background: var(--sidebar-active-bg, rgba(59, 130, 246, 0.18));
|
||||||
box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24));
|
box-shadow: 0 0 0 1px var(--sidebar-active-bg, rgba(59, 130, 246, 0.24));
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,7 +1718,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr.document.selected {
|
.documents-panel tbody tr.document.selected {
|
||||||
background: var(--accent-soft);
|
background: var(--selection-soft);
|
||||||
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
|
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1631,6 +1849,63 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-correspondents {
|
||||||
|
display: inline;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-correspondent-link {
|
||||||
|
background: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent, #2563eb);
|
||||||
|
text-decoration: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-correspondent-link:not(.is-static):hover,
|
||||||
|
.doc-correspondent-link:not(.is-static):focus-visible {
|
||||||
|
color: var(--accent-strong, var(--accent));
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-correspondent-link:not(.is-static):focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-panel tbody tr.document.selected .doc-correspondents,
|
||||||
|
.documents-panel tbody tr.document.selected .doc-correspondent-link,
|
||||||
|
.document-card.selected .doc-correspondent-link {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card.selected .doc-correspondents {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-correspondent-link.is-active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-correspondent-link.is-static {
|
||||||
|
color: inherit;
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-correspondent-link__separator {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr.document.dragging {
|
.documents-panel tbody tr.document.dragging {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
@@ -1687,6 +1962,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip--more {
|
.tag-chip--more {
|
||||||
@@ -1695,6 +1971,39 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-chip--removable {
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip__label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip__remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip__remove:hover,
|
||||||
|
.tag-chip__remove:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip__remove:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -1719,6 +2028,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 0 24px var(--shadow-soft);
|
box-shadow: 0 0 24px var(--shadow-soft);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
@@ -2093,32 +2403,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.18rem;
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
color: var(--fg);
|
|
||||||
padding: 0.2rem 0.45rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-pill button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-pill button:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
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({
|
function TagsPanel({
|
||||||
tags,
|
tags,
|
||||||
@@ -170,6 +174,14 @@ function TagsPanel({
|
|||||||
disabled={creating}
|
disabled={creating}
|
||||||
aria-label="Tag color (optional)"
|
aria-label="Tag color (optional)"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCreateColor(generateRandomTagColor())}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Random color
|
||||||
|
</button>
|
||||||
{createColor && (
|
{createColor && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -244,14 +256,22 @@ function TagsPanel({
|
|||||||
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
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
|
IconLogout,
|
||||||
|
IconChevronDown,
|
||||||
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -183,6 +186,15 @@ export const MinusVerticalIcon = ({ className, size = '1em', stroke = 1.6, ...re
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 }) => (
|
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconAnalyze
|
<IconAnalyze
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -201,6 +213,24 @@ export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...r
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -219,6 +249,8 @@ export default {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user