From f30e455c2d0eef1476ce72a96cb6bfa914aac58d Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 12 Oct 2025 01:52:30 +0200 Subject: [PATCH] patch tags --- README.md | 27 ++ backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/src/auth/jwt.rs | 37 ++ backend/src/config.rs | 10 + backend/src/lib.rs | 1 + backend/src/routes/documents.rs | 149 +++++- backend/src/routes/folders.rs | 47 +- backend/src/routes/mod.rs | 8 +- backend/src/routes/tags.rs | 154 +++++- backend/src/storage.rs | 6 + backend/src/utils/json.rs | 16 + backend/src/utils/mod.rs | 1 + backend/src/workers/ocr.rs | 1 + backend/src/workers/thumbnails.rs | 1 + backend/tests/common/mod.rs | 5 + backend/tests/documents_flow.rs | 13 + backend/tests/tags_flow.rs | 54 ++- frontend/src/index.jsx | 748 +++++++++++++++++++++++++----- frontend/src/styles.css | 187 +++++++- 20 files changed, 1306 insertions(+), 161 deletions(-) create mode 100644 backend/src/utils/json.rs create mode 100644 backend/src/utils/mod.rs diff --git a/README.md b/README.md index 33339af..50b120f 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,30 @@ The compose service uses tmpfs storage, giving each test run a clean database. - `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text from PDFs when no embedded text layer is available. Ensure it is installed and available on the worker hosts if OCR is desired. - Quickwit (optional): The Quickwit indexer is used to ingest extracted text for search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when running workers if you want indexing jobs to run. The local compose file starts a Quickwit instance on `http://localhost:7280` and seeds the `documents` index automatically. + +## Running Migrations in Kubernetes + +The backend container image ships the `diesel` CLI, so schema migrations can be executed as a short-lived Job (or Helm hook) before rolling out new pods. Example manifest: + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: paperless-migrate +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: migrate + image: ghcr.io/example/paperless-backend: + command: ["/usr/local/bin/diesel", "migration", "run"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: paperless-db + key: DATABASE_URL +``` + +Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a Helm pre-install/pre-upgrade hook so migrations run automatically on each deployment. Once the Job succeeds, deploy/update the backend `Deployment` as usual. diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0258d8b..3046600 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2132,6 +2132,7 @@ dependencies = [ "mime_guess", "once_cell", "pdfium-render", + "percent-encoding", "rand 0.8.5", "reqwest", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5ef2fba..5076199 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -39,6 +39,7 @@ pdfium-render = "0.8" mime_guess = "2.0" tempfile = "3.10" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +percent-encoding = "2.3" # Error handling thiserror = "1.0" diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index 17c4e02..464b2a9 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -13,6 +13,8 @@ pub struct JwtService { issuer: String, audience: String, expiry: Duration, + download_audience: String, + download_expiry: Duration, } impl JwtService { @@ -23,6 +25,8 @@ impl JwtService { issuer: config.jwt_issuer.clone(), audience: config.jwt_audience.clone(), expiry: Duration::minutes(config.jwt_expiry_minutes), + download_audience: config.download_token_audience.clone(), + download_expiry: Duration::minutes(config.download_token_expiry_minutes), }) } @@ -49,6 +53,29 @@ impl JwtService { let data = decode::(token, &self.decoding, &validation)?; Ok(data.claims) } + + pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result { + let now = Utc::now(); + let exp = now + self.download_expiry; + let claims = DownloadClaims { + doc_id: document_id, + user_id, + iss: self.issuer.clone(), + aud: self.download_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_download_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.download_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -61,3 +88,13 @@ pub struct Claims { pub iat: usize, pub exp: usize, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadClaims { + pub doc_id: Uuid, + pub user_id: Uuid, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} diff --git a/backend/src/config.rs b/backend/src/config.rs index c5733d8..89d35c4 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -11,6 +11,8 @@ pub struct AppConfig { pub jwt_issuer: String, pub jwt_audience: String, pub jwt_expiry_minutes: i64, + pub download_token_audience: String, + pub download_token_expiry_minutes: i64, pub refresh_token_expiry_days: i64, pub refresh_cookie_secure: bool, pub refresh_cookie_domain: Option, @@ -40,6 +42,12 @@ impl AppConfig { .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(|_| "paperless-neo-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() @@ -65,6 +73,8 @@ impl AppConfig { jwt_issuer, jwt_audience, jwt_expiry_minutes, + download_token_audience, + download_token_expiry_minutes, refresh_token_expiry_days, refresh_cookie_secure, refresh_cookie_domain, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 19a161f..c7dc874 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -9,5 +9,6 @@ pub mod s3; pub mod schema; pub mod state; pub mod storage; +pub mod utils; pub mod workers; pub use workers::{default_handlers, Worker}; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 8de69e3..0f47ced 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -5,7 +5,7 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use chrono::{DateTime, NaiveDateTime, Utc}; use diesel::dsl::exists; -use diesel::{prelude::*, PgConnection}; +use diesel::{prelude::*, select, PgConnection}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -18,11 +18,35 @@ use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; use crate::models::{ Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, }; -use crate::schema::{document_assets, document_tags, document_versions, documents, folders, tags}; +use crate::schema::{ + document_assets, document_tags, document_versions, documents, folders, + refresh_tokens::dsl as refresh_dsl, tags, +}; use crate::state::AppState; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; +fn inline_content_disposition(filename: &str) -> Option { + if filename.is_empty() { + return None; + } + + let sanitized: String = filename + .chars() + .map(|ch| match ch { + '"' | '\\' => '_', + _ => ch, + }) + .collect(); + + let encoded = + percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC); + Some(format!( + "inline; filename=\"{}\"; filename*=UTF-8''{}", + sanitized, encoded + )) +} + #[derive(Deserialize)] pub struct DocumentListQuery { pub folder_id: Option, @@ -69,6 +93,7 @@ pub struct DocumentResponse { pub metadata: Value, pub tags: Vec, pub thumbnail: Option, + pub download_path: String, } #[derive(Serialize, Clone)] @@ -182,6 +207,7 @@ pub struct AssignTagsRequest { pub async fn list_documents( State(state): State, Query(query): Query, + user: AuthenticatedUser, ) -> AppResult>> { let mut conn = state.db()?; @@ -210,14 +236,18 @@ pub async fn list_documents( let thumbnails = load_primary_thumbnails(&state, &docs).await?; - let response = docs - .into_iter() - .map(|doc| { - let tags = tags_map.get(&doc.id).cloned(); - let thumbnail = thumbnails.get(&doc.id).cloned(); - to_document_response(doc, tags, thumbnail) - }) - .collect(); + let mut response = Vec::with_capacity(doc_ids.len()); + for doc in docs { + let tags = tags_map.get(&doc.id).cloned(); + let thumbnail = thumbnails.get(&doc.id).cloned(); + response.push(to_document_response( + &state, + user.user_id, + doc, + tags, + thumbnail, + )?); + } Ok(Json(response)) } @@ -225,6 +255,7 @@ pub async fn list_documents( pub async fn get_document( State(state): State, Path(document_id): Path, + user: AuthenticatedUser, ) -> AppResult> { let mut conn = state.db()?; @@ -249,7 +280,13 @@ pub async fn get_document( .cloned(); Ok(Json(DocumentDetailResponse { - document: to_document_response(doc, tags_map.get(&document_id).cloned(), thumbnail), + document: to_document_response( + &state, + user.user_id, + doc, + tags_map.get(&document_id).cloned(), + thumbnail, + )?, current_version: to_version_response(current_version), assets, })) @@ -257,7 +294,7 @@ pub async fn get_document( pub async fn upload_document( State(state): State, - _user: AuthenticatedUser, + user: AuthenticatedUser, mut multipart: Multipart, ) -> AppResult<(StatusCode, Json)> { let mut file_bytes: Option> = None; @@ -335,7 +372,7 @@ pub async fn upload_document( metadata, }; - let outcome = match process_upload(&state, request).await { + let outcome = match process_upload(&state, request, user.user_id).await { Ok(outcome) => { info!( document_id = %outcome.detail.document.id, @@ -527,6 +564,54 @@ pub async fn download_document( })) } +pub async fn download_with_token( + State(state): State, + Path(token): Path, +) -> AppResult { + let claims = state + .jwt + .verify_download_token(&token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = state.db()?; + + let doc: Document = documents::table.find(claims.doc_id).first(&mut conn)?; + if doc.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let version: DocumentVersion = document_versions::table + .filter(document_versions::document_id.eq(claims.doc_id)) + .filter(document_versions::version_number.eq(doc.current_version)) + .first(&mut conn)?; + + let now = Utc::now().naive_utc(); + let has_active_refresh: bool = select(exists( + refresh_dsl::refresh_tokens + .filter(refresh_dsl::user_id.eq(claims.user_id)) + .filter(refresh_dsl::revoked_at.is_null()) + .filter(refresh_dsl::expires_at.gt(now)), + )) + .get_result(&mut conn)?; + + if !has_active_refresh { + return Err(AppError::unauthorized()); + } + + drop(conn); + + let presigned_url = state + .storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + ) + .await + .map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?; + + Ok(axum::response::Redirect::temporary(&presigned_url)) +} + pub async fn delete_document( State(state): State, Path(document_id): Path, @@ -757,7 +842,11 @@ pub async fn remove_tag( Ok(StatusCode::NO_CONTENT) } -async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult { +async fn process_upload( + state: &AppState, + request: UploadRequest, + user_id: Uuid, +) -> AppResult { let UploadRequest { bytes, original_name, @@ -824,7 +913,7 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult AppResult AppResult>, thumbnail: Option, -) -> DocumentResponse { - DocumentResponse { +) -> AppResult { + let download_path = build_download_path(state, doc.id, user_id)?; + + Ok(DocumentResponse { id: doc.id, filename: doc.filename, title: doc.title, @@ -1042,7 +1142,16 @@ pub(crate) fn to_document_response( .map(TagResponse::from) .collect(), thumbnail, - } + download_path, + }) +} + +fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> AppResult { + state + .jwt + .generate_download_token(document_id, user_id) + .map(|token| format!("/download/{token}")) + .map_err(|err| AppError::internal(format!("failed to generate download token: {err}"))) } fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse { diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 98e3c3c..d435847 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -10,10 +10,13 @@ use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; -use crate::error::{AppError, AppResult}; use crate::models::{Document, Folder, NewFolder}; use crate::schema::{document_tags, documents, folders}; use crate::state::AppState; +use crate::{ + auth::AuthenticatedUser, + error::{AppError, AppResult}, +}; use super::documents::{ load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso, @@ -162,6 +165,7 @@ pub async fn create_folder( pub async fn list_folder_contents( State(state): State, Path(folder_identifier): Path, + user: AuthenticatedUser, ) -> AppResult> { let mut conn = state.db()?; @@ -214,14 +218,18 @@ pub async fn list_folder_contents( let thumbnails = load_primary_thumbnails(&state, &docs).await?; - let documents = docs - .into_iter() - .map(|doc| { - let tags = tags_map.get(&doc.id).cloned(); - let thumbnail = thumbnails.get(&doc.id).cloned(); - to_document_response(doc, tags, thumbnail) - }) - .collect(); + let mut documents = Vec::with_capacity(doc_ids.len()); + for doc in docs { + let tags = tags_map.get(&doc.id).cloned(); + let thumbnail = thumbnails.get(&doc.id).cloned(); + documents.push(to_document_response( + &state, + user.user_id, + doc, + tags, + thumbnail, + )?); + } Ok(Json(FolderContentsResponse { folder, @@ -234,6 +242,7 @@ pub async fn search_documents( State(state): State, Path(folder_identifier): Path, Query(params): Query, + user: AuthenticatedUser, ) -> AppResult>> { let mut conn = state.db()?; @@ -392,14 +401,18 @@ pub async fn search_documents( drop(conn); let thumbnails = load_primary_thumbnails(&state, &docs).await?; - let response = docs - .into_iter() - .map(|doc| { - let tags = tags_map.get(&doc.id).cloned(); - let thumbnail = thumbnails.get(&doc.id).cloned(); - to_document_response(doc, tags, thumbnail) - }) - .collect(); + let mut response = Vec::with_capacity(doc_ids.len()); + for doc in docs { + let tags = tags_map.get(&doc.id).cloned(); + let thumbnail = thumbnails.get(&doc.id).cloned(); + response.push(to_document_response( + &state, + user.user_id, + doc, + tags, + thumbnail, + )?); + } Ok(Json(response)) } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 6117d59..1a0ec60 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -75,6 +75,9 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/:id/tags", post(documents::assign_tags)) .route("/:id/tags/:tag_id", delete(documents::remove_tag)); + let download_routes = + Router::new().route("/download/:token", get(documents::download_with_token)); + let folders_routes = Router::new() .route("/", post(folders::create_folder)) .route("/path", post(folders::ensure_folder_path)) @@ -85,7 +88,9 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/:id/contents", get(folders::list_folder_contents)) .route("/:id/documents", get(folders::search_documents)); - let tags_routes = Router::new().route("/", get(tags::list_tags).post(tags::create_tag)); + let tags_routes = Router::new() + .route("/", get(tags::list_tags).post(tags::create_tag)) + .route("/:id", patch(tags::update_tag)); let protected_state = state.clone(); let protected_routes = Router::new() @@ -96,6 +101,7 @@ pub fn create_router(state: AppState) -> Router<()> { .layer(middleware::from_extractor_with_state::(protected_state)); Router::new() + .merge(download_routes) .nest("/api/auth", auth_routes) .merge(protected_routes) .with_state(state) diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index 4c4d40f..c9e22e1 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -1,32 +1,69 @@ -use axum::{extract::State, Json}; -use diesel::prelude::*; -use serde::Deserialize; +use crate::utils::json::{classify_nullable, NullableValue}; +use axum::{ + extract::{Path, State}, + Json, +}; +use diesel::{dsl::count_star, prelude::*}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; use uuid::Uuid; use crate::error::{AppError, AppResult}; use crate::models::{NewTag, Tag}; -use crate::schema::tags; +use crate::schema::{document_tags, tags}; use crate::state::AppState; -use super::documents::TagResponse; - #[derive(Deserialize)] pub struct CreateTagRequest { pub label: String, pub color: Option, } -pub async fn list_tags(State(state): State) -> AppResult>> { +#[derive(AsChangeset, Default)] +#[diesel(table_name = tags)] +struct UpdateTagChangeset<'a> { + label: Option<&'a str>, + color: Option>, +} + +#[derive(Serialize)] +pub struct TagCatalogEntry { + pub id: Uuid, + pub label: String, + pub color: Option, + pub usage_count: i64, +} + +pub async fn list_tags(State(state): State) -> AppResult>> { let mut conn = state.db()?; + let tag_list: Vec = tags::table.order(tags::label.asc()).load(&mut conn)?; - let response = tag_list.into_iter().map(TagResponse::from).collect(); + + let usage_rows: Vec<(Uuid, i64)> = document_tags::table + .group_by(document_tags::tag_id) + .select((document_tags::tag_id, count_star())) + .load(&mut conn)?; + + let usage_map: HashMap = usage_rows.into_iter().collect(); + + let response = tag_list + .into_iter() + .map(|tag| TagCatalogEntry { + id: tag.id, + label: tag.label, + color: tag.color, + usage_count: *usage_map.get(&tag.id).unwrap_or(&0), + }) + .collect(); + Ok(Json(response)) } pub async fn create_tag( State(state): State, Json(payload): Json, -) -> AppResult> { +) -> AppResult> { if payload.label.trim().is_empty() { return Err(AppError::bad_request("label must not be empty")); } @@ -53,5 +90,102 @@ pub async fn create_tag( } let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?; - Ok(Json(TagResponse::from(tag))) + Ok(Json(TagCatalogEntry { + id: tag.id, + label: tag.label, + color: tag.color, + usage_count: 0, + })) +} + +pub async fn update_tag( + State(state): State, + Path(tag_id): Path, + Json(body): Json, +) -> AppResult> { + let mut conn = state.db()?; + let existing: Tag = tags::table.find(tag_id).first(&mut conn)?; + let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; + let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?; + + if matches!(label_class, NullableValue::Omitted) + && matches!(color_class, NullableValue::Omitted) + { + return Err(AppError::bad_request("no changes supplied")); + } + + let mut new_label: Option = None; + let mut label_changed = false; + match label_class { + NullableValue::Omitted => {} + NullableValue::Null => { + return Err(AppError::bad_request("label cannot be null")); + } + NullableValue::String(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("label must not be empty")); + } + if trimmed != existing.label { + let duplicate = tags::table + .filter(tags::label.eq(trimmed)) + .filter(tags::id.ne(tag_id)) + .first::(&mut conn) + .optional()?; + if duplicate.is_some() { + return Err(AppError::bad_request("tag label already exists")); + } + new_label = Some(trimmed.to_string()); + label_changed = true; + } + } + } + + let mut color_change: Option> = None; + let mut color_changed = false; + match color_class { + NullableValue::Omitted => {} + NullableValue::Null => { + color_change = Some(None); + color_changed = true; + } + NullableValue::String(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("color must not be empty")); + } + if existing.color.as_deref() != Some(trimmed) { + color_change = Some(Some(trimmed.to_string())); + color_changed = true; + } + } + } + + if !label_changed && !color_changed { + return Err(AppError::bad_request("no changes supplied")); + } + + let changeset = UpdateTagChangeset { + label: new_label.as_deref(), + color: color_change + .as_ref() + .map(|opt| opt.as_ref().map(|value| value.as_str())), + }; + + diesel::update(tags::table.find(tag_id)) + .set(&changeset) + .execute(&mut conn)?; + + let updated: Tag = tags::table.find(tag_id).first(&mut conn)?; + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(count_star()) + .first(&mut conn)?; + + Ok(Json(TagCatalogEntry { + id: updated.id, + label: updated.label, + color: updated.color, + usage_count, + })) } diff --git a/backend/src/storage.rs b/backend/src/storage.rs index b27f35a..df0b84f 100644 --- a/backend/src/storage.rs +++ b/backend/src/storage.rs @@ -13,6 +13,7 @@ pub trait ObjectStorage: Send + Sync + 'static { key: &str, bytes: Vec, content_type: Option, + content_disposition: Option, ) -> Result<()>; async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result; @@ -43,6 +44,7 @@ impl ObjectStorage for S3Storage { key: &str, bytes: Vec, content_type: Option, + content_disposition: Option, ) -> Result<()> { let mut request = self .client @@ -55,6 +57,10 @@ impl ObjectStorage for S3Storage { request = request.content_type(content_type); } + if let Some(content_disposition) = content_disposition { + request = request.content_disposition(content_disposition); + } + request .send() .await diff --git a/backend/src/utils/json.rs b/backend/src/utils/json.rs new file mode 100644 index 0000000..7f9a366 --- /dev/null +++ b/backend/src/utils/json.rs @@ -0,0 +1,16 @@ +use serde_json::Value; + +pub enum NullableValue { + Omitted, + Null, + String(String), +} + +pub fn classify_nullable(optional_value: Option<&Value>) -> Result { + match optional_value { + None => Ok(NullableValue::Omitted), + Some(Value::Null) => Ok(NullableValue::Null), + Some(Value::String(s)) => Ok(NullableValue::String(s.to_owned())), + Some(other) => Err(format!("expected string or null, got {other}")), + } +} diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs new file mode 100644 index 0000000..22fdbb3 --- /dev/null +++ b/backend/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod json; diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs index e7388e5..25ce03f 100644 --- a/backend/src/workers/ocr.rs +++ b/backend/src/workers/ocr.rs @@ -140,6 +140,7 @@ impl JobHandler for GenerateOcrTextJob { &s3_key, generation.text.into_bytes(), Some("text/plain".into()), + None, ) .await { diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index efc8636..073e3c4 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -118,6 +118,7 @@ impl JobHandler for GenerateThumbnailsJob { &s3_key, generation.image_bytes.clone(), Some("image/png".into()), + None, ) .await { diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index fd395dd..5d749fc 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -37,6 +37,7 @@ pub struct StoredObject { pub key: String, pub bytes: Vec, pub content_type: Option, + pub content_disposition: Option, } #[derive(Default)] @@ -51,11 +52,13 @@ impl ObjectStorage for FakeStorage { key: &str, bytes: Vec, content_type: Option, + content_disposition: Option, ) -> Result<()> { let stored = StoredObject { key: key.to_string(), bytes, content_type, + content_disposition, }; let mut guard = self.objects.lock().await; guard.insert(stored.key.clone(), stored); @@ -119,6 +122,8 @@ impl TestApp { jwt_issuer: "test-issuer".to_string(), jwt_audience: "test-audience".to_string(), jwt_expiry_minutes: 60, + download_token_audience: "test-download".to_string(), + download_token_expiry_minutes: 60, refresh_token_expiry_days: 30, refresh_cookie_secure: false, refresh_cookie_domain: None, diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index d107df1..a20cf57 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -22,6 +22,7 @@ struct DocumentInfo { deleted_at: Option, issued_at: Option, tags: Vec, + download_path: String, } #[derive(Deserialize)] @@ -42,6 +43,7 @@ struct DocumentAssetInfo { struct DocumentListItem { id: Uuid, current_version: i32, + download_path: String, } #[derive(Deserialize)] @@ -154,6 +156,7 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(detail.document.deleted_at, None); assert!(detail.document.issued_at.is_none()); assert!(detail.document.tags.is_empty()); + assert!(detail.document.download_path.starts_with("/download/")); assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64); assert!(detail.assets.is_empty()); @@ -173,6 +176,7 @@ async fn upload_and_list_document() -> Result<()> { let item = list.pop().unwrap(); assert_eq!(item.id, detail.document.id); assert_eq!(item.current_version, 1); + assert!(item.download_path.starts_with("/download/")); let download = app .get( @@ -186,6 +190,15 @@ async fn upload_and_list_document() -> Result<()> { assert!(download_info.url.contains(&detail.current_version.s3_key)); assert_eq!(download_info.filename, "doc.txt"); + let redirect = app.get(&detail.document.download_path, None).await?; + assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); + let location = redirect + .headers() + .get("location") + .expect("redirect location header"); + let location = location.to_str().expect("location header utf8"); + assert!(location.contains(&detail.current_version.s3_key)); + app.cleanup().await?; Ok(()) } diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index cd7468c..0d34abb 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -21,11 +21,16 @@ struct DocumentInfo { #[derive(Deserialize)] struct TagInfo { label: String, + #[allow(dead_code)] + color: Option, } #[derive(Deserialize)] struct TagResponse { id: Uuid, + label: String, + color: Option, + usage_count: i64, } #[derive(Serialize)] @@ -75,6 +80,53 @@ async fn tag_assignment_flow() -> Result<()> { assert_eq!(create_tag.status(), StatusCode::OK); let body = body_to_vec(create_tag.into_body()).await?; let tag: TagResponse = serde_json::from_slice(&body)?; + assert_eq!(tag.label, "Important"); + assert_eq!(tag.color.as_deref(), Some("#FF0000")); + assert_eq!(tag.usage_count, 0); + + let update = app + .patch_json( + &format!("/api/tags/{}", tag.id), + &serde_json::json!({ + "label": "Critical", + "color": "#00FF00" + }), + Some(&token), + ) + .await?; + let updated_status = update.status(); + let updated_body = body_to_vec(update.into_body()).await?; + if updated_status != StatusCode::OK { + panic!( + "update tag failed: {}", + String::from_utf8_lossy(&updated_body) + ); + } + let updated: TagResponse = serde_json::from_slice(&updated_body)?; + assert_eq!(updated.label, "Critical"); + assert_eq!(updated.color.as_deref(), Some("#00FF00")); + assert_eq!(updated.usage_count, 0); + + let clear_color = app + .patch_json( + &format!("/api/tags/{}", tag.id), + &serde_json::json!({ + "color": null + }), + Some(&token), + ) + .await?; + let cleared_status = clear_color.status(); + let cleared_body = body_to_vec(clear_color.into_body()).await?; + if cleared_status != StatusCode::OK { + panic!( + "clear color failed: {}", + String::from_utf8_lossy(&cleared_body) + ); + } + let cleared: TagResponse = serde_json::from_slice(&cleared_body)?; + assert_eq!(cleared.color, None); + assert_eq!(cleared.usage_count, 0); let assign = app .post_json( @@ -97,7 +149,7 @@ async fn tag_assignment_flow() -> Result<()> { let refreshed_body = body_to_vec(refreshed.into_body()).await?; let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; assert_eq!(refreshed_detail.document.tags.len(), 1); - assert_eq!(refreshed_detail.document.tags[0].label, "Important"); + assert_eq!(refreshed_detail.document.tags[0].label, "Critical"); let remove = app .delete( diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 618e806..3a7c5d9 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -24,11 +24,56 @@ const api = axios.create({ withCredentials: true, }); +const STORED_TOKEN = window.localStorage.getItem('paperless_token') || ''; +if (STORED_TOKEN) { + api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; +} + const DEFAULT_FOLDER_NAME = 'All Documents'; +const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path); + const hasFiles = (event) => Array.from(event.dataTransfer?.types || []).includes('Files'); +const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; + +const hexToRgb = (input) => { + if (!input) return null; + const match = HEX_COLOR_PATTERN.exec(input.trim()); + if (!match) return null; + const value = parseInt(match[1], 16); + return { + r: (value >> 16) & 0xff, + g: (value >> 8) & 0xff, + b: value & 0xff, + hex: `#${match[1].toLowerCase()}`, + }; +}; + +const relativeLuminance = ({ r, g, b }) => { + const transform = (channel) => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }; + const [red, green, blue] = [transform(r), transform(g), transform(b)]; + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +}; + +const getTagColorStyle = (hex) => { + const rgb = hexToRgb(hex); + if (!rgb) return null; + const luminance = relativeLuminance(rgb); + const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff'; + return { + backgroundColor: rgb.hex, + borderColor: rgb.hex, + color: textColor, + }; +}; + const IconChevronRight = ({ className }) => (
{tags.length ? ( - tags.map((tag) => ( - - )) + tags.map((tag) => { + const isActive = activeTagIds.includes(tag.id); + const style = getTagColorStyle(tag.color); + const buttonStyle = style + ? { + ...style, + opacity: isActive ? 1 : 0.95, + boxShadow: isActive ? '0 0 0 1px rgba(0, 0, 0, 0.18)' : undefined, + } + : undefined; + return ( + + ); + }) ) : ( No tags yet )} @@ -339,8 +396,9 @@ const DocumentsTable = ({ draggingDocumentIds = [], onDocumentDragStart, onDocumentDragEnd, - onDownload, + onDocumentDelete, filterBar, + tagLookupById, }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; @@ -441,7 +499,6 @@ const DocumentsTable = ({ {folder.name} Folder — - —
@@ -514,17 +580,34 @@ const DocumentsTable = ({ : '—' } - +
+ {doc.download_path ? ( + event.stopPropagation()} + onAuxClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + > + + Download + + ) : ( + No download + )} + +
); @@ -639,7 +722,8 @@ const DetailPanel = ({ selectedDocuments = [], detailMap = new Map(), tags = [], - onDownload, + tagLookupById = new Map(), + tagLookupByLabel = new Map(), onTagAdd, onTagRemove, onRegenerateThumbnails, @@ -746,6 +830,9 @@ const DetailPanel = ({ } const displayName = singleDoc.title || singleDoc.original_name; + const downloadHref = singleDoc.download_path + ? resolveApiPath(singleDoc.download_path) + : null; return ( <> @@ -788,10 +875,21 @@ const DetailPanel = ({
- + - - )) + detail.document.tags.map((tag) => { + const colorSource = tag?.color || tagLookupById.get(tag.id)?.color; + const style = getTagColorStyle(colorSource); + return ( + + {tag.label}{' '} + + + ); + }) ) : ( No tags yet. )} @@ -884,11 +986,16 @@ const DetailPanel = ({
{commonTags.length > 0 && (
- {commonTags.map((label) => ( - - {label} - - ))} + {commonTags.map((label) => { + const key = typeof label === 'string' ? label.toLowerCase() : ''; + const tagInfo = key ? tagLookupByLabel.get(key) : null; + const style = getTagColorStyle(tagInfo?.color); + return ( + + {label} + + ); + })}
)}
@@ -971,7 +1078,6 @@ const PreviewWorkspace = ({ detail, previewEntry, onClose, - onDownload, onRegenerateThumbnails, }) => { if (!document) { @@ -984,6 +1090,9 @@ const PreviewWorkspace = ({ detail?.document?.original_name || document.original_name; const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; + const downloadHref = document.download_path + ? resolveApiPath(document.download_path) + : null; return (
@@ -1007,10 +1116,21 @@ const PreviewWorkspace = ({
+
+
+

Tags

+ {tags.length} +
+ +
); }; -function App({ routeFolderId = null, routeDocumentId = null, navigate }) { +const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => { + const [editingId, setEditingId] = useState(null); + const [draftLabel, setDraftLabel] = useState(''); + const [draftColor, setDraftColor] = useState(''); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const startEdit = useCallback((tag) => { + setEditingId(tag.id); + setDraftLabel(tag.label || ''); + setDraftColor(tag.color || ''); + setError(null); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setDraftLabel(''); + setDraftColor(''); + setSaving(false); + setError(null); + }, []); + + const handleSave = useCallback(async () => { + if (!editingId) return; + + const trimmedLabel = draftLabel.trim(); + if (!trimmedLabel) { + setError('Tag label cannot be empty.'); + return; + } + + const trimmedColor = draftColor.trim(); + const colorPattern = /^#([0-9a-fA-F]{6})$/; + if (trimmedColor && !colorPattern.test(trimmedColor)) { + setError('Colors must use the #RRGGBB format.'); + return; + } + + setSaving(true); + setError(null); + try { + await onUpdateTag(editingId, { + label: trimmedLabel, + color: trimmedColor ? trimmedColor : null, + }); + cancelEdit(); + } catch (updateError) { + const message = updateError?.message || 'Failed to update tag.'; + setError(message); + } finally { + setSaving(false); + } + }, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit]); + + const handleKeyDown = useCallback( + (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSave(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + } + }, + [handleSave, cancelEdit], + ); + + return ( +
+
+
+

Tags

+
{tags.length} total
+
+
+ +
+
+
+ {error && ( +
+ {error} +
+ )} + {tags.length === 0 ? ( +
No tags created yet.
+ ) : ( +
+ + + + + + + + + + + {tags.map((tag) => { + const isEditing = editingId === tag.id; + return ( + + + + + + + ); + })} + +
TagColor + Documents + + Actions +
+ {isEditing ? ( + setDraftLabel(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving} + autoFocus + /> + ) : ( + + {tag.label} + + )} + + {isEditing ? ( +
+ setDraftColor(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="#3366ff" + spellCheck="false" + disabled={saving} + /> + {draftColor && ( + + )} +
+ ) : tag.color ? ( + + ) : ( + + )} +
{tag.usage_count ?? 0} + {isEditing ? ( +
+ + +
+ ) : ( + + )} +
+
+ )} +
+
+ ); +}; + +const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => ( +
+ + {children} +
+); + +const DocumentsWorkspace = ({ + sidebarProps, + previewActive, + previewWorkspaceDocument, + previewWorkspaceDetail, + previewWorkspaceEntry, + closeDocumentPreview, + handleThumbnailRegeneration, + documentsTableProps, + detailPanelProps, +}) => { + if (previewActive && previewWorkspaceDocument) { + return ( +
+ +
+ ); + } + + return ( + + + + + ); +}; + +const TagsWorkspace = ({ sidebarProps, tags, onRefresh, onUpdateTag }) => ( + + + +); + +function App({ + routeFolderId = null, + routeDocumentId = null, + navigate, + mode = 'documents', +}) { const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || ''); const [status, setStatus] = useState(null); const setStatusMessage = useCallback((message, variant = 'info') => { @@ -1186,6 +1573,26 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { const selectionAnchorRef = useRef(routeDocumentId); const selectionOrderRef = useRef(initialSelection); + const tagLookupById = useMemo(() => { + const map = new Map(); + tags.forEach((tag) => { + if (tag?.id) { + map.set(tag.id, tag); + } + }); + return map; + }, [tags]); + + const tagLookupByLabel = useMemo(() => { + const map = new Map(); + tags.forEach((tag) => { + if (tag?.label) { + map.set(tag.label.toLowerCase(), tag); + } + }); + return map; + }, [tags]); + const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => { const nextSet = new Set(nextSelection); const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); @@ -1559,6 +1966,40 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { ], ); + const handleDocumentDragStart = useCallback( + (event, documentId) => { + const selection = selectedDocumentIds.includes(documentId) + ? selectedDocumentIds + : [documentId]; + + if (!selectedDocumentIds.includes(documentId)) { + applySelection([documentId], { + anchor: documentId, + interactedIds: [documentId], + }); + } + + setDraggedDocumentIds(selection); + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData( + 'application/x-paperless-doc-list', + JSON.stringify(selection), + ); + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + event.currentTarget.classList.add('dragging'); + }, + [selectedDocumentIds, applySelection], + ); + + const handleDocumentDragEnd = useCallback((event) => { + setDraggedDocumentIds([]); + event.currentTarget.classList.remove('dragging'); + }, []); + const ensureFolderData = useCallback( async (folderId, { force = false } = {}) => { if (!force && folderContents.has(folderId)) { @@ -1795,6 +2236,39 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { } }, [setStatusMessage]); + const handleTagUpdate = useCallback( + async (tagId, changes) => { + if (!tagId) { + throw new Error('Missing tag identifier.'); + } + + const payload = {}; + if (typeof changes.label === 'string') { + payload.label = changes.label; + } + if (Object.prototype.hasOwnProperty.call(changes, 'color')) { + payload.color = changes.color; + } + + if (Object.keys(payload).length === 0) { + return false; + } + + try { + await api.patch(`/tags/${tagId}`, payload); + await refreshTags(); + setStatusMessage('Tag updated.', 'success'); + return true; + } catch (error) { + console.error(error); + const message = error.response?.data?.error || 'Failed to update tag.'; + setStatusMessage(message, 'error'); + throw new Error(message); + } + }, + [api, refreshTags, setStatusMessage], + ); + const loadFolder = useCallback( async (folderId, { showLoading = true } = {}) => { const targetId = folderId || 'root'; @@ -2474,23 +2948,6 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { [selectedDocumentIds, moveDocumentsToFolder, setStatusMessage], ); -const handleDownload = useCallback( - async (documentId) => { - try { - setLoading(true); - const { data } = await api.get(`/documents/${documentId}/download`); - window.open(data.url, '_blank'); - setStatusMessage('Download link opened in a new tab.', 'success'); - } catch (error) { - console.error(error); - setStatusMessage('Unable to fetch download link.', 'error'); - } finally { - setLoading(false); - } - }, - [setStatusMessage], -); - const handleThumbnailRegeneration = useCallback( async (documentId) => { if (!token) { @@ -3332,7 +3789,81 @@ const handleDownload = useCallback( return pool.find((doc) => doc.id === previewDocumentId) || null; }, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]); - const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); + const isTagsView = mode === 'tags'; + const previewActive = !isTagsView && Boolean(previewDocumentId && previewWorkspaceDocument); + + const goToTagsView = useCallback(() => { + if (!navigate || isTagsView) { + return; + } + navigate('/tags'); + }, [navigate, isTagsView]); + + const sidebarProps = { + folderNodes, + onToggle: folderClickHandlers.onToggle, + onSelect: folderClickHandlers.onSelect, + onDrop: folderClickHandlers.onDrop, + onDragOver: folderClickHandlers.onDragOver, + onDragLeave: folderClickHandlers.onDragLeave, + onCreateFolder: handleFolderCreate, + onDeleteFolder: handleFolderDelete, + selectedFolder, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onShowTags: goToTagsView, + isTagsView, + tags, + }; + + const documentsTableProps = { + currentFolderName, + breadcrumbs, + onRefresh: refreshCurrentFolder, + subfolders: currentSubfolders, + documents, + searchResults, + isFilterActive, + onFolderSelect: selectFolder, + onFolderDrop: folderClickHandlers.onDrop, + onFolderDragOver: folderClickHandlers.onDragOver, + onFolderDragLeave: folderClickHandlers.onDragLeave, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onFolderDelete: handleFolderDelete, + onDocumentRowClick: handleDocumentRowClick, + onDocumentOpen: openDocumentPreview, + selectedDocumentIds, + focusedDocumentId, + draggingDocumentIds: draggedDocumentIds, + onDocumentDragStart: handleDocumentDragStart, + onDocumentDragEnd: handleDocumentDragEnd, + filterBar, + tagLookupById, + }; + + const detailPanelProps = { + selectedDocuments: orderedSelectedDocuments, + detailMap: documentDetails, + tags, + tagLookupById, + tagLookupByLabel, + onTagAdd: handleTagAdd, + onTagRemove: handleTagRemove, + onRegenerateThumbnails: handleThumbnailRegeneration, + previewEntry: selectedPreviewEntry, + onOpenPreview: openDocumentPreview, + onBulkTagAdd: handleBulkTagAddFromDetail, + onBulkTagRemove: handleBulkTagRemoveFromDetail, + onBulkMove: handleBulkMoveFromDetail, + onBulkReanalyze: handleBulkSelectionReanalyze, + folderOptions, + defaultMoveTarget, + onPromoteSelection: promoteSelectionOrder, + activePreviewId, + }; return (
@@ -3378,7 +3909,6 @@ const handleDownload = useCallback( detail={previewWorkspaceDetail} previewEntry={previewWorkspaceEntry} onClose={closeDocumentPreview} - onDownload={handleDownload} onRegenerateThumbnails={handleThumbnailRegeneration} /> ) : ( @@ -3419,40 +3949,38 @@ const handleDownload = useCallback( focusedDocumentId={focusedDocumentId} draggingDocumentIds={draggedDocumentIds} onDocumentDragStart={(event, documentId) => { - const selection = selectedDocumentIds.includes(documentId) - ? selectedDocumentIds - : [documentId]; - if (!selectedDocumentIds.includes(documentId)) { - applySelection([documentId], { - anchor: documentId, - interactedIds: [documentId], - }); - } - setDraggedDocumentIds(selection); - event.dataTransfer.effectAllowed = 'move'; - try { - event.dataTransfer.setData( - 'application/x-paperless-doc-list', - JSON.stringify(selection), - ); - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - event.currentTarget.classList.add('dragging'); - }} - onDocumentDragEnd={(event) => { - setDraggedDocumentIds([]); - event.currentTarget.classList.remove('dragging'); - }} - onDownload={handleDownload} - filterBar={filterBar} - /> - { + setDraggedDocumentIds([]); + event.currentTarget.classList.remove('dragging'); + }} + filterBar={filterBar} + /> + { +const RoutedDocumentsApp = () => { const navigate = useNavigate(); const params = useParams(); const folderId = params.folderId ?? null; @@ -3486,6 +4014,7 @@ const RoutedApp = () => { return ( { ); }; +const RoutedTagsApp = () => { + const navigate = useNavigate(); + return ; +}; + const AppRouter = () => ( } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } + /> + } /> } /> ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6e6876a..bfe0fa0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -52,6 +52,30 @@ button:hover:not([disabled]) { background: #2047d9; } +a.button-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font: inherit; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.35rem 0.85rem; + background: var(--accent); + color: #fff; + text-decoration: none; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +a.button-link[aria-disabled='true'] { + opacity: 0.55; + pointer-events: none; +} + +a.button-link:hover:not([aria-disabled='true']) { + background: #2047d9; +} + button.secondary { background: transparent; color: var(--fg); @@ -194,6 +218,16 @@ button.icon-button.ghost:hover:not([disabled]) { overflow: hidden; } +.tags-main { + flex: 1; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 1.5rem; + padding: 0.75rem 1.5rem 1.25rem; + min-height: 0; + overflow: hidden; +} + .preview-main { flex: 1; display: flex; @@ -254,6 +288,90 @@ button.icon-button.ghost:hover:not([disabled]) { border: none; } +.tags-panel__body { + overflow-y: auto; +} + +.tags-table { + width: 100%; + overflow: auto; +} + +.tags-table table { + width: 100%; + border-collapse: collapse; + min-width: 320px; +} + +.tags-table th, +.tags-table td { + padding: 0.45rem 0.6rem; + text-align: left; + border-bottom: 1px solid var(--divider); + font-size: 0.85rem; +} + +.tags-table th.numeric, +.tags-table td.numeric { + text-align: right; +} + +.tags-table th.actions, +.tags-table td.actions { + text-align: right; + width: 0; +} + +.tags-table tbody tr:hover { + background: var(--surface-subtle); +} + +.tags-table tr.editing { + background: rgba(43, 92, 255, 0.08); +} + +.tags-table__label { + max-width: 24rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tags-table__swatch { + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 2px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +.tags-panel__error { + margin: 0.5rem 0; + color: var(--danger); + font-size: 0.8rem; +} + +.tags-table__label-input { + width: 100%; +} + +.tags-table__color-editor { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.tags-table__color-field { + width: 7rem; + font-family: var(--monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); +} + +.tags-table__edit-controls { + display: flex; + justify-content: flex-end; + gap: 0.4rem; +} + .preview-workspace__message { color: var(--muted); font-size: 0.95rem; @@ -441,6 +559,52 @@ button.icon-button.ghost:hover:not([disabled]) { padding: 0; } +.sidebar-section { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.sidebar-section__header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.75rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sidebar-section__header h3 { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + color: inherit; +} + +.sidebar-link { + border: none; + background: transparent; + padding: 0.3rem 0.3rem; + text-align: left; + font-size: 0.82rem; + color: var(--fg); + border-radius: 3px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.sidebar-link:hover { + background: var(--surface-subtle); +} + +.sidebar-link.active { + background: rgba(43, 92, 255, 0.12); + color: var(--accent); + font-weight: 600; +} + .folder-row.is-drop-target { outline: 2px dashed var(--accent); outline-offset: 2px; @@ -631,22 +795,22 @@ button.icon-button.ghost:hover:not([disabled]) { .tag-filter { border: 1px solid var(--border); - background: transparent; + background: var(--surface-subtle); color: var(--fg); padding: 0.25rem 0.6rem; border-radius: 2px; cursor: pointer; font-size: 0.85rem; - transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; } .tag-filter:hover { - background: var(--surface-subtle); + filter: brightness(0.97); } .tag-filter.active { - background: var(--accent); - color: white; + box-shadow: 0 0 0 1px currentColor inset; + transform: translateY(-1px); } .filter-actions { @@ -668,7 +832,11 @@ button.icon-button.ghost:hover:not([disabled]) { background: var(--surface-subtle); color: var(--fg); font-size: 0.74rem; - margin-right: 0.25rem; + border: 1px solid transparent; +} + +.tag-chip { + gap: 0.25rem; } .empty-state { @@ -866,10 +1034,15 @@ button.icon-button.ghost:hover:not([disabled]) { .tag-pill button { background: none; border: none; - color: var(--muted); + color: inherit; padding: 0; cursor: pointer; font-size: 0.85rem; + opacity: 0.8; +} + +.tag-pill button:hover { + opacity: 1; } input,