21 Commits
Author SHA1 Message Date
nils e150b81190 rename skeuo to desktop 2025-10-28 10:41:56 +01:00
nils 94578475ea appconfig 2025-10-28 02:52:41 +01:00
nils 92c491b742 auth tests 2025-10-28 02:27:10 +01:00
nils eb93a2131f refactor 2025-10-28 02:05:38 +01:00
nils 1e177580c9 stuff 2025-10-28 01:38:19 +01:00
nils 7df9a6415a refactor 2025-10-28 01:18:35 +01:00
nils 1179f4fcd4 frontend tweaks, preview workspace 2025-10-28 00:43:54 +01:00
nils 709d32050e backend tracing 2025-10-28 00:30:45 +01:00
nils b9d84fa72b remove download endpoint 2025-10-28 00:17:57 +01:00
nils 7abc10acde backend 2025-10-28 00:01:40 +01:00
nils 9be0e3b5c4 frontend 2025-10-28 00:01:16 +01:00
nils ed0f0fb759 backend 2025-10-27 22:45:16 +01:00
nils 2d80515ad5 backend 2025-10-27 22:25:43 +01:00
nils 50125f4659 document updates 2025-10-27 22:06:33 +01:00
nils a6da34740f drag thumbs 2025-10-27 21:20:54 +01:00
nils 7ed06ccdcf drag 2025-10-27 21:06:29 +01:00
nils db07e0debb increase icon size in grid view 2025-10-27 20:55:29 +01:00
nils a0be094cbd table 2025-10-27 20:53:51 +01:00
nils 7366d2e16b frontend: tags 2025-10-27 20:29:21 +01:00
nils 82aa8948cf tenants 2025-10-27 11:32:22 +01:00
nils 84a3a9a5b5 skeuo aspect ratio 2025-10-27 01:55:37 +01:00
49 changed files with 5784 additions and 2797 deletions
+42
View File
@@ -611,6 +611,7 @@ dependencies = [
"diesel",
"diesel_migrations",
"dotenv",
"envy",
"futures-util",
"hex",
"http-body-util",
@@ -625,6 +626,7 @@ dependencies = [
"rand 0.8.5",
"reqwest",
"serde",
"serde-aux",
"serde_json",
"sha2",
"tempfile",
@@ -1175,6 +1177,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "envy"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965"
dependencies = [
"serde",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2160,6 +2171,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "outref"
version = "0.5.2"
@@ -2904,6 +2924,28 @@ dependencies = [
"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]]
name = "serde_core"
version = "1.0.228"
+2
View File
@@ -25,6 +25,8 @@ aws-credential-types = "1.2"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
envy = "0.4"
serde-aux = "4.4"
# Utilities
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 url::Url;
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_bool_from_anything;
use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Deserialize)]
pub struct AppConfig {
pub database_url: String,
#[serde(default = "default_database_max_pool_size")]
pub database_max_pool_size: u32,
#[serde(default = "default_server_host")]
pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16,
#[serde(default = "default_webdav_host")]
pub webdav_host: String,
#[serde(default = "default_webdav_port")]
pub webdav_port: u16,
pub jwt_secret: String,
#[serde(default = "default_jwt_issuer")]
pub jwt_issuer: String,
#[serde(default = "default_jwt_audience")]
pub jwt_audience: String,
#[serde(default = "default_jwt_expiry_minutes")]
pub jwt_expiry_minutes: i64,
#[serde(default = "default_download_token_audience")]
pub download_token_audience: String,
#[serde(default = "default_download_token_expiry_minutes")]
pub download_token_expiry_minutes: i64,
#[serde(default = "default_refresh_token_expiry_days")]
pub refresh_token_expiry_days: i64,
#[serde(
default = "default_refresh_cookie_secure",
deserialize_with = "deserialize_bool_from_anything"
)]
pub refresh_cookie_secure: bool,
#[serde(default)]
pub refresh_cookie_domain: Option<String>,
#[serde(default)]
pub cors_allowed_origin: Option<String>,
#[serde(default)]
pub aws_endpoint_url: Option<String>,
#[serde(default)]
pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>,
#[serde(default = "default_aws_region")]
pub aws_region: String,
pub s3_bucket: String,
#[serde(default)]
pub quickwit_endpoint: Option<String>,
#[serde(default)]
pub quickwit_index: Option<String>,
#[serde(default = "default_tenant_slug")]
pub default_tenant_slug: String,
}
@@ -49,80 +74,9 @@ impl AppConfig {
}
pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
.ok()
.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,
})
let config: AppConfig = envy::from_env()
.context("failed to parse application configuration from environment")?;
Ok(config.normalize())
}
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 {
match Url::parse(raw) {
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;
pub const QUICKWIT_MAX_HITS: usize = 200;
pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input
.split_whitespace()
@@ -38,6 +46,67 @@ pub fn escape_quickwit_token(token: &str) -> String {
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> {
for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) {
@@ -89,3 +158,9 @@ pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
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 config;
pub mod db;
pub mod documents;
pub mod error;
pub mod jobs;
pub mod models;
+1 -5
View File
@@ -123,7 +123,6 @@ pub struct DocumentVersion {
pub size_bytes: i64,
pub checksum: String,
pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
}
@@ -137,7 +136,6 @@ pub struct NewDocumentVersion {
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
}
@@ -283,11 +281,10 @@ pub struct NewCorrespondent {
#[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))]
#[diesel(primary_key(document_id, correspondent_id))]
pub struct DocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
@@ -298,7 +295,6 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
}
+88 -35
View File
@@ -18,7 +18,6 @@ use uuid::Uuid;
doc::get_document,
doc::update_document,
doc::delete_document,
doc::download_document,
doc::download_with_token,
doc::move_document,
doc::assign_tags,
@@ -57,13 +56,13 @@ use uuid::Uuid;
schemas::LoginResponseVariants,
schemas::DocumentResponse,
schemas::DocumentDetailResponse,
schemas::DocumentVersion,
schemas::DocumentVersionResponse,
schemas::DocumentVersionDetailResponse,
schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail,
schemas::DocumentAssetObject,
schemas::DocumentCorrespondent,
schemas::DocumentTag,
schemas::DocumentDownloadResponse,
schemas::UpdateDocumentRequest,
schemas::BulkMoveDocumentsRequest,
schemas::BulkMoveDocumentsResponse,
@@ -77,7 +76,6 @@ use uuid::Uuid;
schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams,
@@ -96,6 +94,7 @@ use uuid::Uuid;
schemas::TagCatalogEntry,
schemas::CreateTagRequest,
schemas::UpdateTagRequest,
schemas::CorrespondentUsage,
schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest,
@@ -240,12 +239,34 @@ mod doc {
#[utoipa::path(
get,
path = "/api/documents/{id}/download",
path = "/api/documents/{id}/versions",
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"
)]
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(
get,
@@ -330,8 +351,7 @@ mod doc {
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
RemoveCorrespondentParams
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
@@ -539,6 +559,7 @@ pub mod schemas {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -547,9 +568,15 @@ pub mod schemas {
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet {
pub id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub access_token: String,
pub tenants: Vec<TenantSummary>,
}
@@ -569,11 +596,26 @@ pub mod schemas {
#[into_params(parameter_in = Query)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
pub include_deleted: Option<bool>,
#[schema(nullable)]
pub include_descendants: Option<bool>,
pub query: Option<String>,
pub tags: 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)]
@@ -618,17 +660,21 @@ pub mod schemas {
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersion {
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub checksum: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub metadata: Value,
#[schema(nullable)]
pub operations_summary: Option<Value>,
#[schema(nullable)]
pub assets: Option<Vec<DocumentAssetSummary>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersionDetailResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetSummary>,
pub download_path: String,
}
@@ -636,7 +682,6 @@ pub mod schemas {
pub struct DocumentCorrespondent {
pub id: Uuid,
pub name: String,
pub role: String,
pub metadata: Value,
pub assigned_at: String,
}
@@ -659,10 +704,10 @@ pub mod schemas {
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<DocumentTag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondent>,
#[schema(nullable)]
pub correspondents: Option<Vec<DocumentCorrespondent>>,
#[schema(nullable)]
pub current_version: Option<DocumentVersion>,
pub current_version: Option<DocumentVersionDetailResponse>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -671,19 +716,27 @@ pub mod schemas {
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentDownloadResponse {
pub url: String,
pub expires_in: u64,
pub filename: String,
pub struct DocumentMetadataUpdate {
pub value: Value,
#[serde(default)]
#[schema(default = false)]
pub replace: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct RestoreDocumentRequest {
#[schema(nullable)]
pub content_type: Option<String>,
pub size_bytes: i64,
pub folder_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateDocumentRequest {
#[schema(nullable)]
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)]
@@ -732,7 +785,6 @@ pub mod schemas {
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentAssignment {
pub correspondent_id: Uuid,
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -767,12 +819,6 @@ pub mod schemas {
pub removed: usize,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct RemoveCorrespondentParams {
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeRequest {
pub document_ids: Vec<Uuid>,
@@ -926,12 +972,19 @@ pub mod schemas {
pub color: Option<Option<String>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentUsage {
pub total: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentCatalogEntry {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub role_counts: Value,
pub created_at: String,
pub updated_at: String,
pub usage: CorrespondentUsage,
}
#[derive(Serialize, Deserialize, ToSchema)]
+74 -10
View File
@@ -43,6 +43,7 @@ pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize)]
@@ -51,12 +52,23 @@ pub struct TenantSummary {
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSnippet {
pub id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub access_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Serialize)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
@@ -68,9 +80,15 @@ pub async fn login(
) -> AppResult<Response> {
let mut conn = state.db_unscoped()?;
let user: User = dsl::users
let user: Option<User> = dsl::users
.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)
.map_err(|_| AppError::unauthorized())?;
@@ -121,7 +139,7 @@ pub async fn login(
.collect();
let response = Json(TenantSelectionResponse {
selection_token,
access_token: selection_token,
tenants,
})
.into_response();
@@ -174,15 +192,19 @@ pub async fn select_tenant(
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> {
let claims = state
.jwt
.verify_tenant_selector_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
Ok(claims) => claims.sub,
Err(_) => state
.jwt
.verify_token(bearer.token())
.map(|claims| claims.sub)
.map_err(|_| AppError::unauthorized())?,
};
let mut conn = state.db_unscoped()?;
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))
.inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id)
@@ -194,7 +216,7 @@ pub async fn select_tenant(
}
let user: User = dsl::users
.find(claims.sub)
.find(user_id)
.first(&mut conn)
.map_err(AppError::from)?;
@@ -250,6 +272,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
pub async fn list_tenants(
State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
) -> AppResult<Json<TenantListResponse>> {
let bearer = auth.ok_or_else(AppError::unauthorized)?;
let token = bearer.token();
let user_id = match state.jwt.verify_token(token) {
Ok(claims) => claims.sub,
Err(_) => {
let claims = state
.jwt
.verify_tenant_selector_token(token)
.map_err(|_| AppError::unauthorized())?;
claims.sub
}
};
let mut conn = state.db_unscoped()?;
let tenants = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user_id))
.select((tenant_dsl::id, tenant_dsl::slug))
.load::<(Uuid, String)>(&mut conn)?
.into_iter()
.map(|(id, slug)| TenantSnippet { id, slug })
.collect();
Ok(Json(TenantListResponse { tenants }))
}
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
@@ -262,6 +316,12 @@ fn issue_session(
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let tenant_slug: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::slug)
.first(conn)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
@@ -283,6 +343,10 @@ fn issue_session(
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: tenant_id,
slug: tenant_slug,
},
})
.into_response();
+17 -41
View File
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc;
@@ -21,8 +21,6 @@ use crate::{
#[derive(Serialize)]
pub struct CorrespondentUsage {
pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
}
#[derive(Serialize)]
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
.order(correspondents::name.asc())
.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))
.group_by((
document_correspondents::correspondent_id,
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.group_by(document_correspondents::correspondent_id)
.select((document_correspondents::correspondent_id, count_star()))
.load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
for (correspondent_id, role, count) in usage_rows {
usage_map
.entry(correspondent_id)
.or_default()
.insert(role, count);
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
for (correspondent_id, count) in usage_rows {
usage_map.insert(correspondent_id, count);
}
let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
response.push(build_summary(correspondent, role_counts));
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
response.push(build_summary(correspondent, total));
}
response.into_json()
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
.first(&mut conn)
.one()?;
build_summary(correspondent, BTreeMap::new()).into_json()
build_summary(correspondent, 0).into_json()
}
pub async fn update_correspondent(
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
no_content()
}
fn build_summary(
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
CorrespondentSummary {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage {
total,
by_role: role_counts,
},
usage: CorrespondentUsage { total },
}
}
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table
) -> AppResult<i64> {
let total: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role)
.select((document_correspondents::role, count_star()))
.load(conn)?;
.select(count_star())
.get_result(conn)?;
let mut map = BTreeMap::new();
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
Ok(total)
}
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},
};
use super::documents::{
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, DocumentResponse,
use super::documents::{to_document_response, DocumentResponse};
use crate::documents::{
asset::load_primary_assets, correspondents::load_correspondents_for_documents,
tags::load_tags_for_documents,
};
use crate::utils::{
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)?;
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());
for doc in docs {
+17 -2
View File
@@ -7,7 +7,10 @@ use axum::{
Router,
};
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 crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
@@ -54,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant))
.route("/tenants", get(auth::list_tenants))
.route("/me", get(auth::me));
let documents_routes = Router::new()
@@ -78,12 +82,17 @@ pub fn create_router(state: AppState) -> Router<()> {
.delete(documents::delete_document)
.patch(documents::update_document),
)
.route("/:id/download", get(documents::download_document))
.route(
"/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets),
)
.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/:tag_id", delete(documents::remove_tag))
.route(
@@ -153,4 +162,10 @@ pub fn create_router(state: AppState) -> Router<()> {
.with_state(state)
.layer(cors)
.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! {
document_correspondents (document_id, correspondent_id, role) {
document_correspondents (document_id, correspondent_id) {
document_id -> Uuid,
correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,
@@ -69,7 +67,6 @@ diesel::table! {
#[max_length = 64]
checksum -> Varchar,
created_at -> Timestamptz,
operations_summary -> Jsonb,
metadata -> Jsonb,
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 diesel::prelude::*;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use serde_json::json;
use tokio::task;
use tracing::{error, warn};
use uuid::Uuid;
@@ -104,7 +104,7 @@ fn analyze_document(
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 existing_ocr: Option<DocumentAsset> = document_assets::table
@@ -117,32 +117,6 @@ fn analyze_document(
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 {
let enqueue_result = enqueue_job(
&mut conn,
+267 -4
View File
@@ -1,15 +1,49 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use anyhow::{anyhow, Context, Result};
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 diesel::prelude::*;
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct AuthenticatedUser {
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]
async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await;
@@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> {
let password = "s3cret";
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);
let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
@@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> {
app.cleanup().await?;
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
))
}
}
+16 -3
View File
@@ -6,7 +6,7 @@ use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::http::{header, Method, Request, StatusCode};
use axum::Router;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
@@ -328,7 +328,7 @@ impl TestApp {
#[derive(Deserialize)]
struct TenantSelectionResponse {
selection_token: String,
access_token: String,
tenants: Vec<TenantSummary>,
}
@@ -350,7 +350,7 @@ impl TestApp {
&SelectTenantPayload {
tenant_id: target_tenant,
},
Some(&selection.selection_token),
Some(&selection.access_token),
)
.await?;
@@ -396,6 +396,16 @@ impl TestApp {
path: &str,
payload: &T,
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>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
@@ -405,6 +415,9 @@ impl TestApp {
if let Some(token) = 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))?;
Ok(self
.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
+11 -9
View File
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
Authentication
--------------
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
- GET /api/auth/me - Return the authenticated principal payload.
@@ -16,22 +16,24 @@ Health
Documents
---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
- 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/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.
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title).
- 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.
- 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.
- 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`.
- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string).
- 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.
Document Assets
---------------
@@ -62,7 +64,7 @@ Tags
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).
- PATCH /api/correspondents/:id - Update name and/or metadata.
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
@@ -6,6 +6,20 @@
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 {
flex: 1;
display: flex;
@@ -33,29 +47,14 @@
font-size: 0.95rem;
}
.skeuo-item {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
width: auto;
cursor: grab;
touch-action: none;
transform-origin: center center;
transition: transform 0.28s ease, box-shadow 0.16s ease;
outline: none;
will-change: transform;
}
.skeuo-item__body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
transition: width 0.28s ease, height 0.28s ease;
justify-content: center;
flex-grow: 1;
}
.skeuo-item:focus-visible {
@@ -69,8 +68,8 @@
}
.skeuo-item.is-tag-target .skeuo-item__card {
outline: 1em dashed var(--accent);
outline-offset: 1.41em;
outline: 0.35rem dashed var(--accent);
outline-offset: 0.35rem;
}
.skeuo-item.is-tag-pending .skeuo-item__card {
@@ -99,50 +98,39 @@
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 {
font-size: 0.75rem;
padding: 0.18rem 0.55rem;
.tag-chip--draggable {
user-select: none;
pointer-events: auto;
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;
pointer-events: none;
}
.skeuo-tag span {
display: block;
pointer-events: none;
user-select: none;
.skeuo-item__tags .tag-chip {
font-size: 0.85rem;
padding: 0.18rem 0.55rem;
gap: 0.3rem;
}
.skeuo-card__nav {
position: absolute;
bottom: calc(3em * 0.707 * var(--nav-scale));
bottom: 1.8rem;
left: 50%;
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
transform: translateX(-50%);
transform-origin: center;
display: flex;
gap: 2rem;
gap: 1.5rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
@@ -157,10 +145,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.3em;
height: 3.3em;
padding: 0.36em;
border-radius: 3.3em;
width: 2.4em;
height: 2.4em;
padding: 0.25em;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
@@ -187,8 +175,8 @@
height: 100%;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4;
.skeuo-item__tags .tag-chip--tear-pending {
opacity: 0.35;
}
body.skeuo-cursor-remove,
@@ -206,9 +194,17 @@ body.skeuo-cursor-remove * {
display: flex;
align-items: 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;
--nav-scale: 1;
}
.skeuo-item__card img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.skeuo-item__card--empty {
@@ -250,6 +246,7 @@ body.skeuo-cursor-remove * {
color: #3b3b3b;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
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';
}
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();
}
const roleSummary = entries
.map(([role, count]) => `${role}: ${count}`)
.join(', ');
return `${total} (${roleSummary})`;
return total.toString();
}, []);
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})`;
+248
View File
@@ -0,0 +1,248 @@
import { useCallback, useRef } from 'react';
import { useDesktopContext } from './context';
import { preventAll } from './events';
import { clamp, formatTransform } from './math';
const useDocumentDrag = () => {
const {
layoutRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
syncLayoutSnapshot,
canvasSize,
openOverlayForDoc,
recalcVisibleDocIds,
settings,
} = useDesktopContext();
const dragStateRef = useRef(null);
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
const finishDrag = useCallback(
(pointerId) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== pointerId) {
return;
}
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot();
},
[setDraggingId, syncLayoutSnapshot],
);
const handlePointerDown = useCallback(
(event, docId) => {
if (debugDrag) {
console.log(
'[skeuo] handlePointerDown fired for doc',
docId,
'button',
event.button,
'pointerType',
event.pointerType,
'pointerId',
event.pointerId,
);
}
preventAll(event);
const entry = layoutRef.current.get(docId) || null;
const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
const normalizedBaseScale =
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2;
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
layoutRef.current.set(docId, { ...entry, centerX, centerY });
}
bringToFront(docId);
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = {
docId,
pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
moved: false,
locked: false,
width: docWidth,
height: docHeight,
dragScale: 1,
baseScale: normalizedBaseScale,
capturedTarget,
};
setDraggingId(docId);
},
[
bringToFront,
canvasPadding,
documentLookup,
ensureDocumentSize,
layoutRef,
resolveBaseMetrics,
setDraggingId,
debugDrag,
],
);
const handlePointerMove = useCallback(
(event) => {
const state = dragStateRef.current;
if (!state) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId);
}
return;
}
if (state.pointerId !== event.pointerId) {
if (debugDrag) {
console.log(
'[skeuo] handlePointerMove: pointer mismatch expected',
state.pointerId,
'got',
event.pointerId,
);
}
return;
}
preventAll(event);
if (state.locked) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId);
}
return;
}
const entry = layoutRef.current.get(state.docId);
if (!entry) {
return;
}
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || defaultCanvasWidth;
const canvasHeight = canvasSize.height || defaultCanvasHeight;
const minCenterX = canvasPadding + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
const minCenterY = canvasPadding + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
const prevCenterX = typeof entry.centerX === 'number' ? entry.centerX : state.originCenterX;
const prevCenterY = typeof entry.centerY === 'number' ? entry.centerY : state.originCenterY;
if (Math.abs(clampedCenterX - prevCenterX) < 0.5 && Math.abs(clampedCenterY - prevCenterY) < 0.5) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId);
}
return;
}
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
layoutRef.current.set(state.docId, updated);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
clampedCenterX - state.width / 2,
clampedCenterY - state.height / 2,
state.rotation,
state.dragScale || 1,
);
}
state.moved = true;
if (debugDrag) {
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
}
recalcVisibleDocIds();
},
[
canvasPadding,
canvasSize.height,
canvasSize.width,
defaultCanvasHeight,
defaultCanvasWidth,
itemRefs,
layoutRef,
recalcVisibleDocIds,
debugDrag,
],
);
const handlePointerUp = useCallback(
(event) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) {
const moved = Boolean(state.moved);
const docId = state.docId;
const shouldOpen = !moved && event.detail >= 2;
finishDrag(event.pointerId);
if (shouldOpen) {
const originInfo = {
rotation: state.rotation || 0,
scale: state.baseScale || 1,
width: state.width,
height: state.height,
};
openOverlayForDoc(docId, originInfo);
}
return;
}
finishDrag(event.pointerId);
},
[finishDrag, openOverlayForDoc],
);
const handlePointerCancel = useCallback(
(event) => {
finishDrag(event.pointerId);
},
[finishDrag],
);
return {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
};
};
export default useDocumentDrag;
+64 -96
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DownloadIcon,
EditIcon,
@@ -13,13 +14,10 @@ import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
import PreviewZoomOverlay from './PreviewZoomOverlay';
const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase();
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
@@ -29,56 +27,41 @@ const derivePreviewOrientation = (metadata) => {
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 = []) =>
entries
.map((entry) => ({
id: entry.id,
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 }) => (
<div className="correspondent-list">
{entries.length ? (
entries.map((entry) => (
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
<span className="correspondent-pill__label">
<strong>{formatRoleLabel(entry.role)}</strong>
<span>
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
entries.map((entry, index) => {
const key = entry.id ?? `${entry.name}-${index}`;
return (
<span key={key} className="correspondent-pill">
<span className="correspondent-pill__label">
<span>
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
</span>
</span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name}`}
>
×
</button>
) : null}
</span>
{onRemove ? (
<button
type="button"
className="correspondent-pill__remove"
onClick={() => onRemove(entry)}
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
>
×
</button>
) : null}
</span>
))
);
})
) : (
<span className="meta">No correspondents yet.</span>
)}
@@ -104,11 +87,18 @@ const TagSection = ({
tags.map((tag) => {
const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color);
const removable = Boolean(onRemove);
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
return (
<span key={key} className="tag-pill" style={style || undefined}>
{tag.label}{' '}
{onRemove ? (
<button type="button" onClick={() => onRemove(tag)}>
<span key={key} className={className} style={style || undefined}>
<span className="tag-chip__label">{tag.label}</span>
{removable ? (
<button
type="button"
className="tag-chip__remove"
onClick={() => onRemove(tag)}
aria-label={`Remove tag ${tag.label}`}
>
×
</button>
) : null}
@@ -166,11 +156,9 @@ const CorrespondentSection = ({
event.preventDefault();
const form = event.currentTarget;
const nameInput = form.elements.correspondent;
const roleSelect = form.elements.role;
const value = nameInput.value.trim();
const role = roleSelect.value;
if (!value) return;
onAdd({ name: value, role, input: nameInput });
onAdd({ name: value, input: nameInput });
form.reset();
}}
>
@@ -179,13 +167,6 @@ const CorrespondentSection = ({
placeholder={addPlaceholder}
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>
{datalistId ? (
<datalist id={datalistId}>
@@ -697,17 +678,14 @@ const DetailPanel = ({
if (!doc?.id) return;
(doc.correspondents || []).forEach((entry) => {
if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
const key = `${entry.id}:${normalizedRole}`;
if (!map.has(key)) {
map.set(key, {
if (!map.has(entry.id)) {
map.set(entry.id, {
id: entry.id,
name: entry.name || '',
role: normalizedRole,
documentIds: new Set(),
});
}
map.get(key).documentIds.add(doc.id);
map.get(entry.id).documentIds.add(doc.id);
});
});
@@ -715,23 +693,20 @@ const DetailPanel = ({
.map((entry) => ({
id: entry.id,
name: entry.name,
role: entry.role,
documentIds: [...entry.documentIds],
count: entry.documentIds.size,
}))
.sort(compareCorrespondents);
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}, [selectedDocuments]);
const handleBulkCorrespondentRemove = useCallback(
(entry) => {
if (!entry?.id) return;
const normalizedRole = normalizeRole(entry.role);
if (onBulkCorrespondentRemove) {
return onBulkCorrespondentRemove({
assignments: [
{
correspondent_id: entry.id,
role: normalizedRole,
},
],
documentIds: entry.documentIds,
@@ -742,11 +717,7 @@ const DetailPanel = ({
const targets = entry.documentIds && entry.documentIds.length
? entry.documentIds
: selectedDocuments
.filter((doc) =>
(doc.correspondents || []).some(
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
),
)
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
.map((doc) => doc.id);
return Promise.all(
@@ -754,12 +725,11 @@ const DetailPanel = ({
onCorrespondentRemove({
documentId,
correspondentId: entry.id,
role: normalizedRole,
}),
),
).catch(() => {});
},
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
);
const openZoomPreview = useCallback((config) => {
@@ -1150,14 +1120,12 @@ const DetailPanel = ({
onCorrespondentRemove?.({
documentId: singleDoc.id,
correspondentId: entry.id,
role: entry.role,
})
}
onAdd={({ name, role, input }) =>
onAdd={({ name, input }) =>
onCorrespondentAdd?.({
document: singleDoc,
name,
role,
input,
})
}
@@ -1302,8 +1270,8 @@ const DetailPanel = ({
title="Correspondents"
entries={bulkCorrespondents}
onRemove={handleBulkCorrespondentRemove}
onAdd={({ name, role, input }) =>
onBulkCorrespondentAdd?.({ name, role, input })
onAdd={({ name, input }) =>
onBulkCorrespondentAdd?.({ name, input })
}
addPlaceholder="Add correspondent to selection"
datalistId="correspondent-catalog-bulk"
@@ -1323,15 +1291,30 @@ const DetailPanel = ({
<aside className="detail-panel panel">
<div className="panel-header">
<div className="panel-actions">
<button
type="button"
className="icon-button ghost"
onClick={onClose}
<button
type="button"
className="icon-button ghost"
onClick={onClose}
aria-label="Close detail panel"
title="Close detail panel"
>
<ChevronsRightIcon />
</button>
{singleDoc ? (
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
onOpenPreview(singleDoc.id);
}}
aria-label="Open preview"
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
</button>
) : null}
<div className="spacer" />
{isBulkSelection && onBulkReanalyze ? (
<button
@@ -1360,21 +1343,6 @@ const DetailPanel = ({
<DownloadIcon />
</a>
) : 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 ? (
<button
type="button"
+316 -17
View File
@@ -1,10 +1,22 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
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 DEFAULT_GRID_ICON_SIZE = 96;
const DEFAULT_GRID_ICON_SIZE = 144;
const DEFAULT_GRID_TITLE_SIZE = '11px';
const LIST_ICON_SIZE = 48;
@@ -13,6 +25,39 @@ const getPageCount = (doc) =>
? doc.current_version.metadata.page_count
: null;
const resolveCorrespondents = (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry, index) => {
if (!entry) return;
const id = entry.id ?? entry.correspondent_id ?? null;
const name = (entry.name || entry.label || entry.slug || '').trim();
if (!name) return;
if (id && seen.has(id)) {
return;
}
if (id) {
seen.add(id);
}
results.push({
id,
name,
key: id ?? `${name}-${index}`,
});
});
return results;
};
// Detects when an element becomes visible within a scroll container.
const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null);
@@ -111,6 +156,25 @@ const DocumentThumbnailImage = ({
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 (
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}>
@@ -165,6 +229,7 @@ const DocumentsTable = ({
onFolderRename,
onDocumentRename,
tagLookupById,
activeCorrespondentIds = [],
onDocumentListFocus,
onDocumentListKeyDown,
onFocusedRowChange,
@@ -172,6 +237,7 @@ const DocumentsTable = ({
getDocumentAsset = () => null,
getDownloadHref,
onTagClick,
onCorrespondentClick,
isSearchLoading = false,
onDocumentTagDrop,
viewMode = 'list',
@@ -194,7 +260,12 @@ const DocumentsTable = ({
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const activeCorrespondentIdSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const scrollRef = useRef(null);
const suppressDocumentClickRef = useRef(false);
const [, forceVisibilityTick] = useState(0);
const lastScrollNodeRef = useRef(null);
const assignScrollRef = useCallback((node) => {
@@ -333,6 +404,79 @@ const DocumentsTable = ({
[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 showListSearchEmptyState =
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
@@ -498,6 +642,7 @@ const DocumentsTable = ({
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
const visibleTags = tagList.slice(0, 3);
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
const correspondents = resolveCorrespondents(doc);
const cardClasses = ['document-card', 'document'];
if (isSelected) cardClasses.push('selected');
if (isDraggingDoc) cardClasses.push('is-dragging');
@@ -508,11 +653,11 @@ const DocumentsTable = ({
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onClick={(event) => handleDocumentClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={onDocumentDragEnd}
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
onDragEnd={handleDocumentDragEndLocal}
onDragOver={(event) => handleDocumentTagDragOver(event)}
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
onDragLeave={handleDocumentTagDragLeave}
@@ -533,7 +678,12 @@ const DocumentsTable = ({
className="document-card__title"
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>
{visibleTags.length > 0 && (
<div className="document-card__tags">
@@ -606,8 +756,7 @@ const DocumentsTable = ({
<tr>
<th className="thumb-column">Preview</th>
<th>Name</th>
<th>Type</th>
<th>Updated</th>
<th>Issued</th>
<th className="actions-column">Actions</th>
</tr>
</thead>
@@ -671,7 +820,6 @@ const DocumentsTable = ({
<span>{folder.name}</span>
</div>
</td>
<td>Folder</td>
<td></td>
<td className="actions">
<div className="action-buttons">
@@ -721,6 +869,7 @@ const DocumentsTable = ({
if (isSelected) rowClasses.push('selected');
if (isDraggingDoc) rowClasses.push('is-dragging');
const downloadHref = getDownloadHref?.(doc) || null;
const correspondents = resolveCorrespondents(doc);
return (
<tr
@@ -728,11 +877,11 @@ const DocumentsTable = ({
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onClick={(event) => handleDocumentClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
onDragStart={(event) => onDocumentDragStart(event, doc)}
onDragEnd={onDocumentDragEnd}
onDragStart={(event) => handleDocumentDragStartLocal(event, doc)}
onDragEnd={handleDocumentDragEndLocal}
onDragOver={handleDocumentTagDragOver}
onDragLeave={handleDocumentTagDragLeave}
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
@@ -749,7 +898,14 @@ const DocumentsTable = ({
<td className="doc-list__name">
<div className="doc-name">
<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>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
@@ -810,11 +966,18 @@ const DocumentsTable = ({
)}
</div>
</td>
<td>{doc.content_type || 'Document'}</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 className="actions">
<div className="action-buttons">
@@ -896,3 +1059,139 @@ const DocumentsTable = ({
export default DocumentsTable;
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,
};
};
+674 -358
View File
File diff suppressed because it is too large Load Diff
+190 -56
View File
@@ -1,13 +1,11 @@
import React from 'react';
import { DownloadIcon } from '../ui/icons';
import React, { useMemo } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
const PreviewWorkspace = ({
document,
previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
@@ -15,59 +13,63 @@ const PreviewWorkspace = ({
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
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 (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
<aside className="preview-workspace__sidebar">
<div className="preview-workspace__info">
{metadataSummary.length ? (
<dl className="preview-workspace__summary">
{metadataSummary.map(([label, value]) => (
<div className="preview-workspace__summary-row" key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : null}
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{metadata ? (
<section className="preview-workspace__metadata">
<h4>Metadata payload</h4>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
) : null}
</aside>
<div className="preview-workspace__viewer">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
@@ -78,15 +80,147 @@ const PreviewWorkspace = ({
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
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 { useNavigate, useParams } from 'react-router-dom';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => {
const {
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
notifyApiError,
resolveApiPath,
} = useAppShell();
const { documentId } = useParams();
const navigate = useNavigate();
@@ -44,29 +39,15 @@ const DocumentViewerRoute = () => {
};
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady =
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
if (!documentId) {
return <Navigate to="/documents" replace />;
}
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
return <div className="preview-workspace__message">Loading preview</div>;
}
return <Navigate to="/documents" replace />;
};
export default DocumentViewerRoute;
+124 -18
View File
@@ -1,5 +1,13 @@
import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ChevronIcon,
TrashIcon,
EditIcon,
FolderIcon,
ChevronsLeftIcon,
LogoutIcon,
ChevronDownIcon,
} from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
@@ -20,9 +28,9 @@ const FolderNode = ({
draggingFolderId,
}) => {
const isRoot = node.id === 'root';
const hasChildren = node.children.length > 0;
const hasChildren = Boolean(node.hasChildren);
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 canDrag = !isRoot;
const isDragging = draggingFolderId === node.id;
@@ -149,6 +157,10 @@ const Sidebar = ({
onLogout,
status,
onCollapse,
tenantSlug,
tenants = [],
activeTenantId = null,
onSelectTenant,
}) => {
const sortedCorrespondents = useMemo(
() =>
@@ -181,7 +193,59 @@ const Sidebar = ({
const handleSearchClear = useCallback(() => {
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]);
@@ -227,17 +291,65 @@ const Sidebar = ({
);
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 (
<aside className="sidebar">
<div className="panel-header sidebar__header">
<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" />
{onCollapse ? (
<button
@@ -253,7 +365,6 @@ const Sidebar = ({
</div>
</div>
<div className="panel-body sidebar__body">
<span className="sidebar__hint">{hintText}</span>
{status && (
<div className="sidebar__status">
<div className={`status-banner ${status.variant}`}>{status.message}</div>
@@ -376,11 +487,6 @@ const Sidebar = ({
})}
</ul>
</div>
<div className="sidebar__footer">
<button className="secondary" type="button" onClick={handleLogoutClick}>
Log out
</button>
</div>
</div>
</aside>
);
+342 -67
View File
@@ -11,14 +11,13 @@
--fg: oklch(0.32 0.005 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)));
--border: oklch(0.88 0.002 calc(270deg + var(--warmth)));
--border: oklch(0.92 0.002 calc(270deg + var(--warmth)));
/* --- Accent (primary) --- */
--accent: oklch(0.61 0.20 calc(260deg + var(--warmth)));
--accent-hover: oklch(0.70 0.18 calc(260deg + var(--warmth)));
--accent: oklch(0.61 0.2 calc(250deg + var(--warmth)));
--accent-hover: oklch(0.70 0.02 calc(100deg + var(--warmth)));
--on-accent: oklch(1 0 0);
/* --- Derived accent states --- */
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
--accent-elevated: color-mix(in oklch, var(--accent) 16%, 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);
--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 --- */
--shadow-faint: color-mix(in oklch, black 6%, transparent);
--shadow-soft: color-mix(in oklch, black 12%, transparent);
@@ -46,11 +51,12 @@
--danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent);
/* --- Explorer states --- */
--row-hover-bg: color-mix(in oklch, var(--accent) 6%, transparent);
--row-active-bg: color-mix(in oklch, var(--accent) 12%, transparent);
--sidebar-hover-bg: color-mix(in oklch, var(--accent) 8%, transparent);
--sidebar-active-bg: color-mix(in oklch, var(--accent) 16%, transparent);
--row-hover-bg: color-mix(in oklch, var(--selection) 6%, transparent);
--row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent);
--sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent);
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
--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-size: 15px;
@@ -203,8 +209,8 @@ button.danger:hover:not([disabled]) {
.panel-actions button,
.panel-actions a.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
align-items: flex-start;
justify-content: flex-start;
border: none;
background: transparent;
color: var(--muted);
@@ -213,6 +219,7 @@ button.danger:hover:not([disabled]) {
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
text-decoration: none;
text-align: left;
}
.panel-actions .icon-button:hover:not([disabled]),
@@ -444,6 +451,10 @@ button.danger:hover:not([disabled]) {
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 {
padding: 0.5rem 1.25rem;
justify-content: space-between;
@@ -537,9 +548,9 @@ button.danger:hover:not([disabled]) {
.preview-workspace {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1.5rem;
min-height: 0;
padding: 1rem 1.5rem;
}
.preview-workspace__header {
@@ -598,6 +609,13 @@ button.danger:hover:not([disabled]) {
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 img {
width: 100%;
@@ -664,17 +682,84 @@ button.danger:hover:not([disabled]) {
gap: 0.5rem;
}
.preview-workspace__body {
flex: 1;
.preview-workspace__sidebar {
flex: 0 0 20em;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
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;
}
.preview-workspace__object {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
.tags-panel__body {
@@ -857,20 +942,14 @@ button.danger:hover:not([disabled]) {
}
.preview-workspace__message {
color: var(--muted);
font-size: 0.95rem;
}
.preview-workspace__metadata {
border-radius: 2px;
padding: 0.75rem;
background: var(--surface);
max-height: 180px;
overflow: auto;
}
.preview-workspace__metadata h3 {
margin: 0 0 0.5rem;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
background: rgba(0, 0, 0, 0.65);
padding: 0.5rem 1rem;
border-radius: 999px;
font-size: 0.9rem;
}
@@ -1044,9 +1123,140 @@ button.danger:hover:not([disabled]) {
color: var(--fg);
}
.sidebar__hint {
.sidebar__tenant {
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 {
@@ -1101,6 +1311,7 @@ button.danger:hover:not([disabled]) {
.sidebar-section__header {
display: flex;
align-items: center;
font-size: 0.75rem;
justify-content: space-between;
color: var(--muted);
}
@@ -1135,9 +1346,7 @@ button.danger:hover:not([disabled]) {
.sidebar-section__header h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: inherit;
}
.sidebar-item {
@@ -1192,7 +1401,7 @@ button.danger:hover:not([disabled]) {
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
line-height: 1;
cursor: pointer;
background: var(--surface-soft);
@@ -1205,7 +1414,7 @@ button.danger:hover:not([disabled]) {
.sidebar-tag-pill:hover {
transform: none;
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 {
@@ -1214,8 +1423,8 @@ button.danger:hover:not([disabled]) {
}
.sidebar-tag-pill.active {
border-color: var(--accent);
box-shadow: 0 0 0 1.5px var(--accent);
border-color: var(--sidebar-active-pill-border);
box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border);
}
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
@@ -1255,8 +1464,8 @@ button.danger:hover:not([disabled]) {
}
.sidebar-correspondent-item.active {
background: var(--accent-soft, rgba(59, 130, 246, 0.18));
box-shadow: 0 0 0 1px var(--accent-soft, rgba(59, 130, 246, 0.24));
background: var(--sidebar-active-bg, rgba(59, 130, 246, 0.18));
box-shadow: 0 0 0 1px var(--sidebar-active-bg, rgba(59, 130, 246, 0.24));
color: var(--fg);
}
@@ -1500,7 +1709,7 @@ button.danger:hover:not([disabled]) {
}
.documents-panel tbody tr.document.selected {
background: var(--accent-soft);
background: var(--selection-soft);
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
}
@@ -1631,6 +1840,63 @@ button.danger:hover:not([disabled]) {
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 {
opacity: 0.4;
}
@@ -1687,6 +1953,7 @@ button.danger:hover:not([disabled]) {
gap: 0.25rem;
border-radius: 1rem;
padding: 0.2rem 0.4rem;
font-weight: 600;
}
.tag-chip--more {
@@ -1695,6 +1962,39 @@ button.danger:hover:not([disabled]) {
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 {
border: 1px dashed var(--border);
border-radius: 0;
@@ -1719,6 +2019,7 @@ button.danger:hover:not([disabled]) {
height: 100%;
background: var(--surface);
box-shadow: 0 0 24px var(--shadow-soft);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 20;
@@ -2093,32 +2394,6 @@ button.danger:hover:not([disabled]) {
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,
textarea,
select {
+29 -9
View File
@@ -1,5 +1,9 @@
import React, { useCallback, useMemo, useState } from 'react';
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
import {
getTagColorStyle,
HEX_COLOR_PATTERN,
generateRandomTagColor,
} from '../utils/colors';
function TagsPanel({
tags,
@@ -170,6 +174,14 @@ function TagsPanel({
disabled={creating}
aria-label="Tag color (optional)"
/>
<button
type="button"
className="secondary"
onClick={() => setCreateColor(generateRandomTagColor())}
disabled={creating}
>
Random color
</button>
{createColor && (
<button
type="button"
@@ -244,14 +256,22 @@ function TagsPanel({
className="tags-table__color-picker"
value={colorPickerValue}
onChange={(event) => setDraftColor(event.target.value)}
disabled={saving || deletingId === tag.id}
aria-label="Pick tag color"
/>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving || deletingId === tag.id}
aria-label="Pick tag color"
/>
<button
type="button"
className="secondary"
onClick={() => setDraftColor(generateRandomTagColor())}
disabled={saving || deletingId === tag.id}
>
Random color
</button>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving || deletingId === tag.id}
>
Clear
+32
View File
@@ -18,6 +18,9 @@ import {
IconFolderPlus,
IconRefresh,
IconMinusVertical,
IconLogout,
IconChevronDown,
IconX,
} from '@tabler/icons-react';
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 }) => (
<IconAnalyze
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 {
ChevronIcon,
TrashIcon,
@@ -219,6 +249,8 @@ export default {
RefreshIcon,
ArrowUpIcon,
MinusVerticalIcon,
LogoutIcon,
ChevronDownIcon,
};
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 clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
const gammaEncode = (channel) =>
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')}`;
};
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) => {
if (!input) return null;
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;
};
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
const rgb = hexToRgb(hex);
if (!rgb) return dark;
const luminance = relativeLuminance(rgb);
return luminance > 0.6 ? light : dark;
const contrastRatio = (lumA, lumB) => {
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
return (lighter + 0.05) / (darker + 0.05);
};
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) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
const baseHex = rgb.hex;
const baseHsl = rgbToHsl(rgb);
const baseText = getReadableTextColor(baseHex);
const baseRatio = contrastForPair(baseHex, baseText);
const TARGET_RATIO = 8;
const MIN_LIGHTNESS = 0.12;
const MAX_LIGHTNESS = 0.88;
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
const seen = new Map();
const registerCandidate = (lightness) => {
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
if (!seen.has(hexValue)) {
seen.set(hexValue, clamped);
}
};
registerCandidate(baseHsl.l);
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
let bestBackground = baseHex;
let bestText = baseText;
let bestRatio = baseRatio;
if (bestRatio >= TARGET_RATIO) {
return {
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
}
let compliantBackground = null;
let compliantText = null;
let compliantDelta = Infinity;
let compliantRatio = -Infinity;
seen.forEach((lightness, candidateHex) => {
const textHex = getReadableTextColor(candidateHex);
const ratio = contrastForPair(candidateHex, textHex);
const delta = Math.abs(lightness - baseHsl.l);
if (ratio > bestRatio) {
bestBackground = candidateHex;
bestText = textHex;
bestRatio = ratio;
}
if (ratio >= TARGET_RATIO) {
const deltaEpsilon = 0.0025;
const ratioEpsilon = 0.01;
const isCloser = delta + deltaEpsilon < compliantDelta;
const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon;
const improvesRatio = ratio > compliantRatio + ratioEpsilon;
if (
compliantBackground === null
|| isCloser
|| (isSimilarDistance && improvesRatio)
) {
compliantBackground = candidateHex;
compliantText = textHex;
compliantDelta = delta;
compliantRatio = ratio;
}
}
});
if (compliantBackground) {
return {
backgroundColor: compliantBackground,
borderColor: compliantBackground,
color: compliantText,
};
}
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
color: getReadableTextColor(rgb.hex),
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
};