22 Commits
Author SHA1 Message Date
nils cc37fdde9a fix 2025-10-28 13:08:44 +01:00
nils e351638e52 frontend 2025-10-28 12:34:43 +01:00
nils 90a642c1c5 desktop view 2025-10-28 12:17:20 +01:00
nils e150b81190 rename skeuo to desktop 2025-10-28 10:41:56 +01:00
nils 94578475ea appconfig 2025-10-28 02:52:41 +01:00
nils 92c491b742 auth tests 2025-10-28 02:27:10 +01:00
nils eb93a2131f refactor 2025-10-28 02:05:38 +01:00
nils 1e177580c9 stuff 2025-10-28 01:38:19 +01:00
nils 7df9a6415a refactor 2025-10-28 01:18:35 +01:00
nils 1179f4fcd4 frontend tweaks, preview workspace 2025-10-28 00:43:54 +01:00
nils 709d32050e backend tracing 2025-10-28 00:30:45 +01:00
nils b9d84fa72b remove download endpoint 2025-10-28 00:17:57 +01:00
nils 7abc10acde backend 2025-10-28 00:01:40 +01:00
nils 9be0e3b5c4 frontend 2025-10-28 00:01:16 +01:00
nils ed0f0fb759 backend 2025-10-27 22:45:16 +01:00
nils 2d80515ad5 backend 2025-10-27 22:25:43 +01:00
nils 50125f4659 document updates 2025-10-27 22:06:33 +01:00
nils a6da34740f drag thumbs 2025-10-27 21:20:54 +01:00
nils 7ed06ccdcf drag 2025-10-27 21:06:29 +01:00
nils db07e0debb increase icon size in grid view 2025-10-27 20:55:29 +01:00
nils a0be094cbd table 2025-10-27 20:53:51 +01:00
nils 7366d2e16b frontend: tags 2025-10-27 20:29:21 +01:00
51 changed files with 5831 additions and 3367 deletions
+42
View File
@@ -611,6 +611,7 @@ dependencies = [
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dotenv", "dotenv",
"envy",
"futures-util", "futures-util",
"hex", "hex",
"http-body-util", "http-body-util",
@@ -625,6 +626,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"serde", "serde",
"serde-aux",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
@@ -1175,6 +1177,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -2160,6 +2171,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.2" version = "0.5.2"
@@ -2904,6 +2924,28 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-aux"
version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a"
dependencies = [
"chrono",
"serde",
"serde-value",
"serde_json",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
+2
View File
@@ -25,6 +25,8 @@ aws-credential-types = "1.2"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
envy = "0.4"
serde-aux = "4.4"
# Utilities # Utilities
tracing = "0.1" tracing = "0.1"
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
ADD COLUMN operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
DROP COLUMN IF EXISTS operations_summary;
@@ -0,0 +1,7 @@
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
UPDATE document_correspondents SET role = 'other';
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id, role);
@@ -0,0 +1,24 @@
WITH ranked AS (
SELECT
document_id,
correspondent_id,
role,
assigned_at,
assigned_by,
tenant_id,
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
FROM document_correspondents
)
DELETE FROM document_correspondents dc
USING ranked r
WHERE dc.document_id = r.document_id
AND dc.correspondent_id = r.correspondent_id
AND dc.role = r.role
AND dc.tenant_id = r.tenant_id
AND r.rn > 1;
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
ALTER TABLE document_correspondents DROP COLUMN role;
ALTER TABLE document_correspondents
ADD CONSTRAINT document_correspondents_pkey
PRIMARY KEY (document_id, correspondent_id);
+96 -77
View File
@@ -1,35 +1,60 @@
use std::env;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use url::Url; use url::Url;
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_bool_from_anything;
use crate::db::DEFAULT_MAX_POOL_SIZE; use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug)] #[derive(Clone, Debug, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub database_url: String, pub database_url: String,
#[serde(default = "default_database_max_pool_size")]
pub database_max_pool_size: u32, pub database_max_pool_size: u32,
#[serde(default = "default_server_host")]
pub server_host: String, pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16, pub server_port: u16,
#[serde(default = "default_webdav_host")]
pub webdav_host: String, pub webdav_host: String,
#[serde(default = "default_webdav_port")]
pub webdav_port: u16, pub webdav_port: u16,
pub jwt_secret: String, pub jwt_secret: String,
#[serde(default = "default_jwt_issuer")]
pub jwt_issuer: String, pub jwt_issuer: String,
#[serde(default = "default_jwt_audience")]
pub jwt_audience: String, pub jwt_audience: String,
#[serde(default = "default_jwt_expiry_minutes")]
pub jwt_expiry_minutes: i64, pub jwt_expiry_minutes: i64,
#[serde(default = "default_download_token_audience")]
pub download_token_audience: String, pub download_token_audience: String,
#[serde(default = "default_download_token_expiry_minutes")]
pub download_token_expiry_minutes: i64, pub download_token_expiry_minutes: i64,
#[serde(default = "default_refresh_token_expiry_days")]
pub refresh_token_expiry_days: i64, pub refresh_token_expiry_days: i64,
#[serde(
default = "default_refresh_cookie_secure",
deserialize_with = "deserialize_bool_from_anything"
)]
pub refresh_cookie_secure: bool, pub refresh_cookie_secure: bool,
#[serde(default)]
pub refresh_cookie_domain: Option<String>, pub refresh_cookie_domain: Option<String>,
#[serde(default)]
pub cors_allowed_origin: Option<String>, pub cors_allowed_origin: Option<String>,
#[serde(default)]
pub aws_endpoint_url: Option<String>, pub aws_endpoint_url: Option<String>,
#[serde(default)]
pub aws_access_key_id: Option<String>, pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>, pub aws_secret_access_key: Option<String>,
#[serde(default = "default_aws_region")]
pub aws_region: String, pub aws_region: String,
pub s3_bucket: String, pub s3_bucket: String,
#[serde(default)]
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
#[serde(default)]
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
#[serde(default = "default_tenant_slug")]
pub default_tenant_slug: String, pub default_tenant_slug: String,
} }
@@ -49,80 +74,9 @@ impl AppConfig {
} }
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let config: AppConfig = envy::from_env()
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE") .context("failed to parse application configuration from environment")?;
.ok() Ok(config.normalize())
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "papercrate-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
quickwit_endpoint,
quickwit_index,
default_tenant_slug,
})
} }
pub fn redacted_database_url(&self) -> String { pub fn redacted_database_url(&self) -> String {
@@ -130,6 +84,71 @@ impl AppConfig {
} }
} }
impl AppConfig {
fn normalize(mut self) -> Self {
if self.webdav_host.is_empty() {
self.webdav_host = self.server_host.clone();
}
self
}
}
fn default_database_max_pool_size() -> u32 {
DEFAULT_MAX_POOL_SIZE
}
fn default_server_host() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
3000
}
fn default_webdav_host() -> String {
String::new()
}
fn default_webdav_port() -> u16 {
3001
}
fn default_jwt_issuer() -> String {
"papercrate".to_string()
}
fn default_jwt_audience() -> String {
"papercrate-clients".to_string()
}
fn default_jwt_expiry_minutes() -> i64 {
60
}
fn default_download_token_audience() -> String {
"papercrate-download".to_string()
}
fn default_download_token_expiry_minutes() -> i64 {
60
}
fn default_refresh_token_expiry_days() -> i64 {
30
}
fn default_refresh_cookie_secure() -> bool {
false
}
fn default_aws_region() -> String {
"us-east-1".to_string()
}
fn default_tenant_slug() -> String {
"admin".to_string()
}
fn redact_database_url(raw: &str) -> String { fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
+263
View File
@@ -0,0 +1,263 @@
use std::collections::HashMap;
use std::path::Path as FsPath;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions};
use crate::state::AppState;
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
}
#[derive(Serialize, ToSchema)]
pub struct DocumentAssetDetailResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<DocumentAssetObjectResponse>,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub metadata: Value,
}
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetResponse>,
pub download_path: String,
}
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
pub async fn load_asset_responses(
state: &AppState,
tenant_id: Uuid,
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db_for_tenant(tenant_id)?;
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
Ok(assets
.into_iter()
.map(|(asset, _)| to_asset_summary(asset))
.collect())
}
pub fn load_primary_assets(
state: &AppState,
tenant_id: Uuid,
documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
for doc in documents {
doc_to_version.insert(doc.id, doc.current_version_id);
version_ids.push(doc.current_version_id);
}
version_ids.sort();
version_ids.dedup();
let mut conn = state.db_for_tenant(tenant_id)?;
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?;
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions {
version_map.insert(version.id, version);
}
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq_any(&version_ids))
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, _object) in assets {
let version_id = asset.document_version_id;
let response = to_asset_summary(asset);
assets_by_version
.entry(version_id)
.or_default()
.push(response);
}
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
HashMap::with_capacity(doc_to_version.len());
for (doc_id, version_id) in doc_to_version {
if let Some(version) = version_map.remove(&version_id) {
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
result.insert(doc_id, (to_version_response(version), assets));
}
}
Ok(result)
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::collections::HashMap;
use chrono::Utc;
use diesel::prelude::*;
use serde::Serialize;
use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent};
use crate::schema::{correspondents, document_correspondents, documents};
use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)]
pub struct DocumentCorrespondentResponse {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub assigned_at: String,
}
pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
let mut unique: Vec<Uuid> = ids.iter().copied().collect();
unique.sort_unstable();
unique.dedup();
if unique.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one correspondent",
));
}
Ok(unique)
}
pub fn insert_document_correspondents(
conn: &mut PgConnection,
tenant_id: Uuid,
document_id: Uuid,
user_id: Uuid,
correspondent_ids: &[Uuid],
) -> AppResult<usize> {
let ids = normalize_correspondent_ids(correspondent_ids)?;
let existing: Vec<Uuid> = correspondents::table
.filter(correspondents::id.eq_any(&ids))
.filter(correspondents::tenant_id.eq(tenant_id))
.select(correspondents::id)
.load(conn)?;
if existing.len() != ids.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
let new_rows: Vec<NewDocumentCorrespondent> = ids
.into_iter()
.map(|correspondent_id| NewDocumentCorrespondent {
document_id,
correspondent_id,
assigned_by: Some(user_id),
tenant_id,
})
.collect();
if new_rows.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?;
if inserted > 0 {
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(inserted)
}
pub fn load_correspondents_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table
.inner_join(correspondents::table)
.filter(document_correspondents::document_id.eq_any(document_ids))
.order((
document_correspondents::document_id.asc(),
document_correspondents::assigned_at.asc(),
))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<DocumentCorrespondentResponse>> = HashMap::new();
for (assignment, correspondent) in rows {
map.entry(assignment.document_id)
.or_default()
.push(DocumentCorrespondentResponse {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
assigned_at: to_iso(assignment.assigned_at),
});
}
Ok(map)
}
+21
View File
@@ -0,0 +1,21 @@
use diesel::dsl::exists;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::AppResult;
use crate::schema::folders;
use crate::utils::validation::ensure_exists;
pub fn ensure_folder_exists_on_conn(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<()> {
let exists: bool = diesel::select(exists(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
ensure_exists(exists, "folder")
}
+52
View File
@@ -0,0 +1,52 @@
use serde_json::{map::Entry, Map, Value};
use crate::error::{AppError, AppResult};
pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
let mut base = match existing {
Value::Object(map) => map,
Value::Null => Map::new(),
_ => {
return Err(AppError::bad_request(
"existing metadata is not an object; set replace=true to overwrite",
));
}
};
let incoming = match updates {
Value::Object(map) => map,
_ => {
return Err(AppError::bad_request(
"metadata value must be a JSON object when replace is false",
));
}
};
merge_metadata_maps(&mut base, incoming);
Ok(Value::Object(base))
}
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
for (key, value) in updates {
match target.entry(key) {
Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
match value {
Value::Object(update_map) => {
if let Value::Object(existing_map) = existing {
merge_metadata_maps(existing_map, update_map);
} else {
*existing = Value::Object(update_map);
}
}
other => {
*existing = other;
}
}
}
Entry::Vacant(entry) => {
entry.insert(value);
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod asset;
pub mod correspondents;
pub mod folders;
pub mod metadata;
pub mod search;
pub mod tags;
@@ -1,6 +1,14 @@
use serde_json::Value; use std::collections::HashSet;
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::{debug, error};
use uuid::Uuid; use uuid::Uuid;
pub const QUICKWIT_MAX_HITS: usize = 200;
pub fn build_quickwit_query(input: &str) -> Option<String> { pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input let tokens: Vec<String> = input
.split_whitespace() .split_whitespace()
@@ -38,6 +46,67 @@ pub fn escape_quickwit_token(token: &str) -> String {
escaped escaped
} }
pub async fn quickwit_search(
endpoint: &str,
index: &str,
tenant_id: Uuid,
query: &str,
) -> Result<Vec<Uuid>> {
let tenant_clause = format!("tenant_id:{}", tenant_id);
let quickwit_query = match build_quickwit_query(query) {
Some(q) => {
debug!(%query, quickwit_query = %q, "built quickwit search query");
format!("{} AND ({})", tenant_clause, q)
}
None => {
debug!(%query, "quickwit search skipped because query produced no tokens");
return Ok(vec![]);
}
};
let client = Client::new();
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
let payload = json!({
"query": quickwit_query,
"max_hits": QUICKWIT_MAX_HITS,
});
debug!(%url, payload = %payload, "sending quickwit search request");
let response = client.post(url).json(&payload).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
error!(%status, body = %body, "quickwit search request failed");
return Err(anyhow!(
"quickwit search failed with status {status}: {body}"
));
}
let data: QuickwitSearchResponse = response.json().await?;
debug!("quickwit search response parsed successfully");
let QuickwitSearchResponse { hits } = data;
let total_hits = hits.len();
let mut seen = HashSet::new();
let mut doc_ids = Vec::with_capacity(total_hits);
for hit in hits {
if let Some(doc_id) = extract_document_id(&hit) {
if seen.insert(doc_id) {
doc_ids.push(doc_id);
}
}
}
debug!(
total_hits = total_hits,
unique_ids = doc_ids.len(),
"quickwit search completed"
);
Ok(doc_ids)
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> { pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] { for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) { if let Some(value) = hit.get(key) {
@@ -89,3 +158,9 @@ pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
None None
} }
#[derive(Deserialize)]
struct QuickwitSearchResponse {
#[serde(default)]
hits: Vec<Value>,
}
+80
View File
@@ -0,0 +1,80 @@
use std::collections::HashMap;
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, NewDocumentTag, Tag};
use crate::schema::{document_tags, tags};
pub fn assign_tags(
conn: &mut PgConnection,
tenant_id: Uuid,
document: &Document,
raw_tag_ids: &[Uuid],
assigned_by: Option<Uuid>,
) -> AppResult<usize> {
if raw_tag_ids.is_empty() {
return Ok(0);
}
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
tag_ids.sort_unstable();
tag_ids.dedup();
if tag_ids.is_empty() {
return Ok(0);
}
let existing: Vec<Uuid> = tags::table
.filter(tags::id.eq_any(&tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.select(tags::id)
.load(conn)?;
if existing.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let new_tags: Vec<NewDocumentTag> = tag_ids
.into_iter()
.map(|tag_id| NewDocumentTag {
document_id: document.id,
tag_id,
assigned_by,
tenant_id,
})
.collect();
if new_tags.is_empty() {
return Ok(0);
}
let inserted = diesel::insert_into(document_tags::table)
.values(&new_tags)
.on_conflict_do_nothing()
.execute(conn)?;
Ok(inserted)
}
pub fn load_tags_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, Tag)> = document_tags::table
.inner_join(tags::table)
.filter(document_tags::document_id.eq_any(document_ids))
.select((document_tags::document_id, tags::all_columns))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
+1
View File
@@ -1,6 +1,7 @@
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod documents;
pub mod error; pub mod error;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
+1 -5
View File
@@ -123,7 +123,6 @@ pub struct DocumentVersion {
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -137,7 +136,6 @@ pub struct NewDocumentVersion {
pub s3_key: String, pub s3_key: String,
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -283,11 +281,10 @@ pub struct NewCorrespondent {
#[diesel(table_name = document_correspondents)] #[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))] #[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))] #[diesel(primary_key(document_id, correspondent_id))]
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
@@ -298,7 +295,6 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent { pub struct NewDocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
+80 -34
View File
@@ -18,7 +18,6 @@ use uuid::Uuid;
doc::get_document, doc::get_document,
doc::update_document, doc::update_document,
doc::delete_document, doc::delete_document,
doc::download_document,
doc::download_with_token, doc::download_with_token,
doc::move_document, doc::move_document,
doc::assign_tags, doc::assign_tags,
@@ -57,13 +56,13 @@ use uuid::Uuid;
schemas::LoginResponseVariants, schemas::LoginResponseVariants,
schemas::DocumentResponse, schemas::DocumentResponse,
schemas::DocumentDetailResponse, schemas::DocumentDetailResponse,
schemas::DocumentVersion, schemas::DocumentVersionResponse,
schemas::DocumentVersionDetailResponse,
schemas::DocumentAssetSummary, schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail, schemas::DocumentAssetDetail,
schemas::DocumentAssetObject, schemas::DocumentAssetObject,
schemas::DocumentCorrespondent, schemas::DocumentCorrespondent,
schemas::DocumentTag, schemas::DocumentTag,
schemas::DocumentDownloadResponse,
schemas::UpdateDocumentRequest, schemas::UpdateDocumentRequest,
schemas::BulkMoveDocumentsRequest, schemas::BulkMoveDocumentsRequest,
schemas::BulkMoveDocumentsResponse, schemas::BulkMoveDocumentsResponse,
@@ -77,7 +76,6 @@ use uuid::Uuid;
schemas::BulkCorrespondentsResponse, schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction, schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest, schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest, schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse, schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams, schemas::DocumentAssetRequestParams,
@@ -96,6 +94,7 @@ use uuid::Uuid;
schemas::TagCatalogEntry, schemas::TagCatalogEntry,
schemas::CreateTagRequest, schemas::CreateTagRequest,
schemas::UpdateTagRequest, schemas::UpdateTagRequest,
schemas::CorrespondentUsage,
schemas::CorrespondentCatalogEntry, schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest, schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest, schemas::UpdateCorrespondentRequest,
@@ -240,12 +239,34 @@ mod doc {
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/documents/{id}/download", path = "/api/documents/{id}/versions",
params(("id" = Uuid, Path, description = "Document ID")), params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)), responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
tag = "Documents" tag = "Documents"
)] )]
pub(super) fn download_document() {} pub(super) fn list_document_versions() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub(super) fn get_document_version() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = RestoreDocumentRequest,
responses((status = 204, description = "Document restored")),
tag = "Documents"
)]
pub(super) fn restore_document() {}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -330,8 +351,7 @@ mod doc {
path = "/api/documents/{id}/correspondents/{correspondent_id}", path = "/api/documents/{id}/correspondents/{correspondent_id}",
params( params(
("id" = Uuid, Path, description = "Document ID"), ("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID"), ("correspondent_id" = Uuid, Path, description = "Correspondent ID")
RemoveCorrespondentParams
), ),
responses((status = 204, description = "Correspondent removed")), responses((status = 204, description = "Correspondent removed")),
tag = "Documents" tag = "Documents"
@@ -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)]
+17 -7
View File
@@ -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()) {
Ok(claims) => claims.sub,
Err(_) => state
.jwt .jwt
.verify_tenant_selector_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .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)?;
+17 -41
View File
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::HashMap;
use axum::{extract::Path, http::StatusCode, Json}; use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc; use chrono::Utc;
@@ -21,8 +21,6 @@ use crate::{
#[derive(Serialize)] #[derive(Serialize)]
pub struct CorrespondentUsage { pub struct CorrespondentUsage {
pub total: i64, pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(( .group_by(document_correspondents::correspondent_id)
document_correspondents::correspondent_id, .select((document_correspondents::correspondent_id, count_star()))
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?; .load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new(); let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
for (correspondent_id, role, count) in usage_rows { for (correspondent_id, count) in usage_rows {
usage_map usage_map.insert(correspondent_id, count);
.entry(correspondent_id)
.or_default()
.insert(role, count);
} }
let mut response = Vec::with_capacity(correspondents_list.len()); let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list { for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default(); let total = usage_map.remove(&correspondent.id).unwrap_or(0);
response.push(build_summary(correspondent, role_counts)); response.push(build_summary(correspondent, total));
} }
response.into_json() response.into_json()
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
.first(&mut conn) .first(&mut conn)
.one()?; .one()?;
build_summary(correspondent, BTreeMap::new()).into_json() build_summary(correspondent, 0).into_json()
} }
pub async fn update_correspondent( pub async fn update_correspondent(
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
no_content() no_content()
} }
fn build_summary( fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary { CorrespondentSummary {
id: correspondent.id, id: correspondent.id,
name: correspondent.name, name: correspondent.name,
metadata: correspondent.metadata, metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at), created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at), updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage { usage: CorrespondentUsage { total },
total,
by_role: role_counts,
},
} }
} }
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid, tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> { ) -> AppResult<i64> {
let rows: Vec<(String, i64)> = document_correspondents::table let total: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role) .select(count_star())
.select((document_correspondents::role, count_star())) .get_result(conn)?;
.load(conn)?;
let mut map = BTreeMap::new(); Ok(total)
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
} }
File diff suppressed because it is too large Load Diff
-120
View File
@@ -1,120 +0,0 @@
use std::path::Path as FsPath;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::state::AppState;
use crate::utils::time::to_iso;
use super::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
DocumentVersionResponse,
};
pub fn build_download_path(
state: &AppState,
document: &Document,
user_id: Uuid,
) -> AppResult<String> {
state
.jwt
.generate_download_token(document.id, user_id, document.tenant_id)
.map(|token| format!("/download/{token}"))
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(
version: DocumentVersion,
include_operations_summary: bool,
) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
operations_summary: if include_operations_summary {
Some(version.operations_summary)
} else {
None
},
}
}
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
cardinality: asset.cardinality,
}
}
pub fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
pub fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
pub fn derive_document_title(original: &str) -> String {
let trimmed = original.trim();
if trimmed.is_empty() {
return "Document".to_string();
}
let stem = FsPath::new(trimmed)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
stem.unwrap_or_else(|| trimmed.to_string())
}
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
let extension = FsPath::new(current_filename)
.extension()
.and_then(|ext| ext.to_str());
if let Some(ext) = extension {
if title
.rsplit_once('.')
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
.unwrap_or(false)
{
title.to_string()
} else {
format!("{title}.{ext}")
}
} else {
title.to_string()
}
}
@@ -1,61 +0,0 @@
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use super::CorrespondentAssignmentInput;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
pub fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
pub fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
+5 -4
View File
@@ -15,9 +15,10 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
}; };
use super::documents::{ use super::documents::{to_document_response, DocumentResponse};
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, use crate::documents::{
to_document_response, DocumentResponse, asset::load_primary_assets, correspondents::load_correspondents_for_documents,
tags::load_tags_for_documents,
}; };
use crate::utils::{ use crate::utils::{
json::{classify_nullable, NullableValue}, json::{classify_nullable, NullableValue},
@@ -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 {
+16 -2
View File
@@ -7,7 +7,10 @@ use axum::{
Router, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::{
cors::{AllowOrigin, CorsLayer},
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
};
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState}; use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
@@ -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)),
)
} }
+1 -4
View File
@@ -37,11 +37,9 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
document_correspondents (document_id, correspondent_id, role) { document_correspondents (document_id, correspondent_id) {
document_id -> Uuid, document_id -> Uuid,
correspondent_id -> Uuid, correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid, tenant_id -> Uuid,
@@ -69,7 +67,6 @@ diesel::table! {
#[max_length = 64] #[max_length = 64]
checksum -> Varchar, checksum -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid, tenant_id -> Uuid,
} }
+2 -28
View File
@@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use diesel::prelude::*; use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::json;
use tokio::task; use tokio::task;
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -104,7 +104,7 @@ fn analyze_document(
let tenant_id = document.tenant_id; let tenant_id = document.tenant_id;
let (supported, reason) = determine_thumbnail_support(&document); let (supported, _reason) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document); let ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table let existing_ocr: Option<DocumentAsset> = document_assets::table
@@ -117,32 +117,6 @@ fn analyze_document(
let skip_ocr = existing_ocr.is_some() && !payload.force; let skip_ocr = existing_ocr.is_some() && !payload.force;
let mut summary_map = match version.operations_summary {
Value::Object(map) => map,
_ => Map::new(),
};
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
if let Some(reason) = reason {
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
} else {
summary_map.remove("thumbnail_reason");
}
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
if ocr_supported {
summary_map.remove("ocr_reason");
} else {
summary_map.insert(
"ocr_reason".to_string(),
Value::String("document is not a PDF".into()),
);
}
diesel::update(document_versions::table.find(version.id))
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if supported { if supported {
let enqueue_result = enqueue_job( let enqueue_result = enqueue_job(
&mut conn, &mut conn,
+267 -4
View File
@@ -1,15 +1,49 @@
mod common; mod common;
use anyhow::Result; use anyhow::{anyhow, Context, Result};
use axum::http::StatusCode; use axum::body::Body;
use axum::http::{header::SET_COOKIE, StatusCode};
use backend::models::NewUserMembership;
use backend::schema::{tenants, user_memberships};
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticatedUser { struct AuthenticatedUser {
username: String, username: String,
} }
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
}
#[derive(Deserialize)]
struct LoginTenant {
slug: String,
}
#[derive(Deserialize)]
struct LoginResponse {
access_token: String,
tenant: LoginTenant,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantSummary {
tenant_id: Uuid,
slug: String,
}
#[tokio::test] #[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> { async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
let password = "s3cret"; let password = "s3cret";
app.insert_user("alice", password, "admin").await?; app.insert_user("alice", password, "admin").await?;
let token = app.login_token("alice", password).await?; let (login, _) = login_with_session(&app, "alice", password).await?;
let response = app.get("/api/auth/me", Some(&token)).await?; let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?; let user: AuthenticatedUser = serde_json::from_slice(&body)?;
@@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> {
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn login_rejects_unknown_user() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "ghost", "password": "nope" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "unauthorized");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "valid";
app.insert_user("robin", password, "admin").await?;
let payload = json!({ "username": "robin", "password": "wrong" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "unauthorized");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn refresh_rotates_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rotate";
app.insert_user("rita", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
let response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let new_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
assert_eq!(refreshed.tenant.slug, login.tenant.slug);
let me_response = app
.get("/api/auth/me", Some(&refreshed.access_token))
.await?;
assert_eq!(me_response.status(), StatusCode::OK);
let retry = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
// new cookie should differ from old to avoid reuse
assert_ne!(new_cookie, refresh_cookie);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn logout_revokes_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "logout";
app.insert_user("logan", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
let response = app
.post_json_with_cookie(
"/api/auth/logout",
&json!({}),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let cleared_cookie = extract_refresh_cookie(response.headers())?;
assert!(cleared_cookie.ends_with("="));
let after_logout = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn me_requires_authentication() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let unauthenticated = app.get("/api/auth/me", None).await?;
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "multipass";
let user_id = app.insert_user("multipass", password, "admin").await?;
let secondary_slug = "secondary".to_string();
let slug_for_insert = secondary_slug.clone();
let secondary_id = Uuid::new_v4();
app.with_conn(move |conn| {
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(secondary_id),
tenants::slug.eq(&slug_for_insert),
))
.execute(conn)?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id,
tenant_id: secondary_id,
role: "admin".to_string(),
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.execute(conn)?;
Ok(())
})
.await?;
let payload = json!({ "username": "multipass", "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
assert!(selection.tenants.len() >= 2);
let secondary = selection
.tenants
.iter()
.find(|tenant| tenant.slug == secondary_slug)
.map(|t| t.tenant_id)
.context("secondary tenant missing from selection")?;
let select_response = app
.post_json(
"/api/auth/select-tenant",
&json!({ "tenant_id": secondary }),
Some(&selection.access_token),
)
.await?;
assert_eq!(select_response.status(), StatusCode::OK);
let session_cookie = extract_refresh_cookie(select_response.headers())?;
let select_body = body_to_vec(select_response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(login.tenant.slug, secondary_slug);
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(me_response.status(), StatusCode::OK);
let refresh_response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
.await?;
assert_eq!(refresh_response.status(), StatusCode::OK);
app.cleanup().await?;
Ok(())
}
async fn login_with_session(
app: &TestApp,
username: &str,
password: &str,
) -> Result<(LoginResponse, String)> {
let payload = json!({ "username": username, "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
ensure_status(&response, StatusCode::OK)?;
let refresh_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&body)
.map_err(|_| anyhow!("expected login response with session"))?;
Ok((login, refresh_cookie))
}
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
let header_value = headers
.get(SET_COOKIE)
.context("missing set-cookie header")?
.to_str()
.context("invalid set-cookie header")?;
let cookie = header_value
.split(';')
.next()
.context("set-cookie missing cookie value")?
.to_string();
Ok(cookie)
}
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
if response.status() == expected {
Ok(())
} else {
Err(anyhow!(
"unexpected status: got {}, expected {}",
response.status(),
expected
))
}
}
+14 -1
View File
@@ -6,7 +6,7 @@ use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result}; use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use axum::body::Body; use axum::body::Body;
use axum::http::{Method, Request, StatusCode}; use axum::http::{header, Method, Request, StatusCode};
use axum::Router; use axum::Router;
use backend::auth::jwt::JwtService; use backend::auth::jwt::JwtService;
use backend::config::AppConfig; use backend::config::AppConfig;
@@ -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
+276
View File
@@ -0,0 +1,276 @@
mod common;
use anyhow::Result;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentSummary,
}
#[derive(Deserialize)]
struct DocumentSummary {
id: Uuid,
title: String,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentSummary>,
}
#[derive(Deserialize)]
struct DocumentCorrespondentSummary {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
struct TestContext {
app: TestApp,
token: String,
document_ids: Vec<Uuid>,
sender_id: Uuid,
receiver_id: Uuid,
}
impl TestContext {
const SENDER_NAME: &'static str = "Acme Corp";
const RECEIVER_NAME: &'static str = "Bank Ltd";
async fn new(prefix: &str) -> Result<Self> {
let app = TestApp::new().await?;
let username = format!("{prefix}_user");
let password = format!("{prefix}_pw");
app.insert_user(&username, &password, "admin").await?;
let token = app.login_token(&username, &password).await?;
let first_id =
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
let second_id =
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
Ok(Self {
app,
token,
document_ids: vec![first_id, second_id],
sender_id,
receiver_id,
})
}
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
self.assign_with_action(correspondent_ids, None).await
}
async fn assign_with_action(
&self,
correspondent_ids: &[Uuid],
action: Option<&str>,
) -> Result<BulkCorrespondentResult> {
let assignments: Vec<_> = correspondent_ids
.iter()
.map(|id| json!({ "correspondent_id": id }))
.collect();
let mut payload = json!({
"document_ids": self.document_ids,
"assignments": assignments,
});
if let Some(action) = action {
if let Some(obj) = payload.as_object_mut() {
obj.insert("action".to_string(), json!(action));
}
}
let response = self
.app
.post_json(
"/api/documents/bulk/correspondents",
&payload,
Some(&self.token),
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn fetch_correspondents(
&self,
document_id: Uuid,
) -> Result<Vec<DocumentCorrespondentSummary>> {
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
Ok(detail.document.correspondents)
}
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
create_correspondent(&self.app, &self.token, name).await
}
}
#[tokio::test]
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_add").await?;
let result = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(result.assigned, 4);
assert_eq!(result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::SENDER_NAME));
assert!(names.contains(&TestContext::RECEIVER_NAME));
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.sender_id));
assert!(ids.contains(&context.receiver_id));
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_idempotent").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let repeat = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(repeat.assigned, 0);
assert_eq!(repeat.removed, 0);
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_remove").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let removal = context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
assert_eq!(removal.assigned, 0);
assert_eq!(removal.removed, 2);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 1);
let entry = &correspondents[0];
assert_eq!(entry.id, context.receiver_id);
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_append").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
let charlie_name = "Charlie";
let charlie_id = context.create_correspondent(charlie_name).await?;
let add_result = context.assign(&[charlie_id]).await?;
assert_eq!(add_result.assigned, 2);
assert_eq!(add_result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 2);
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.receiver_id));
assert!(ids.contains(&charlie_id));
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::RECEIVER_NAME));
assert!(names.contains(&charlie_name));
}
context.app.cleanup().await?;
Ok(())
}
async fn upload_document(
app: &TestApp,
token: &str,
filename: &str,
contents: &[u8],
) -> Result<Uuid> {
let response = app
.upload_document(
"/api/documents",
filename,
"text/plain",
contents,
None,
token,
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
Ok(detail.document.id)
}
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
let response = app
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn fetch_document_detail(
app: &TestApp,
token: &str,
document_id: Uuid,
) -> Result<DocumentDetail> {
let response = app
.get(&format!("/api/documents/{document_id}"), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -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
-3
View File
@@ -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) : [];
if (!entries.length) {
return total.toString(); return total.toString();
}
const roleSummary = entries
.map(([role, count]) => `${role}: ${count}`)
.join(', ');
return `${total} (${roleSummary})`;
}, []); }, []);
return ( return (
+17
View File
@@ -0,0 +1,17 @@
import React, { createContext, useContext } from 'react';
const DesktopContext = createContext(null);
export const DesktopProvider = ({ value, children }) => (
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
);
export const useDesktopContext = () => {
const context = useContext(DesktopContext);
if (!context) {
throw new Error('useDesktopContext must be used within a DesktopProvider');
}
return context;
};
export default DesktopContext;
+17
View File
@@ -0,0 +1,17 @@
export const preventAll = (event) => {
if (!event) {
return;
}
try {
event.preventDefault();
} catch (
// eslint-disable-next-line no-empty
error
) {}
try {
event.stopPropagation();
} catch (
// eslint-disable-next-line no-empty
error
) {}
};
+8
View File
@@ -0,0 +1,8 @@
export const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
+252
View File
@@ -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;
+45 -487
View File
@@ -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,39 +27,23 @@ 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}`;
return (
<span key={key} className="correspondent-pill">
<span className="correspondent-pill__label"> <span className="correspondent-pill__label">
<strong>{formatRoleLabel(entry.role)}</strong>
<span> <span>
{entry.name} {entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''} {showCount && entry.count ? ` (${entry.count})` : ''}
@@ -73,13 +54,14 @@ const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
type="button" type="button"
className="correspondent-pill__remove" className="correspondent-pill__remove"
onClick={() => onRemove(entry)} onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`} aria-label={`Remove ${entry.name}`}
> >
× ×
</button> </button>
) : null} ) : null}
</span> </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}>
@@ -318,7 +298,6 @@ const DetailPanel = ({
onPromoteSelection, onPromoteSelection,
activePreviewId = null, activePreviewId = null,
onUpdateTitle = async () => false, onUpdateTitle = async () => false,
onUpdateIssuedAt = async () => false,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
correspondents = [], correspondents = [],
@@ -326,8 +305,6 @@ const DetailPanel = ({
onCorrespondentRemove, onCorrespondentRemove,
resolveApiPath, resolveApiPath,
onFolderNavigate = null, onFolderNavigate = null,
ollamaUrl = null,
ollamaModel = 'llama3',
resolveFolderPath = null, resolveFolderPath = null,
onClose = () => {}, onClose = () => {},
}) => { }) => {
@@ -357,12 +334,6 @@ const DetailPanel = ({
const [ocrLoading, setOcrLoading] = useState(false); const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null); const [ocrError, setOcrError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null); const [zoomedPreview, setZoomedPreview] = useState(null);
const ocrTextCacheRef = useRef({ docId: null, text: null });
const [aiSuggestion, setAiSuggestion] = useState(null);
const [aiSuggestionSelection, setAiSuggestionSelection] = useState({ title: true, issuedAt: true });
const [aiSuggestionLoading, setAiSuggestionLoading] = useState(false);
const [aiSuggestionError, setAiSuggestionError] = useState(null);
const [aiSuggestionApplying, setAiSuggestionApplying] = useState(false);
useEffect(() => { useEffect(() => {
if (!singleDoc) { if (!singleDoc) {
@@ -396,15 +367,6 @@ const DetailPanel = ({
setZoomedPreview(null); setZoomedPreview(null);
}, [selectionKey]); }, [selectionKey]);
useEffect(() => {
ocrTextCacheRef.current = { docId: null, text: null };
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(null);
setAiSuggestionLoading(false);
setAiSuggestionApplying(false);
}, [singleDoc?.id]);
const startTitleEdit = useCallback(() => { const startTitleEdit = useCallback(() => {
if (!singleDoc) return; if (!singleDoc) return;
setTitleEditDocId(singleDoc.id); setTitleEditDocId(singleDoc.id);
@@ -458,40 +420,6 @@ const DetailPanel = ({
[singleDoc, getDocumentAsset], [singleDoc, getDocumentAsset],
); );
const effectiveOllamaUrl = useMemo(
() => (ollamaUrl ? String(ollamaUrl).trim().replace(/\/$/, '') : null),
[ollamaUrl],
);
const effectiveOllamaModel = useMemo(
() => (ollamaModel ? String(ollamaModel).trim() || 'llama3' : 'llama3'),
[ollamaModel],
);
const canSuggestTitle = Boolean(singleDoc);
const suggestionDisabledReason = useMemo(() => {
if (!singleDoc) {
return 'Select a single document to generate suggestions.';
}
if (!hasOcrAsset) {
return 'OCR text is required to generate suggestions.';
}
if (!effectiveOllamaUrl) {
return 'Set an Ollama URL to enable AI suggestions.';
}
return null;
}, [singleDoc, hasOcrAsset, effectiveOllamaUrl]);
const suggestButtonDisabled = Boolean(aiSuggestionLoading || suggestionDisabledReason);
const canApplyAiSuggestion = useMemo(() => {
if (!aiSuggestion) {
return false;
}
const applyTitle = aiSuggestionSelection.title && Boolean(aiSuggestion.title);
const applyIssuedAt = aiSuggestionSelection.issuedAt && Boolean(aiSuggestion.issuedAt);
return applyTitle || applyIssuedAt;
}, [aiSuggestion, aiSuggestionSelection]);
const loadOcrUrl = useCallback(async () => { const loadOcrUrl = useCallback(async () => {
if (!singleDoc) { if (!singleDoc) {
return; return;
@@ -534,138 +462,6 @@ const DetailPanel = ({
} }
}, [singleDoc, ensureAssetUrl, getDocumentAsset]); }, [singleDoc, ensureAssetUrl, getDocumentAsset]);
const fetchOcrText = useCallback(async () => {
if (!singleDoc) {
throw new Error('No document selected.');
}
const cached = ocrTextCacheRef.current;
if (cached?.docId === singleDoc.id && typeof cached.text === 'string') {
return cached.text;
}
const asset = getDocumentAsset(singleDoc, 'ocr-text');
if (!asset) {
throw new Error('No OCR text available for this document.');
}
let entry = asset;
let url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url && typeof ensureAssetUrl === 'function') {
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
if (ensured) {
entry = ensured;
}
url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}
if (!url) {
throw new Error('OCR text URL is unavailable.');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch OCR text (status ${response.status})`);
}
const text = await response.text();
const trimmed = (text || '').trim();
if (!trimmed) {
throw new Error('OCR text is empty.');
}
ocrTextCacheRef.current = { docId: singleDoc.id, text: trimmed };
return trimmed;
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
const extractSuggestionPayload = useCallback((raw) => {
const tryParse = (input) => {
if (!input) return null;
try {
return JSON.parse(input);
} catch (error) {
return null;
}
};
const trimmed = (raw || '').trim();
if (!trimmed) {
return null;
}
let payload = tryParse(trimmed);
if (!payload) {
const fenced = trimmed.match(/```json([\s\S]*?)```/i);
if (fenced) {
payload = tryParse(fenced[1]);
}
}
if (!payload) {
const firstObject = trimmed.match(/\{[\s\S]*\}/);
if (firstObject) {
payload = tryParse(firstObject[0]);
}
}
if (!payload || typeof payload !== 'object') {
return null;
}
const title = typeof payload.title === 'string' ? payload.title : null;
const issuedAt =
typeof payload.issued_at === 'string'
? payload.issued_at
: typeof payload.issuedAt === 'string'
? payload.issuedAt
: null;
return { title, issuedAt };
}, []);
const normalizeIssuedAtSuggestion = useCallback((input) => {
if (!input) {
return null;
}
const raw = String(input).trim();
if (!raw || raw.toLowerCase() === 'null') {
return null;
}
const isoMatch = raw.match(/(\d{4})[-/.](\d{2})[-/.](\d{2})/);
let year;
let month;
let day;
if (isoMatch) {
year = isoMatch[1];
month = isoMatch[2];
day = isoMatch[3];
} else {
const parsed = Date.parse(raw);
if (Number.isNaN(parsed)) {
return null;
}
const date = new Date(parsed);
year = String(date.getUTCFullYear());
month = String(date.getUTCMonth() + 1).padStart(2, '0');
day = String(date.getUTCDate()).padStart(2, '0');
}
if (!year || !month || !day) {
return null;
}
return `${year}-${month}-${day}T00:00:00Z`;
}, []);
const openOcrModal = useCallback(() => { const openOcrModal = useCallback(() => {
if (!singleDoc) { if (!singleDoc) {
return; return;
@@ -678,154 +474,6 @@ const DetailPanel = ({
setOcrOpen(false); setOcrOpen(false);
}, []); }, []);
const handleSuggestTitle = useCallback(async () => {
if (!singleDoc) {
return;
}
if (suggestionDisabledReason) {
setAiSuggestionError(suggestionDisabledReason);
return;
}
setAiSuggestionLoading(true);
setAiSuggestionError(null);
try {
const ocrText = await fetchOcrText();
const snippetLimit = 6000;
const trimmedOcr = ocrText.length > snippetLimit ? `${ocrText.slice(0, snippetLimit)}` : ocrText;
const prompt = `You are helping rename scanned documents. Using only the OCR extract below, reply with a single JSON object of the form:
{
"title": "<short descriptive title in the document's original language or null>",
"issued_at": "<ISO8601 date YYYY-MM-DD if a clear issue/publication date exists, otherwise null>"
}
The title should be precise and descriptive (max 12 words), keep key entities (people, companies, case numbers, etc.), stay in the document's language, and omit surrounding quotes or trailing punctuation. Only include a date when the text clearly indicates one.
OCR TEXT:
${trimmedOcr}`;
const body = {
model: effectiveOllamaModel,
prompt,
stream: false,
};
const suggestionResponse = await fetch(`${effectiveOllamaUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!suggestionResponse.ok) {
throw new Error(`Ollama request failed (${suggestionResponse.status})`);
}
const data = await suggestionResponse.json();
const raw = (data?.response || data?.text || '').trim();
if (!raw) {
throw new Error('Model returned an empty response.');
}
const payload = extractSuggestionPayload(raw);
if (!payload) {
throw new Error('Unable to parse suggestion response.');
}
const cleanedTitle = payload.title
? payload.title
.replace(/^['"\s]+/, '')
.replace(/['"\s]+$/, '')
.replace(/[.!?]+$/, '')
.trim()
: null;
const normalizedIssuedAt = normalizeIssuedAtSuggestion(payload.issuedAt);
if (!cleanedTitle && !normalizedIssuedAt) {
throw new Error('No usable title or date found in the response.');
}
setAiSuggestion({
title: cleanedTitle || null,
issuedAt: normalizedIssuedAt,
raw,
model: effectiveOllamaModel,
});
setAiSuggestionSelection({
title: Boolean(cleanedTitle),
issuedAt: Boolean(normalizedIssuedAt),
});
setAiSuggestionError(null);
} catch (error) {
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(error.message || 'Failed to generate suggestions.');
} finally {
setAiSuggestionLoading(false);
}
}, [
singleDoc,
effectiveOllamaUrl,
effectiveOllamaModel,
fetchOcrText,
extractSuggestionPayload,
normalizeIssuedAtSuggestion,
suggestionDisabledReason,
]);
const handleApplySuggestion = useCallback(async () => {
if (!singleDoc || !aiSuggestion) {
return;
}
const applyTitle = Boolean(aiSuggestionSelection.title && aiSuggestion.title);
const applyIssuedAt = Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt);
if (!applyTitle && !applyIssuedAt) {
setAiSuggestionError('Select at least one suggestion to apply.');
return;
}
setAiSuggestionApplying(true);
setAiSuggestionError(null);
try {
let titleOk = true;
let issuedOk = true;
if (applyTitle) {
titleOk = await onUpdateTitle(singleDoc.id, aiSuggestion.title);
}
if (applyIssuedAt) {
issuedOk = await onUpdateIssuedAt(singleDoc.id, aiSuggestion.issuedAt);
}
if (titleOk && issuedOk) {
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(null);
} else {
const failures = [];
if (!titleOk && applyTitle) failures.push('title');
if (!issuedOk && applyIssuedAt) failures.push('date');
setAiSuggestionError(`Failed to update ${failures.join(' and ')}.`);
}
} finally {
setAiSuggestionApplying(false);
}
}, [
singleDoc,
aiSuggestion,
aiSuggestionSelection,
onUpdateTitle,
onUpdateIssuedAt,
]);
const handleDismissSuggestion = useCallback(() => {
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(null);
}, []);
const singlePreviewNavigator = useAssetNavigator({ const singlePreviewNavigator = useAssetNavigator({
document: singleDoc, document: singleDoc,
assetType: 'preview', assetType: 'preview',
@@ -1030,17 +678,14 @@ ${trimmedOcr}`;
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);
}); });
}); });
@@ -1048,23 +693,20 @@ ${trimmedOcr}`;
.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,
@@ -1075,11 +717,7 @@ ${trimmedOcr}`;
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(
@@ -1087,12 +725,11 @@ ${trimmedOcr}`;
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) => {
@@ -1388,87 +1025,10 @@ ${trimmedOcr}`;
> >
<EditIcon className="icon-inline" /> <EditIcon className="icon-inline" />
</button> </button>
{canSuggestTitle ? (
<button
type="button"
className="secondary detail-title-suggest-button"
onClick={suggestButtonDisabled ? undefined : handleSuggestTitle}
disabled={suggestButtonDisabled}
title={
aiSuggestionLoading
? 'Generating suggestion…'
: suggestionDisabledReason || 'Generate a title suggestion from OCR text'
}
>
{aiSuggestionLoading ? 'Suggesting…' : 'Suggest title'}
</button>
) : null}
</> </>
)} )}
</div> </div>
{titleError ? <div className="status-inline error">{titleError}</div> : null} {titleError ? <div className="status-inline error">{titleError}</div> : null}
{aiSuggestionError ? (
<div className="status-inline error">{aiSuggestionError}</div>
) : null}
{aiSuggestionLoading && !aiSuggestion ? (
<div className="status-inline">Generating title suggestion</div>
) : null}
{aiSuggestion ? (
<div className="detail-title-suggestion" role="status" aria-live="polite">
<div className="detail-title-suggestion__label">AI suggestions</div>
<div className="detail-title-suggestion__meta">Generated with {aiSuggestion.model}</div>
<div className="detail-title-suggestion__options">
<label className="detail-title-suggestion__option">
<input
type="checkbox"
checked={Boolean(aiSuggestionSelection.title && aiSuggestion.title)}
disabled={!aiSuggestion.title || aiSuggestionApplying}
onChange={(event) =>
setAiSuggestionSelection((previous) => ({
...previous,
title: event.target.checked,
}))
}
/>
<span>
<strong>Title:</strong>{' '}
{aiSuggestion.title || <em>Not available</em>}
</span>
</label>
<label className="detail-title-suggestion__option">
<input
type="checkbox"
checked={Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt)}
disabled={!aiSuggestion.issuedAt || aiSuggestionApplying}
onChange={(event) =>
setAiSuggestionSelection((previous) => ({
...previous,
issuedAt: event.target.checked,
}))
}
/>
<span>
<strong>Issued date:</strong>{' '}
{aiSuggestion.issuedAt
? new Date(aiSuggestion.issuedAt).toLocaleDateString()
: <em>Not available</em>}
</span>
</label>
</div>
<div className="detail-title-suggestion__actions">
<button
type="button"
onClick={handleApplySuggestion}
disabled={aiSuggestionApplying || !canApplyAiSuggestion}
>
{aiSuggestionApplying ? 'Applying…' : 'Apply selected'}
</button>
<button type="button" className="secondary" onClick={handleDismissSuggestion}>
Dismiss
</button>
</div>
</div>
) : null}
<div className="meta"> <div className="meta">
<div> <div>
<strong>Uploaded:</strong>{' '} <strong>Uploaded:</strong>{' '}
@@ -1560,14 +1120,12 @@ ${trimmedOcr}`;
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,
}) })
} }
@@ -1712,8 +1270,8 @@ ${trimmedOcr}`;
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"
@@ -1742,6 +1300,21 @@ ${trimmedOcr}`;
> >
<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
@@ -1770,21 +1343,6 @@ ${trimmedOcr}`;
<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"
+93 -27
View File
@@ -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>
+316 -17
View File
@@ -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,
};
};
+546 -383
View File
File diff suppressed because it is too large Load Diff
+189 -55
View File
@@ -1,13 +1,11 @@
import React from 'react'; import React, { useMemo } from 'react';
import { DownloadIcon } from '../ui/icons'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
const PreviewWorkspace = ({ const PreviewWorkspace = ({
document, document,
previewEntry, previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => { }) => {
if (!document) { if (!document) {
return null; return null;
@@ -15,59 +13,63 @@ const PreviewWorkspace = ({
const title = document.title || document.original_name || 'Document'; const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0; const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null; const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata = const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null; document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
const folderName = document.folder_path || document.folder_name || null;
const issuedAt = document.issued_at || document.current_version?.issued_at || null;
const createdAt = document.created_at || null;
const updatedAt = document.updated_at || null;
const tags = Array.isArray(document.tags) ? document.tags : [];
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
const metadataSummary = useMemo(() => {
const rows = [];
if (mime) rows.push(['Type', mime]);
if (sizeLabel) rows.push(['Size', sizeLabel]);
if (issuedAt) rows.push(['Issued', new Date(issuedAt).toLocaleString()]);
if (createdAt) rows.push(['Created', new Date(createdAt).toLocaleString()]);
if (updatedAt) rows.push(['Updated', new Date(updatedAt).toLocaleString()]);
if (folderName) rows.push(['Folder', folderName]);
if (tags.length) {
rows.push(['Tags', tags.map((tag) => tag.label || tag.name || tag.slug).filter(Boolean).join(', ')]);
}
if (correspondents.length) {
rows.push([
'Correspondents',
correspondents
.map((entry) => entry.name || entry.label || entry.slug)
.filter(Boolean)
.join(', '),
]);
}
return rows;
}, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]);
return ( return (
<section className="preview-workspace"> <section className="preview-workspace">
<header className="preview-workspace__header"> <aside className="preview-workspace__sidebar">
<div className="preview-workspace__meta"> <div className="preview-workspace__info">
<button {metadataSummary.length ? (
type="button" <dl className="preview-workspace__summary">
className="secondary" {metadataSummary.map(([label, value]) => (
onClick={() => onClose(document.folder_id ?? 'root')} <div className="preview-workspace__summary-row" key={label}>
> <dt>{label}</dt>
Back <dd>{value || '—'}</dd>
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div> </div>
))}
</dl>
) : null}
</div> </div>
<div className="preview-workspace__actions"> {metadata ? (
<a <section className="preview-workspace__metadata">
className="button-link with-icon" <h4>Metadata payload</h4>
href={downloadHref || '#'} <pre>{JSON.stringify(metadata, null, 2)}</pre>
target="_blank" </section>
rel="noopener noreferrer" ) : null}
aria-disabled={!downloadHref} </aside>
onClick={(event) => { <div className="preview-workspace__viewer">
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? ( {!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div> <div className="preview-workspace__message">Loading preview</div>
) : ( ) : (
@@ -78,15 +80,147 @@ const PreviewWorkspace = ({
/> />
)} )}
</div> </div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section> </section>
); );
}; };
export default PreviewWorkspace; export default PreviewWorkspace;
export const createPreviewWorkspaceHeaderActions = ({
document,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
}) => {
if (!document) {
return null;
}
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const ocrAsset = getDocumentAsset(document, 'ocr-text');
const hasOcr = Boolean(ocrAsset);
const handleOcrClick = async () => {
if (!ocrAsset) {
return;
}
try {
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
const entry = ensured || ocrAsset;
const url = entry?.url
|| resolveDocumentAssetUrl(document, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url) {
throw new Error('OCR text URL unavailable.');
}
window.open(url, '_blank', 'noopener,noreferrer');
} catch (error) {
notifyApiError(error, 'Unable to open OCR text.');
}
};
return (
<>
{downloadHref ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
{hasOcr ? (
<button
type="button"
className="icon-button ghost"
onClick={handleOcrClick}
aria-label="View OCR text"
title="View OCR text"
>
<TextScanIcon />
</button>
) : null}
<button
type="button"
className="icon-button ghost"
onClick={() => onRegenerate(document.id)}
aria-label="Re-run analysis"
title="Re-run analysis"
>
<AnalyzeIcon />
</button>
</>
);
};
export const createPreviewSurface = ({
document,
previewEntry,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
onClose,
renderSidebarToggle,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document preview';
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const closeButton = onClose
? (
<button
type="button"
className="icon-button ghost"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
: null;
const leading = sidebarToggle || closeButton
? (
<>
{sidebarToggle}
{closeButton}
</>
)
: null;
const header = {
title,
subtitle: null,
leading,
actions: createPreviewWorkspaceHeaderActions({
document,
ensureAssetUrl,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate,
}),
};
return {
key: 'preview',
variant: 'preview',
header,
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
supportsDetail: false,
};
};
+8 -27
View File
@@ -1,18 +1,13 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext'; import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => { const DocumentViewerRoute = () => {
const { const {
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData, ensurePreviewData,
notifyApiError, notifyApiError,
resolveApiPath,
} = useAppShell(); } = useAppShell();
const { documentId } = useParams(); const { documentId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,29 +39,15 @@ const DocumentViewerRoute = () => {
}; };
}, [documentId, ensurePreviewData, notifyApiError, navigate]); }, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady = if (!documentId) {
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId; return <Navigate to="/documents" replace />;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
} }
return ( if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
<main className="preview-main"> return <div className="preview-workspace__message">Loading preview</div>;
<PreviewWorkspace }
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry} return <Navigate to="/documents" replace />;
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}; };
export default DocumentViewerRoute; export default DocumentViewerRoute;
+124 -18
View File
@@ -1,5 +1,13 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, 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 -122
View File
@@ -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;
@@ -1840,60 +2150,6 @@ button.danger:hover:not([disabled]) {
color: var(--muted); color: var(--muted);
} }
.detail-panel .detail-title-suggest-button {
margin-left: 0.25rem;
}
.detail-panel .detail-title-suggestion {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: var(--surface-subtle);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.detail-panel .detail-title-suggestion__label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.detail-panel .detail-title-suggestion__value {
font-weight: 600;
color: var(--fg);
}
.detail-panel .detail-title-suggestion__meta {
font-size: 0.75rem;
color: var(--muted);
}
.detail-panel .detail-title-suggestion__actions {
display: flex;
gap: 0.5rem;
}
.detail-panel .detail-title-suggestion__options {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.detail-panel .detail-title-suggestion__option {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
}
.detail-panel .detail-title-suggestion__option input[type='checkbox'] {
width: 1rem;
height: 1rem;
}
.detail-panel .detail-folder-path { .detail-panel .detail-folder-path {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2147,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 {
+21 -1
View File
@@ -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"
@@ -247,6 +259,14 @@ function TagsPanel({
disabled={saving || deletingId === tag.id} disabled={saving || deletingId === tag.id}
aria-label="Pick tag color" aria-label="Pick tag color"
/> />
<button
type="button"
className="secondary"
onClick={() => setDraftColor(generateRandomTagColor())}
disabled={saving || deletingId === tag.id}
>
Random color
</button>
{draftColor && ( {draftColor && (
<button <button
type="button" type="button"
+32
View File
@@ -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 }) => (
+205 -8
View File
@@ -2,6 +2,8 @@ const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const clamp01 = (value) => Math.min(1, Math.max(0, value)); const clamp01 = (value) => Math.min(1, Math.max(0, value));
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
const gammaEncode = (channel) => const gammaEncode = (channel) =>
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055; channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
@@ -77,6 +79,37 @@ const hslToHex = (h, s, l) => {
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`; return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
}; };
const rgbToHsl = ({ r, g, b }) => {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let hue = 0;
if (delta !== 0) {
if (max === rn) {
hue = ((gn - bn) / delta) % 6;
} else if (max === gn) {
hue = (bn - rn) / delta + 2;
} else {
hue = (rn - gn) / delta + 4;
}
hue *= 60;
if (hue < 0) hue += 360;
}
const lightness = (max + min) / 2;
let saturation = 0;
if (delta !== 0) {
saturation = delta / (1 - Math.abs(2 * lightness - 1));
}
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
};
export const hexToRgb = (input) => { export const hexToRgb = (input) => {
if (!input) return null; if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim()); const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -102,20 +135,184 @@ export const relativeLuminance = ({ r, g, b }) => {
return 0.2126 * red + 0.7152 * green + 0.0722 * blue; return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}; };
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => { const contrastRatio = (lumA, lumB) => {
const rgb = hexToRgb(hex); const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
if (!rgb) return dark; return (lighter + 0.05) / (darker + 0.05);
const luminance = relativeLuminance(rgb); };
return luminance > 0.6 ? light : dark;
const parseCandidateColor = (candidate) => {
const rgb = hexToRgb(candidate);
if (!rgb) return null;
return {
hex: rgb.hex,
luminance: relativeLuminance(rgb),
};
};
const contrastForPair = (backgroundHex, textHex) => {
const background = hexToRgb(backgroundHex);
const text = hexToRgb(textHex);
if (!background || !text) {
return 0;
}
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
};
export const getReadableTextColor = (
hex,
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' } = {},
) => {
const background = hexToRgb(hex);
if (!background) return fallback;
const backgroundLuminance = relativeLuminance(background);
const backgroundHsl = rgbToHsl(background);
const hueShift = 180;
const textHue = (backgroundHsl.h + hueShift) % 360;
const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85);
const minLightness = 0.05;
const maxLightness = 0.95;
const sampleCount = 24;
const buildCandidate = (lightness) => {
const light = clampRange(lightness, minLightness, maxLightness);
const hexValue = hslToHex(textHue, targetSaturation, light);
return parseCandidateColor(hexValue);
};
const candidates = new Map();
for (let index = 0; index < sampleCount; index += 1) {
const t = index / (sampleCount - 1);
const candidateLightness = minLightness + t * (maxLightness - minLightness);
const candidate = buildCandidate(candidateLightness);
if (candidate) {
candidates.set(candidate.hex, candidate);
}
}
[light, dark].forEach((preset) => {
const parsed = parseCandidateColor(preset);
if (parsed) {
candidates.set(parsed.hex, parsed);
}
});
if (candidates.size === 0) {
return fallback;
}
let best = null;
let bestRatio = -Infinity;
candidates.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
const MIN_CONTRAST = 4.5;
if (bestRatio < MIN_CONTRAST) {
const extremeLight = buildCandidate(maxLightness);
const extremeDark = buildCandidate(minLightness);
const extremes = [extremeLight, extremeDark].filter(Boolean);
extremes.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
}
return best?.hex || fallback;
}; };
export const getTagColorStyle = (hex) => { export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex); const rgb = hexToRgb(hex);
if (!rgb) return null; if (!rgb) return null;
const baseHex = rgb.hex;
const baseHsl = rgbToHsl(rgb);
const baseText = getReadableTextColor(baseHex);
const baseRatio = contrastForPair(baseHex, baseText);
const TARGET_RATIO = 8;
const MIN_LIGHTNESS = 0.12;
const MAX_LIGHTNESS = 0.88;
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
const seen = new Map();
const registerCandidate = (lightness) => {
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
if (!seen.has(hexValue)) {
seen.set(hexValue, clamped);
}
};
registerCandidate(baseHsl.l);
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
let bestBackground = baseHex;
let bestText = baseText;
let bestRatio = baseRatio;
if (bestRatio >= TARGET_RATIO) {
return { return {
backgroundColor: rgb.hex, backgroundColor: bestBackground,
borderColor: rgb.hex, borderColor: bestBackground,
color: getReadableTextColor(rgb.hex), 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 {
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
}; };
}; };
-2
View File
@@ -48,8 +48,6 @@ module.exports = {
}), }),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL), 'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
'process.env.OLLAMA_BASE_URL': JSON.stringify(process.env.OLLAMA_BASE_URL || ''),
'process.env.OLLAMA_MODEL': JSON.stringify(process.env.OLLAMA_MODEL || ''),
}), }),
], ],
devServer: { devServer: {