preview-stack

This commit is contained in:
2025-10-11 11:09:38 +02:00
parent 30f3fe015b
commit 41a9e8f76c
13 changed files with 2088 additions and 287 deletions
@@ -0,0 +1,2 @@
ALTER TABLE documents
DROP COLUMN issued_at;
@@ -0,0 +1,2 @@
ALTER TABLE documents
ADD COLUMN issued_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE documents
DROP COLUMN name;
@@ -0,0 +1,11 @@
ALTER TABLE documents
ADD COLUMN name VARCHAR(255);
UPDATE documents
SET name = CASE
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
ELSE filename
END;
ALTER TABLE documents
ALTER COLUMN name SET NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN title TO name;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN name TO title;
+4
View File
@@ -58,6 +58,8 @@ pub struct Document {
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
}
#[derive(Debug, Insertable)]
@@ -70,6 +72,8 @@ pub struct NewDocument {
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
+252 -1
View File
@@ -1,4 +1,4 @@
use std::{collections::HashMap, time::Duration};
use std::{collections::HashMap, path::Path as FsPath, time::Duration};
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
@@ -57,6 +57,7 @@ impl From<Tag> for TagResponse {
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub title: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
@@ -64,6 +65,7 @@ pub struct DocumentResponse {
pub uploaded_at: String,
pub updated_at: String,
pub deleted_at: Option<String>,
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<TagResponse>,
pub thumbnail: Option<DocumentAssetResponse>,
@@ -112,6 +114,48 @@ pub struct BulkReanalyzeResponse {
pub queued: usize,
}
#[derive(Deserialize)]
pub struct BulkMoveRequest {
pub document_ids: Vec<Uuid>,
pub folder_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct BulkMoveResponse {
pub updated: usize,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BulkTagAction {
Add,
Remove,
}
#[derive(Deserialize)]
pub struct BulkTagRequest {
pub document_ids: Vec<Uuid>,
pub tag_ids: Vec<Uuid>,
pub action: BulkTagAction,
}
#[derive(Serialize)]
pub struct BulkTagResponse {
pub added: usize,
pub removed: usize,
}
#[derive(Deserialize)]
pub struct BulkReanalyzeSelectionRequest {
pub document_ids: Vec<Uuid>,
#[serde(default = "default_true")]
pub force: bool,
}
fn default_true() -> bool {
true
}
struct UploadRequest {
bytes: Vec<u8>,
original_name: String,
@@ -379,6 +423,57 @@ pub async fn reanalyze_all_documents(
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
}
pub async fn reanalyze_selected_documents(
State(state): State<AppState>,
Json(payload): Json<BulkReanalyzeSelectionRequest>,
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let BulkReanalyzeSelectionRequest {
mut document_ids,
force,
} = payload;
if document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
document_ids.sort();
document_ids.dedup();
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = document_versions::table
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
.filter(documents::id.eq_any(&document_ids))
.filter(documents::deleted_at.is_null())
.filter(document_versions::version_number.eq(documents::current_version))
.select((documents::id, document_versions::id))
.load(&mut conn)?;
if targets.len() != document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
let mut queued = 0usize;
for (document_id, version_id) in targets {
enqueue_job(
&mut conn,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
"document_version_id": version_id,
"force": force,
}),
None,
)
.map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?;
queued += 1;
}
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
}
pub async fn list_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -468,6 +563,54 @@ pub async fn move_document(
Ok(StatusCode::NO_CONTENT)
}
pub async fn bulk_move_documents(
State(state): State<AppState>,
Json(payload): Json<BulkMoveRequest>,
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> {
let BulkMoveRequest {
mut document_ids,
folder_id,
} = payload;
if document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
document_ids.sort();
document_ids.dedup();
if let Some(target_folder) = folder_id {
ensure_folder_exists(&state, target_folder)?;
}
let mut conn = state.db()?;
let existing: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.select((documents::id, documents::deleted_at))
.load(&mut conn)?;
if existing.len() != document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if existing.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request("cannot move deleted documents"));
}
let now = Utc::now().naive_utc();
let updated = diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set((
documents::folder_id.eq(folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
}
pub async fn assign_tags(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -511,6 +654,94 @@ pub async fn assign_tags(
Ok(StatusCode::NO_CONTENT)
}
pub async fn bulk_update_tags(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(payload): Json<BulkTagRequest>,
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> {
let BulkTagRequest {
mut document_ids,
mut tag_ids,
action,
} = payload;
if document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
if tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
document_ids.sort();
document_ids.dedup();
tag_ids.sort();
tag_ids.dedup();
let mut conn = state.db()?;
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.select((documents::id, documents::deleted_at))
.load(&mut conn)?;
if docs.len() != document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign or remove tags from deleted documents",
));
}
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&tag_ids))
.load(&mut conn)?;
if existing_tags.len() != tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let response = match action {
BulkTagAction::Add => {
let mut inserts = Vec::with_capacity(document_ids.len() * tag_ids.len());
for doc_id in &document_ids {
for tag_id in &tag_ids {
inserts.push(NewDocumentTag {
document_id: *doc_id,
tag_id: *tag_id,
assigned_by: Some(user.user_id),
});
}
}
let added = if inserts.is_empty() {
0
} else {
diesel::insert_into(document_tags::table)
.values(&inserts)
.on_conflict_do_nothing()
.execute(&mut conn)?
};
BulkTagResponse { added, removed: 0 }
}
BulkTagAction::Remove => {
let removed = diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq_any(&document_ids))
.filter(document_tags::tag_id.eq_any(&tag_ids)),
)
.execute(&mut conn)?;
BulkTagResponse { added: 0, removed }
}
};
Ok((StatusCode::OK, Json(response)))
}
pub async fn remove_tag(
State(state): State<AppState>,
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
@@ -627,6 +858,8 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
content_type: content_type.clone(),
folder_id,
current_version: version_number,
issued_at: None,
title: derive_document_title(&original_name),
metadata: metadata_value.clone(),
};
diesel::insert_into(documents::table)
@@ -793,6 +1026,7 @@ pub(crate) fn to_document_response(
DocumentResponse {
id: doc.id,
filename: doc.filename,
title: doc.title,
original_name: doc.original_name,
content_type: doc.content_type,
folder_id: doc.folder_id,
@@ -800,6 +1034,7 @@ pub(crate) fn to_document_response(
uploaded_at: to_iso(doc.uploaded_at),
updated_at: to_iso(doc.updated_at),
deleted_at: doc.deleted_at.map(to_iso),
issued_at: doc.issued_at.map(to_iso),
metadata: doc.metadata,
tags: tags
.unwrap_or_default()
@@ -834,6 +1069,22 @@ fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse
}
}
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())
}
async fn load_asset_responses(
state: &AppState,
version_id: Uuid,
+6
View File
@@ -31,6 +31,12 @@ pub fn create_router(state: AppState) -> Router<()> {
get(documents::list_documents).post(documents::upload_document),
)
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route(
"/bulk/reanalyze",
post(documents::reanalyze_selected_documents),
)
.route(
"/:id",
get(documents::get_document).delete(documents::delete_document),
+3
View File
@@ -53,6 +53,9 @@ diesel::table! {
updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb,
issued_at -> Nullable<Timestamptz>,
#[max_length = 255]
title -> Varchar,
}
}
+388 -3
View File
@@ -3,8 +3,7 @@ mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde_json::Value;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize)]
@@ -17,10 +16,12 @@ struct DocumentDetail {
#[derive(Deserialize)]
struct DocumentInfo {
id: Uuid,
title: String,
original_name: String,
current_version: i32,
deleted_at: Option<String>,
tags: Vec<Value>,
issued_at: Option<String>,
tags: Vec<TagSummary>,
}
#[derive(Deserialize)]
@@ -30,6 +31,7 @@ struct DocumentVersion {
size_bytes: i64,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct DocumentAssetInfo {
id: Uuid,
@@ -53,6 +55,22 @@ struct BulkReanalyze {
queued: usize,
}
#[derive(Deserialize)]
struct BulkMoveResult {
updated: usize,
}
#[derive(Deserialize)]
struct BulkTagResult {
added: usize,
removed: usize,
}
#[derive(Deserialize)]
struct TagSummary {
label: String,
}
#[derive(Deserialize)]
struct AnalyzeJobPayload {
document_id: Uuid,
@@ -61,6 +79,51 @@ struct AnalyzeJobPayload {
force: bool,
}
#[derive(Deserialize)]
struct FolderResponse {
folder: FolderInfo,
}
#[derive(Deserialize)]
struct FolderInfo {
id: Uuid,
}
#[derive(Deserialize)]
struct FolderContents {
documents: Vec<DocumentListItem>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
}
#[derive(Serialize)]
struct BulkMoveRequest<'a> {
document_ids: &'a [Uuid],
folder_id: Option<Uuid>,
}
#[derive(Serialize)]
struct BulkTagRequest<'a> {
document_ids: &'a [Uuid],
tag_ids: &'a [Uuid],
action: &'a str,
}
#[derive(Serialize)]
struct CreateFolderRequest<'a> {
name: &'a str,
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
#[tokio::test]
async fn upload_and_list_document() -> Result<()> {
let _lock = acquire_db_lock().await;
@@ -86,8 +149,10 @@ async fn upload_and_list_document() -> Result<()> {
let detail: DocumentDetail = serde_json::from_slice(&body)?;
assert_eq!(detail.document.original_name, "doc.txt");
assert_eq!(detail.document.title, "doc");
assert_eq!(detail.document.current_version, 1);
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.tags.is_empty());
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
assert!(detail.assets.is_empty());
@@ -273,3 +338,323 @@ async fn bulk_reanalyze_documents() -> Result<()> {
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkmove";
app.insert_user("mover", password, "admin").await?;
let token = app.login_token("mover", password).await?;
let alpha = app
.upload_document(
"/api/documents",
"alpha.txt",
"text/plain",
b"alpha",
None,
&token,
)
.await?;
assert_eq!(alpha.status(), StatusCode::CREATED);
let alpha_body = body_to_vec(alpha.into_body()).await?;
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
let beta = app
.upload_document(
"/api/documents",
"beta.txt",
"text/plain",
b"beta",
None,
&token,
)
.await?;
assert_eq!(beta.status(), StatusCode::CREATED);
let beta_body = body_to_vec(beta.into_body()).await?;
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
let folder_resp = app
.post_json(
"/api/folders",
&CreateFolderRequest {
name: "Archives",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
let move_resp = app
.post_json(
"/api/documents/bulk/move",
&BulkMoveRequest {
document_ids: &[alpha_detail.document.id, beta_detail.document.id],
folder_id: Some(folder.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(move_resp.status(), StatusCode::OK);
let move_body = body_to_vec(move_resp.into_body()).await?;
let result: BulkMoveResult = serde_json::from_slice(&move_body)?;
assert_eq!(result.updated, 2);
let folder_contents = app
.get(
&format!("/api/folders/{}/contents", folder.folder.id),
Some(&token),
)
.await?;
assert_eq!(folder_contents.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_contents.into_body()).await?;
let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?;
let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect();
assert!(moved_ids.contains(&alpha_detail.document.id));
assert!(moved_ids.contains(&beta_detail.document.id));
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root_docs: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root_docs
.documents
.iter()
.all(|doc| doc.id != alpha_detail.document.id && doc.id != beta_detail.document.id));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_update_tags_for_selection() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulktags";
app.insert_user("tagger", password, "admin").await?;
let token = app.login_token("tagger", password).await?;
let first = app
.upload_document(
"/api/documents",
"notes.txt",
"text/plain",
b"notes",
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"report.txt",
"text/plain",
b"report",
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let urgent_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Urgent",
color: None,
},
Some(&token),
)
.await?;
assert_eq!(urgent_tag.status(), StatusCode::OK);
let urgent_body = body_to_vec(urgent_tag.into_body()).await?;
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
let review_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Review",
color: None,
},
Some(&token),
)
.await?;
assert_eq!(review_tag.status(), StatusCode::OK);
let review_body = body_to_vec(review_tag.into_body()).await?;
let review: TagResponse = serde_json::from_slice(&review_body)?;
let add_resp = app
.post_json(
"/api/documents/bulk/tags",
&BulkTagRequest {
document_ids: &[first_detail.document.id, second_detail.document.id],
tag_ids: &[urgent.id, review.id],
action: "add",
},
Some(&token),
)
.await?;
assert_eq!(add_resp.status(), StatusCode::OK);
let add_body = body_to_vec(add_resp.into_body()).await?;
let add_result: BulkTagResult = serde_json::from_slice(&add_body)?;
assert_eq!(add_result.added, 4);
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
.document
.tags
.iter()
.map(|tag| tag.label.as_str())
.collect();
assert!(labels.contains(&"Urgent"));
assert!(labels.contains(&"Review"));
}
let remove_resp = app
.post_json(
"/api/documents/bulk/tags",
&BulkTagRequest {
document_ids: &[first_detail.document.id, second_detail.document.id],
tag_ids: &[urgent.id],
action: "remove",
},
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.removed, 2);
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
.document
.tags
.iter()
.map(|tag| tag.label.as_str())
.collect();
assert!(!labels.contains(&"Urgent"));
assert!(labels.contains(&"Review"));
}
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_selected_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "subsetrean";
app.insert_user("subset", password, "admin").await?;
let token = app.login_token("subset", password).await?;
app.clear_jobs().await?;
let first = app
.upload_document(
"/api/documents",
"doc-one.txt",
"text/plain",
b"one",
None,
&token,
)
.await?;
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"doc-two.txt",
"text/plain",
b"two",
None,
&token,
)
.await?;
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let third = app
.upload_document(
"/api/documents",
"doc-three.txt",
"text/plain",
b"three",
None,
&token,
)
.await?;
let third_body = body_to_vec(third.into_body()).await?;
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/bulk/reanalyze",
&serde_json::json!({
"document_ids": [
first_detail.document.id,
third_detail.document.id
],
"force": true
}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
assert!(payload_docs
.iter()
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
let mut expected = vec![
(first_detail.document.id, first_detail.current_version.id),
(third_detail.document.id, third_detail.current_version.id),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
+1276 -261
View File
File diff suppressed because it is too large Load Diff
+138 -22
View File
@@ -109,6 +109,17 @@ button.icon-button.ghost:hover:not([disabled]) {
justify-content: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.icon path {
stroke: currentColor;
stroke-width: 1.6;
@@ -125,9 +136,9 @@ button.icon-button.ghost:hover:not([disabled]) {
.app-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.5rem;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem 1.5rem 0.4rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
@@ -135,6 +146,12 @@ button.icon-button.ghost:hover:not([disabled]) {
z-index: 10;
}
.app-bar__main {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-bar__meta {
display: flex;
flex-direction: column;
@@ -158,9 +175,13 @@ button.icon-button.ghost:hover:not([disabled]) {
gap: 0.5rem;
}
.status-container {
padding: 0.4rem 1.5rem 0;
flex-shrink: 0;
.app-bar__status {
margin: 0 1rem;
min-width: 220px;
}
.app-bar__status .status-banner {
margin: 0;
}
.app-main {
@@ -469,12 +490,9 @@ button.icon-button.ghost:hover:not([disabled]) {
margin-top: 0.4rem;
}
.documents-panel thead {
background: transparent;
border-bottom: 1px solid var(--border);
}
.documents-panel thead th {
border-bottom: 1px solid var(--border);
background-color: var(--bg);
position: sticky;
top: 0;
z-index: 1;
@@ -565,6 +583,33 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.9);
}
.documents-panel tbody tr.document,
.documents-panel tbody tr.document * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.documents-panel tbody tr.document.focused:not(.selected) {
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45);
}
.doc-name {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.doc-name__title {
font-weight: 600;
}
.doc-name__tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.documents-panel tbody tr.document.dragging {
opacity: 0.4;
}
@@ -660,21 +705,14 @@ button.icon-button.ghost:hover:not([disabled]) {
.preview-pane {
margin-top: 0.4rem;
border: 1px solid var(--border);
border-radius: 2px;
background: var(--surface-subtle);
border: none;
border-radius: 0;
background: transparent;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-object {
width: 100%;
height: 320px;
border: none;
background: var(--surface);
overflow: visible;
}
.thumbnail-preview {
@@ -695,6 +733,84 @@ button.icon-button.ghost:hover:not([disabled]) {
color: var(--muted);
}
.bulk-detail-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.6rem 0;
}
.bulk-detail-actions .inline {
display: flex;
gap: 0.4rem;
}
.bulk-move {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.6rem 0;
}
.bulk-move select {
max-width: 100%;
}
.preview-stack {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.preview-stack--stacked {
width: 100%;
min-height: 420px;
flex-shrink: 0;
}
.preview-stack__item {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transition: transform 120ms ease;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
transform-origin: center;
border-radius: 6px;
overflow: hidden;
}
.preview-stack .preview-stack__item.orientation-portrait {
width: 80%;
height: 100%;
}
.preview-stack .preview-stack__item.orientation-landscape {
width: 100%;
height: 80%;
}
.preview-stack__image {
width: 100%;
height: 100%;
object-fit: contain;
background: transparent;
}
.preview-pane--stack {
min-height: 260px;
}
.bulk-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.4rem 0 0.6rem;
}
.detail-panel dl {
margin: 0;
}