4 Commits
Author SHA1 Message Date
nils 5b420a6b62 update filename on title change
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 13m52s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 13m57s
2025-10-15 22:00:14 +02:00
nils 939f248499 unique (folder_id, title) for documents 2025-10-15 21:56:20 +02:00
nils a8d362ff89 index documents (folder_id, title) 2025-10-15 21:36:19 +02:00
nils 068f96880b tags, folder rename, other stuff 2025-10-15 21:32:17 +02:00
18 changed files with 613 additions and 134 deletions
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
@@ -0,0 +1,13 @@
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,16 @@
DROP INDEX IF EXISTS documents_unique_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_documents_folder_title;
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS documents_unique_folder_title;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
+2 -1
View File
@@ -167,7 +167,8 @@ pub async fn update_correspondent(
} }
if new_name.is_none() && new_metadata.is_none() { if new_name.is_none() && new_metadata.is_none() {
return Err(AppError::bad_request("no changes supplied")); let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
return Ok(Json(build_summary(existing.clone(), usage)));
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
+39 -4
View File
@@ -9,7 +9,7 @@ use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists; use diesel::dsl::exists;
use diesel::{prelude::*, select, PgConnection}; use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -726,9 +726,24 @@ pub async fn update_document(
if let Some(title) = new_title { if let Some(title) = new_title {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id)) let new_filename = filename_with_retained_extension(&title, &document.filename);
.set((documents::title.eq(title), documents::updated_at.eq(now)))
.execute(&mut conn)?; let update_result = diesel::update(documents::table.find(document_id)).set((
documents::title.eq(&title),
documents::filename.eq(&new_filename),
documents::updated_at.eq(now),
));
match update_result.execute(&mut conn) {
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
return Err(AppError::bad_request(
"another document in this folder already uses that filename",
));
}
Err(err) => return Err(AppError::from(err)),
}
document = documents::table.find(document_id).first(&mut conn)?; document = documents::table.find(document_id).first(&mut conn)?;
} }
@@ -1515,6 +1530,26 @@ fn derive_document_title(original: &str) -> String {
stem.unwrap_or_else(|| trimmed.to_string()) stem.unwrap_or_else(|| trimmed.to_string())
} }
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()
}
}
async fn load_asset_responses( async fn load_asset_responses(
state: &AppState, state: &AppState,
version_id: Uuid, version_id: Uuid,
+38 -16
View File
@@ -39,7 +39,9 @@ pub struct EnsureFolderPathRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateFolderRequest { pub struct UpdateFolderRequest {
pub parent_id: Option<Uuid>, #[serde(default)]
pub parent_id: Option<Option<Uuid>>,
pub name: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -597,31 +599,27 @@ pub async fn delete_folder(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn update_folder_parent( pub async fn update_folder(
State(state): State<AppState>, State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
if payload.parent_id == Some(folder_id) {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
let mut conn = state.db()?; let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| { conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?; let folder: Folder = folders::table.find(folder_id).first(conn)?;
let current_parent = folder.parent_id;
let next_parent = payload.parent_id;
if current_parent == next_parent { let mut next_parent = folder.parent_id;
return Ok(()); let mut parent_changed = false;
if let Some(parent_request) = payload.parent_id {
if parent_request == Some(folder_id) {
return Err(AppError::bad_request("folder cannot be its own parent"));
} }
if let Some(parent_id) = next_parent { if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table.find(parent_id).first(conn)?; let _parent: Folder = folders::table.find(parent_id).first(conn)?;
}
if let Some(parent_id) = next_parent {
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
if descendant_ids.contains(&parent_id) { if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request( return Err(AppError::bad_request(
@@ -630,17 +628,40 @@ pub async fn update_folder_parent(
} }
} }
parent_changed = parent_request != folder.parent_id;
next_parent = parent_request;
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
if let Some(name) = payload.name {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
}
}
if !parent_changed && !name_changed {
return Ok(());
}
let conflict = if let Some(parent_id) = next_parent { let conflict = if let Some(parent_id) = next_parent {
folders::table folders::table
.filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&folder.name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::name.eq(&folder.name)) .filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id)) .filter(folders::id.ne(folder_id))
.first::<Folder>(conn) .first::<Folder>(conn)
.optional()? .optional()?
@@ -652,11 +673,12 @@ pub async fn update_folder_parent(
)); ));
} }
let new_path = build_path_cache(conn, next_parent, &folder.name)?; let new_path = build_path_cache(conn, next_parent, &new_name)?;
diesel::update(folders::table.find(folder_id)) diesel::update(folders::table.find(folder_id))
.set(( .set((
folders::parent_id.eq(next_parent), folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
folders::path_cache.eq(new_path), folders::path_cache.eq(new_path),
)) ))
.execute(conn)?; .execute(conn)?;
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/path", post(folders::ensure_folder_path)) .route("/path", post(folders::ensure_folder_path))
.route( .route(
"/:id", "/:id",
delete(folders::delete_folder).patch(folders::update_folder_parent), delete(folders::delete_folder).patch(folders::update_folder),
) )
.route("/:id/contents", get(folders::list_folder_contents)) .route("/:id/contents", get(folders::list_folder_contents))
.route("/:id/documents", get(folders::search_documents)); .route("/:id/documents", get(folders::search_documents));
+20 -2
View File
@@ -112,7 +112,16 @@ pub async fn update_tag(
if matches!(label_class, NullableValue::Omitted) if matches!(label_class, NullableValue::Omitted)
&& matches!(color_class, NullableValue::Omitted) && matches!(color_class, NullableValue::Omitted)
{ {
return Err(AppError::bad_request("no changes supplied")); let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
return Ok(Json(TagCatalogEntry {
id: existing.id,
label: existing.label.clone(),
color: existing.color.clone(),
usage_count,
}));
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
@@ -163,7 +172,16 @@ pub async fn update_tag(
} }
if !label_changed && !color_changed { if !label_changed && !color_changed {
return Err(AppError::bad_request("no changes supplied")); let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
return Ok(Json(TagCatalogEntry {
id: existing.id,
label: existing.label.clone(),
color: existing.color.clone(),
usage_count,
}));
} }
let changeset = UpdateTagChangeset { let changeset = UpdateTagChangeset {
+89
View File
@@ -15,10 +15,14 @@ struct FolderResponse {
#[derive(Deserialize)] #[derive(Deserialize)]
struct FolderInfo { struct FolderInfo {
id: Uuid, id: Uuid,
name: String,
path_cache: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct FolderContents { struct FolderContents {
folder: Option<FolderInfo>,
subfolders: Vec<FolderInfo>,
documents: Vec<DocSummary>, documents: Vec<DocSummary>,
} }
@@ -39,6 +43,14 @@ struct EnsureFolderPath<'a> {
segments: &'a [&'a str], segments: &'a [&'a str],
} }
#[derive(Serialize)]
struct UpdateFolderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<Option<Uuid>>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
struct MoveDocumentRequest { struct MoveDocumentRequest {
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
@@ -215,3 +227,80 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "renamepass";
app.insert_user("rename-admin", password, "admin").await?;
let token = app.login_token("rename-admin", password).await?;
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Projects",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Q1",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
let rename_resp = app
.patch_json(
&format!("/api/folders/{}", parent.folder.id),
&UpdateFolderRequest {
parent_id: None,
name: Some("Archive".to_string()),
},
Some(&token),
)
.await?;
assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let renamed = root
.subfolders
.iter()
.find(|f| f.id == parent.folder.id)
.expect("renamed folder present");
assert_eq!(renamed.name, "Archive");
assert_eq!(renamed.path_cache.as_deref(), Some("/Archive"));
let child_contents = app
.get(
&format!("/api/folders/{}/contents", child.folder.id),
Some(&token),
)
.await?;
assert_eq!(child_contents.status(), StatusCode::OK);
let child_contents_body = body_to_vec(child_contents.into_body()).await?;
let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?;
let child_folder = child_details.folder.expect("child folder info");
assert_eq!(child_folder.path_cache.as_deref(), Some("/Archive/Q1"));
app.cleanup().await?;
Ok(())
}
+1 -1
View File
@@ -49,7 +49,7 @@ Folders
- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root. - GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root.
- GET /api/folders/:id/documents - Search within a folder tree with optional `query` and `tags` filters. - GET /api/folders/:id/documents - Search within a folder tree with optional `query` and `tags` filters.
- DELETE /api/folders/:id - Soft-delete a folder. - DELETE /api/folders/:id - Soft-delete a folder.
- PATCH /api/folders/:id - Change a folder's parent. - PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`).
Tags Tags
---- ----
-2
View File
@@ -598,7 +598,6 @@ const DetailPanel = ({
{commonTags.length ? commonTags.join(', ') : 'None'} {commonTags.length ? commonTags.join(', ') : 'None'}
</div> </div>
</div> </div>
{commonTags.length > 0 && (
<div className="bulk-tags"> <div className="bulk-tags">
<strong>Bulk tag operations</strong> <strong>Bulk tag operations</strong>
<form <form
@@ -630,7 +629,6 @@ const DetailPanel = ({
</button> </button>
</form> </form>
</div> </div>
)}
<datalist id="tag-catalog"> <datalist id="tag-catalog">
{tags.map((tag) => ( {tags.map((tag) => (
<option key={tag.id} value={tag.label} /> <option key={tag.id} value={tag.label} />
+76 -3
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, FolderIcon } from '../ui/icons'; import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
const FilterBar = ({ const FilterBar = ({
query, query,
@@ -112,6 +112,8 @@ const DocumentsTable = ({
onDocumentDragStart, onDocumentDragStart,
onDocumentDragEnd, onDocumentDragEnd,
onDocumentDelete, onDocumentDelete,
onFolderRename,
onDocumentRename,
filterBar, filterBar,
tagLookupById, tagLookupById,
onDocumentListFocus, onDocumentListFocus,
@@ -120,6 +122,7 @@ const DocumentsTable = ({
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
getDownloadHref, getDownloadHref,
onTagClick,
}) => { }) => {
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents; const rows = showingSearchResults ? searchResults : documents;
@@ -306,7 +309,34 @@ const DocumentsTable = ({
<FolderIcon className="icon-inline" size={18} /> <FolderIcon className="icon-inline" size={18} />
</div> </div>
</td> </td>
<td>{folder.name}</td> <td className="doc-list__name">
<div className="doc-list__name-content">
<span>{folder.name}</span>
{folder.id !== 'root' && (
<button
type="button"
className="icon-button ghost doc-list__icon-button"
onClick={(event) => {
event.stopPropagation();
if (!onFolderRename) return;
const nextName = window.prompt('Rename folder', folder.name || '');
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === folder.name) {
return;
}
onFolderRename(folder.id, trimmed);
}}
title="Rename folder"
aria-label={`Rename folder ${folder.name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
)}
</div>
</td>
<td>Folder</td> <td>Folder</td>
<td></td> <td></td>
<td className="actions"> <td className="actions">
@@ -351,9 +381,35 @@ const DocumentsTable = ({
alt={`Thumbnail for ${doc.title || doc.original_name}`} alt={`Thumbnail for ${doc.title || doc.original_name}`}
/> />
</td> </td>
<td> <td className="doc-list__name">
<div className="doc-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">{doc.title || doc.original_name}</span>
<button
type="button"
className="icon-button ghost doc-list__icon-button"
onClick={(event) => {
event.stopPropagation();
if (!onDocumentRename) return;
const nextName = window.prompt(
'Rename document',
doc.title || doc.original_name || '',
);
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
return;
}
onDocumentRename(doc.id, trimmed);
}}
title="Rename document"
aria-label={`Rename document ${doc.title || doc.original_name}`}
>
<EditIcon className="doc-list__icon" size={16} />
</button>
</div>
{(doc.tags || []).length > 0 && ( {(doc.tags || []).length > 0 && (
<div className="doc-name__tags"> <div className="doc-name__tags">
{(doc.tags || []).map((tag) => { {(doc.tags || []).map((tag) => {
@@ -365,6 +421,23 @@ const DocumentsTable = ({
className="badge tag-chip" className="badge tag-chip"
style={style || undefined} style={style || undefined}
title={tag.label} title={tag.label}
onClick={(event) => {
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (onTagClick) {
onTagClick(tag.id);
}
}
}}
> >
{tag.label} {tag.label}
</span> </span>
+92 -21
View File
@@ -28,7 +28,7 @@ import TagsPanel from './tags/TagsPanel';
import CorrespondentsPanel from './correspondents/CorrespondentsPanel'; import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
import { CORRESPONDENT_ROLES } from './constants/correspondents'; import { CORRESPONDENT_ROLES } from './constants/correspondents';
import { DownloadIcon } from './ui/icons'; import { DownloadIcon } from './ui/icons';
import { generateRandomTagColor } from './utils/colors'; import TagManager from './tag_manager';
import { formatFileSize } from './utils/format'; import { formatFileSize } from './utils/format';
import Sidebar from './sidebar/Sidebar'; import Sidebar from './sidebar/Sidebar';
import DocumentsTable, { FilterBar } from './documents/DocumentsTable'; import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
@@ -385,6 +385,17 @@ const AppLayout = () => {
} }
const assetManager = assetManagerRef.current; const assetManager = assetManagerRef.current;
const tagManagerRef = useRef(null);
if (!tagManagerRef.current) {
tagManagerRef.current = new TagManager();
}
const tagManager = tagManagerRef.current;
const buildTagPayload = useCallback(
({ label, color } = {}) => tagManager.buildPayload({ label, color }),
[tagManager],
);
const getDocumentAsset = useCallback((doc, type) => { const getDocumentAsset = useCallback((doc, type) => {
if (!doc || !type) return null; if (!doc || !type) return null;
return getAssetFromVersion(doc.current_version || null, type); return getAssetFromVersion(doc.current_version || null, type);
@@ -1238,12 +1249,8 @@ const AppLayout = () => {
); );
const handleTagCreate = useCallback( const handleTagCreate = useCallback(
async ({ label, color }) => { async ({ label, color } = {}) => {
const trimmed = (label || '').trim(); const payload = tagManager.buildPayload({ label, color });
if (!trimmed) {
throw new Error('Tag label is required.');
}
const payload = { label: trimmed, color: color || generateRandomTagColor() };
try { try {
await api.post('/tags', payload); await api.post('/tags', payload);
await refreshTags(); await refreshTags();
@@ -1254,7 +1261,7 @@ const AppLayout = () => {
throw new Error(message); throw new Error(message);
} }
}, },
[api, refreshTags, notifyApiError], [api, refreshTags, notifyApiError, setStatusMessage, tagManager],
); );
const handleCorrespondentUpdate = useCallback( const handleCorrespondentUpdate = useCallback(
@@ -1783,7 +1790,8 @@ const AppLayout = () => {
for (const label of normalized) { for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) { if (!tag) {
const { data } = await api.post('/tags', { label, color: null }); const payload = tagManager.buildPayload({ label });
const { data } = await api.post('/tags', payload);
tag = data; tag = data;
await refreshTags(); await refreshTags();
} }
@@ -1829,6 +1837,7 @@ const AppLayout = () => {
refreshCurrentFolder, refreshCurrentFolder,
notifyApiError, notifyApiError,
setLoading, setLoading,
tagManager,
], ],
); );
@@ -2938,13 +2947,13 @@ const AppLayout = () => {
const handleTagAdd = useCallback( const handleTagAdd = useCallback(
async (document, label, input) => { async (document, label, input) => {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; const normalizedLabel = tagManager.normalizeLabel(label);
let tag =
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
try { try {
if (!tag) { if (!tag) {
const { data } = await api.post('/tags', { const payload = tagManager.buildPayload({ label: normalizedLabel });
label, const { data } = await api.post('/tags', payload);
color: generateRandomTagColor(),
});
tag = data; tag = data;
await refreshTags(); await refreshTags();
} }
@@ -2956,13 +2965,7 @@ const AppLayout = () => {
notifyApiError(error, 'Failed to assign tag.'); notifyApiError(error, 'Failed to assign tag.');
} }
}, },
[ [tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
tags,
refreshTags,
refreshCurrentFolder,
notifyApiError,
setStatusMessage,
],
); );
const handleDocumentTagAttach = useCallback( const handleDocumentTagAttach = useCallback(
@@ -3054,6 +3057,63 @@ const AppLayout = () => {
], ],
); );
const handleFolderRename = useCallback(
async (folderId, nextName) => {
if (!token) {
setStatusMessage('Log in to rename folders.', 'error');
return false;
}
if (!folderId || folderId === 'root') {
setStatusMessage('The root folder cannot be renamed.', 'error');
return false;
}
const trimmed = (nextName || '').trim();
if (!trimmed) {
setStatusMessage('Folder name cannot be empty.', 'error');
return false;
}
setLoading(true);
try {
await api.patch(`/folders/${folderId}`, { name: trimmed });
setFolderNodes((prev) => {
const next = new Map(prev);
const node = next.get(folderId);
if (node) {
next.set(folderId, { ...node, name: trimmed });
}
return next;
});
setFolderContents((prev) => {
if (!prev.has(folderId)) {
return prev;
}
const next = new Map(prev);
const existing = next.get(folderId) || {};
const folderInfo = existing.folder
? { ...existing.folder, name: trimmed }
: { id: folderId, name: trimmed };
next.set(folderId, { ...existing, folder: folderInfo });
return next;
});
setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev));
setStatusMessage('Folder renamed.', 'success');
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to rename folder.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[api, token, notifyApiError, setStatusMessage],
);
const handleFolderCreate = useCallback( const handleFolderCreate = useCallback(
async (name, onSuccess) => { async (name, onSuccess) => {
if (!token) { if (!token) {
@@ -3769,6 +3829,7 @@ const AppLayout = () => {
onDragOver: folderClickHandlers.onDragOver, onDragOver: folderClickHandlers.onDragOver,
onDragLeave: folderClickHandlers.onDragLeave, onDragLeave: folderClickHandlers.onDragLeave,
onDeleteFolder: handleFolderDelete, onDeleteFolder: handleFolderDelete,
onRenameFolder: handleFolderRename,
selectedFolder, selectedFolder,
onFolderDragStart: handleFolderDragStart, onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd, onFolderDragEnd: handleFolderDragEnd,
@@ -3810,9 +3871,11 @@ const AppLayout = () => {
onFolderDragEnd: handleFolderDragEnd, onFolderDragEnd: handleFolderDragEnd,
draggedFolderId, draggedFolderId,
onFolderDelete: handleFolderDelete, onFolderDelete: handleFolderDelete,
onFolderRename: handleFolderRename,
onDocumentRowClick: handleDocumentRowClick, onDocumentRowClick: handleDocumentRowClick,
onDocumentOpen: openDocumentPreview, onDocumentOpen: openDocumentPreview,
onDocumentDelete: handleDocumentDelete, onDocumentDelete: handleDocumentDelete,
onDocumentRename: handleDocumentTitleUpdate,
selectedDocumentIds, selectedDocumentIds,
focusedDocumentId, focusedDocumentId,
focusedRowKey, focusedRowKey,
@@ -3830,6 +3893,12 @@ const AppLayout = () => {
doc?.current_version?.download_path doc?.current_version?.download_path
? resolveApiPath(doc.current_version.download_path) ? resolveApiPath(doc.current_version.download_path)
: null, : null,
onTagClick: (tagId) => {
if (!tagId) return;
setActiveTagFilters((previous) =>
previous.includes(tagId) ? previous : previous.concat([tagId]),
);
},
}; };
const detailPanelProps = { const detailPanelProps = {
@@ -3875,6 +3944,7 @@ const AppLayout = () => {
onRemoveTagFromDocument: handleTagRemove, onRemoveTagFromDocument: handleTagRemove,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
prepareTagPayload: buildTagPayload,
}), }),
[ [
documents, documents,
@@ -3891,6 +3961,7 @@ const AppLayout = () => {
handleTagRemove, handleTagRemove,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
buildTagPayload,
], ],
); );
+28
View File
@@ -6,6 +6,7 @@ import {
TagIcon, TagIcon,
TrashIcon, TrashIcon,
CorrespondentIcon, CorrespondentIcon,
EditIcon,
} from '../ui/icons'; } from '../ui/icons';
const FolderNode = ({ const FolderNode = ({
@@ -18,6 +19,7 @@ const FolderNode = ({
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDelete, onDelete,
onRename,
renderChildren, renderChildren,
onFolderDragStart, onFolderDragStart,
onFolderDragEnd, onFolderDragEnd,
@@ -75,6 +77,28 @@ const FolderNode = ({
{node.name} {node.name}
</span> </span>
{node.id !== 'root' && ( {node.id !== 'root' && (
<div className="folder-row__actions">
<button
type="button"
className="icon-button ghost"
onClick={(event) => {
event.stopPropagation();
if (!onRename) return;
const nextName = window.prompt('Rename folder', node.name || '');
if (!nextName) {
return;
}
const trimmed = nextName.trim();
if (!trimmed || trimmed === node.name) {
return;
}
onRename(node.id, trimmed);
}}
title="Rename folder"
aria-label={`Rename folder ${node.name}`}
>
<EditIcon className="icon-edit" size={18} />
</button>
<button <button
type="button" type="button"
className="icon-button ghost" className="icon-button ghost"
@@ -87,6 +111,7 @@ const FolderNode = ({
> >
<TrashIcon className="icon-trash" size={18} /> <TrashIcon className="icon-trash" size={18} />
</button> </button>
</div>
)} )}
</div> </div>
{isExpanded && node.children.length > 0 && ( {isExpanded && node.children.length > 0 && (
@@ -106,6 +131,7 @@ const Sidebar = ({
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDeleteFolder, onDeleteFolder,
onRenameFolder,
selectedFolder, selectedFolder,
onFolderDragStart, onFolderDragStart,
onFolderDragEnd, onFolderDragEnd,
@@ -139,6 +165,7 @@ const Sidebar = ({
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDelete={onDeleteFolder} onDelete={onDeleteFolder}
onRename={onRenameFolder}
renderChildren={renderNodes} renderChildren={renderNodes}
onFolderDragStart={onFolderDragStart} onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd} onFolderDragEnd={onFolderDragEnd}
@@ -155,6 +182,7 @@ const Sidebar = ({
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDeleteFolder, onDeleteFolder,
onRenameFolder,
onFolderDragStart, onFolderDragStart,
onFolderDragEnd, onFolderDragEnd,
draggedFolderId, draggedFolderId,
+6 -1
View File
@@ -438,6 +438,7 @@ const SkeuomorphicWorkspace = ({
onRemoveTagFromDocument = null, onRemoveTagFromDocument = null,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
prepareTagPayload = null,
}) => { }) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
@@ -1732,7 +1733,11 @@ const SkeuomorphicWorkspace = ({
return; return;
} }
try { try {
await onCreateTag({ label, color: generateRandomTagColor() }); const payload =
typeof prepareTagPayload === 'function'
? prepareTagPayload({ label })
: { label, color: generateRandomTagColor() };
await onCreateTag(payload);
setActiveShelfTagId(null); setActiveShelfTagId(null);
} catch (error) { } catch (error) {
console.error('Failed to create tag', error); console.error('Failed to create tag', error);
+54 -4
View File
@@ -27,6 +27,7 @@
--accent-outline: rgba(63, 106, 216, 0.45); --accent-outline: rgba(63, 106, 216, 0.45);
--accent-outline-strong: rgba(63, 106, 216, 0.9); --accent-outline-strong: rgba(63, 106, 216, 0.9);
--accent-focus: rgba(63, 106, 216, 0.85); --accent-focus: rgba(63, 106, 216, 0.85);
--surface-overlay: rgba(255, 255, 255, 0.82);
/* States specific to explorer UX */ /* States specific to explorer UX */
--row-hover-bg: rgba(63, 106, 216, 0.06); --row-hover-bg: rgba(63, 106, 216, 0.06);
@@ -681,6 +682,7 @@ button.icon-button.ghost:hover:not([disabled]) {
cursor: pointer; cursor: pointer;
color: var(--fg); color: var(--fg);
transition: background 0.12s ease, color 0.12s ease; transition: background 0.12s ease, color 0.12s ease;
position: relative;
} }
.folder-row span.name { .folder-row span.name {
@@ -691,6 +693,7 @@ button.icon-button.ghost:hover:not([disabled]) {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding-right: 3rem;
} }
.folder-row span.name .folder-icon { .folder-row span.name .folder-icon {
@@ -733,15 +736,27 @@ button.icon-button.ghost:hover:not([disabled]) {
transform: rotate(90deg); transform: rotate(90deg);
} }
.folder-row .icon-button { .folder-row__actions {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 0.12s ease; transition: opacity 0.12s ease;
margin-left: auto; background-color: var(--surface-overlay);
border-radius: 8px;
padding: 0 0.18rem;
} }
.folder-row:hover .icon-button, .folder-row__actions .icon-button {
.folder-row:focus-within .icon-button { margin: 0;
}
.folder-row:hover .folder-row__actions,
.folder-row:focus-within .folder-row__actions {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -1548,3 +1563,38 @@ form.inline {
.column-toolbar .filter-bar { .column-toolbar .filter-bar {
margin-bottom: 0; margin-bottom: 0;
} }
.doc-list__name {
width: 100%;
}
.doc-list__name-content {
display: inline-flex;
align-items: center;
gap: 0.22rem;
max-width: 100%;
}
.doc-list__name-content span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-list__icon-button {
opacity: 0;
pointer-events: none;
padding: 0.14rem;
margin: 0;
transition: opacity 0.12s ease;
}
.doc-list__name-content:hover .doc-list__icon-button, .doc-list__name-content:focus-within .doc-list__icon-button {
opacity: 1;
pointer-events: auto;
}
.doc-list__icon {
width: 0.9rem;
height: 0.9rem;
}
+53 -13
View File
@@ -33,6 +33,50 @@ const oklchToHex = (l, c, h) => {
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`; return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
}; };
const hslToHex = (h, s, l) => {
const normalizedH = ((h % 360) + 360) % 360;
const sat = clamp01(s);
const light = clamp01(l);
const chroma = (1 - Math.abs(2 * light - 1)) * sat;
const hPrime = normalizedH / 60;
const x = chroma * (1 - Math.abs((hPrime % 2) - 1));
let r1 = 0;
let g1 = 0;
let b1 = 0;
if (hPrime >= 0 && hPrime < 1) {
r1 = chroma;
g1 = x;
} else if (hPrime >= 1 && hPrime < 2) {
r1 = x;
g1 = chroma;
} else if (hPrime >= 2 && hPrime < 3) {
g1 = chroma;
b1 = x;
} else if (hPrime >= 3 && hPrime < 4) {
g1 = x;
b1 = chroma;
} else if (hPrime >= 4 && hPrime < 5) {
r1 = x;
b1 = chroma;
} else {
r1 = chroma;
b1 = x;
}
const m = light - chroma / 2;
const r = clamp01(r1 + m);
const g = clamp01(g1 + m);
const b = clamp01(b1 + m);
const sr = Math.round(r * 255);
const sg = Math.round(g * 255);
const sb = Math.round(b * 255);
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
};
export const hexToRgb = (input) => { export const hexToRgb = (input) => {
if (!input) return null; if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim()); const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -76,19 +120,15 @@ export const getTagColorStyle = (hex) => {
}; };
export const generateRandomTagColor = () => { export const generateRandomTagColor = () => {
const lightness = 0.72 + (Math.random() - 0.5) * 0.08; const bucketCount = 8;
let chroma = 0.8; const bucketWidth = 360 / bucketCount;
const hue = Math.random() * 360; const bucket = Math.floor(Math.random() * bucketCount);
const baseHue = bucket * bucketWidth;
for (let attempt = 0; attempt < 5; attempt += 1) { const hueJitter = bucketWidth * 0.35;
const hex = oklchToHex(lightness, chroma, hue); const hue = baseHue + (Math.random() * 2 - 1) * hueJitter;
if (hex) { const saturation = 0.45 + Math.random() * 0.2; // 0.45 - 0.65
return hex; const lightness = 0.55 + Math.random() * 0.1; // 0.55 - 0.65
} return hslToHex(hue, saturation, lightness);
chroma *= 0.82;
}
return '#8c8982';
}; };
export { HEX_COLOR_PATTERN }; export { HEX_COLOR_PATTERN };