tags, folder rename, other stuff
This commit is contained in:
@@ -167,7 +167,8 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -39,7 +39,9 @@ pub struct EnsureFolderPathRequest {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateFolderRequest {
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -597,50 +599,69 @@ pub async fn delete_folder(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn update_folder_parent(
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> 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()?;
|
||||
|
||||
conn.transaction::<(), AppError, _>(|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 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
|
||||
if let Some(parent_id) = next_parent {
|
||||
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)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
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) = parent_request {
|
||||
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
||||
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
folders::table
|
||||
.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))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&folder.name))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.first::<Folder>(conn)
|
||||
.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))
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
folders::path_cache.eq(new_path),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
@@ -95,7 +95,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/path", post(folders::ensure_folder_path))
|
||||
.route(
|
||||
"/: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/documents", get(folders::search_documents));
|
||||
|
||||
@@ -112,7 +112,16 @@ pub async fn update_tag(
|
||||
if matches!(label_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;
|
||||
@@ -163,7 +172,16 @@ pub async fn update_tag(
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -15,10 +15,14 @@ struct FolderResponse {
|
||||
#[derive(Deserialize)]
|
||||
struct FolderInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path_cache: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FolderContents {
|
||||
folder: Option<FolderInfo>,
|
||||
subfolders: Vec<FolderInfo>,
|
||||
documents: Vec<DocSummary>,
|
||||
}
|
||||
|
||||
@@ -39,6 +43,14 @@ struct EnsureFolderPath<'a> {
|
||||
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)]
|
||||
struct MoveDocumentRequest {
|
||||
folder_id: Option<Uuid>,
|
||||
@@ -215,3 +227,80 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
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
@@ -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/documents - Search within a folder tree with optional `query` and `tags` filters.
|
||||
- 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
|
||||
----
|
||||
|
||||
@@ -598,39 +598,37 @@ const DetailPanel = ({
|
||||
{commonTags.length ? commonTags.join(', ') : 'None'}
|
||||
</div>
|
||||
</div>
|
||||
{commonTags.length > 0 && (
|
||||
<div className="bulk-tags">
|
||||
<strong>Bulk tag operations</strong>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onBulkTagAdd?.({ label: value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
||||
<button type="submit">Add tag</button>
|
||||
</form>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onBulkTagRemove?.({ label: value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
||||
<button type="submit" className="secondary">
|
||||
Remove tag
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<div className="bulk-tags">
|
||||
<strong>Bulk tag operations</strong>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onBulkTagAdd?.({ label: value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
||||
<button type="submit">Add tag</button>
|
||||
</form>
|
||||
<form
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const input = event.currentTarget.elements.tag;
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
onBulkTagRemove?.({ label: value, input });
|
||||
}}
|
||||
>
|
||||
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
||||
<button type="submit" className="secondary">
|
||||
Remove tag
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<datalist id="tag-catalog">
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.label} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, FolderIcon } from '../ui/icons';
|
||||
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
|
||||
|
||||
const FilterBar = ({
|
||||
query,
|
||||
@@ -112,6 +112,8 @@ const DocumentsTable = ({
|
||||
onDocumentDragStart,
|
||||
onDocumentDragEnd,
|
||||
onDocumentDelete,
|
||||
onFolderRename,
|
||||
onDocumentRename,
|
||||
filterBar,
|
||||
tagLookupById,
|
||||
onDocumentListFocus,
|
||||
@@ -120,6 +122,7 @@ const DocumentsTable = ({
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
getDownloadHref,
|
||||
onTagClick,
|
||||
}) => {
|
||||
const showingSearchResults = searchResults !== null;
|
||||
const rows = showingSearchResults ? searchResults : documents;
|
||||
@@ -306,7 +309,34 @@ const DocumentsTable = ({
|
||||
<FolderIcon className="icon-inline" size={18} />
|
||||
</div>
|
||||
</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>—</td>
|
||||
<td className="actions">
|
||||
@@ -351,25 +381,68 @@ const DocumentsTable = ({
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||||
<div className="doc-list__name-content">
|
||||
<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 && (
|
||||
<div className="doc-name__tags">
|
||||
{(doc.tags || []).map((tag) => {
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||
const style = getTagColorStyle(colorSource);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+92
-21
@@ -28,7 +28,7 @@ import TagsPanel from './tags/TagsPanel';
|
||||
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
||||
import { CORRESPONDENT_ROLES } from './constants/correspondents';
|
||||
import { DownloadIcon } from './ui/icons';
|
||||
import { generateRandomTagColor } from './utils/colors';
|
||||
import TagManager from './tag_manager';
|
||||
import { formatFileSize } from './utils/format';
|
||||
import Sidebar from './sidebar/Sidebar';
|
||||
import DocumentsTable, { FilterBar } from './documents/DocumentsTable';
|
||||
@@ -385,6 +385,17 @@ const AppLayout = () => {
|
||||
}
|
||||
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) => {
|
||||
if (!doc || !type) return null;
|
||||
return getAssetFromVersion(doc.current_version || null, type);
|
||||
@@ -1238,12 +1249,8 @@ const AppLayout = () => {
|
||||
);
|
||||
|
||||
const handleTagCreate = useCallback(
|
||||
async ({ label, color }) => {
|
||||
const trimmed = (label || '').trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Tag label is required.');
|
||||
}
|
||||
const payload = { label: trimmed, color: color || generateRandomTagColor() };
|
||||
async ({ label, color } = {}) => {
|
||||
const payload = tagManager.buildPayload({ label, color });
|
||||
try {
|
||||
await api.post('/tags', payload);
|
||||
await refreshTags();
|
||||
@@ -1254,7 +1261,7 @@ const AppLayout = () => {
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[api, refreshTags, notifyApiError],
|
||||
[api, refreshTags, notifyApiError, setStatusMessage, tagManager],
|
||||
);
|
||||
|
||||
const handleCorrespondentUpdate = useCallback(
|
||||
@@ -1783,7 +1790,8 @@ const AppLayout = () => {
|
||||
for (const label of normalized) {
|
||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||
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;
|
||||
await refreshTags();
|
||||
}
|
||||
@@ -1829,6 +1837,7 @@ const AppLayout = () => {
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
tagManager,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2938,13 +2947,13 @@ const AppLayout = () => {
|
||||
|
||||
const handleTagAdd = useCallback(
|
||||
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 {
|
||||
if (!tag) {
|
||||
const { data } = await api.post('/tags', {
|
||||
label,
|
||||
color: generateRandomTagColor(),
|
||||
});
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||
const { data } = await api.post('/tags', payload);
|
||||
tag = data;
|
||||
await refreshTags();
|
||||
}
|
||||
@@ -2956,13 +2965,7 @@ const AppLayout = () => {
|
||||
notifyApiError(error, 'Failed to assign tag.');
|
||||
}
|
||||
},
|
||||
[
|
||||
tags,
|
||||
refreshTags,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
],
|
||||
[tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
|
||||
);
|
||||
|
||||
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(
|
||||
async (name, onSuccess) => {
|
||||
if (!token) {
|
||||
@@ -3769,6 +3829,7 @@ const AppLayout = () => {
|
||||
onDragOver: folderClickHandlers.onDragOver,
|
||||
onDragLeave: folderClickHandlers.onDragLeave,
|
||||
onDeleteFolder: handleFolderDelete,
|
||||
onRenameFolder: handleFolderRename,
|
||||
selectedFolder,
|
||||
onFolderDragStart: handleFolderDragStart,
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
@@ -3810,9 +3871,11 @@ const AppLayout = () => {
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderDelete: handleFolderDelete,
|
||||
onFolderRename: handleFolderRename,
|
||||
onDocumentRowClick: handleDocumentRowClick,
|
||||
onDocumentOpen: openDocumentPreview,
|
||||
onDocumentDelete: handleDocumentDelete,
|
||||
onDocumentRename: handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
focusedDocumentId,
|
||||
focusedRowKey,
|
||||
@@ -3830,6 +3893,12 @@ const AppLayout = () => {
|
||||
doc?.current_version?.download_path
|
||||
? resolveApiPath(doc.current_version.download_path)
|
||||
: null,
|
||||
onTagClick: (tagId) => {
|
||||
if (!tagId) return;
|
||||
setActiveTagFilters((previous) =>
|
||||
previous.includes(tagId) ? previous : previous.concat([tagId]),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const detailPanelProps = {
|
||||
@@ -3875,6 +3944,7 @@ const AppLayout = () => {
|
||||
onRemoveTagFromDocument: handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
prepareTagPayload: buildTagPayload,
|
||||
}),
|
||||
[
|
||||
documents,
|
||||
@@ -3891,6 +3961,7 @@ const AppLayout = () => {
|
||||
handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
buildTagPayload,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TagIcon,
|
||||
TrashIcon,
|
||||
CorrespondentIcon,
|
||||
EditIcon,
|
||||
} from '../ui/icons';
|
||||
|
||||
const FolderNode = ({
|
||||
@@ -18,6 +19,7 @@ const FolderNode = ({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDelete,
|
||||
onRename,
|
||||
renderChildren,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
@@ -75,18 +77,41 @@ const FolderNode = ({
|
||||
{node.name}
|
||||
</span>
|
||||
{node.id !== 'root' && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
title="Delete folder"
|
||||
aria-label={`Delete folder ${node.name}`}
|
||||
>
|
||||
<TrashIcon className="icon-trash" size={18} />
|
||||
</button>
|
||||
<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
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
title="Delete folder"
|
||||
aria-label={`Delete folder ${node.name}`}
|
||||
>
|
||||
<TrashIcon className="icon-trash" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && node.children.length > 0 && (
|
||||
@@ -106,6 +131,7 @@ const Sidebar = ({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDeleteFolder,
|
||||
onRenameFolder,
|
||||
selectedFolder,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
@@ -139,6 +165,7 @@ const Sidebar = ({
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDelete={onDeleteFolder}
|
||||
onRename={onRenameFolder}
|
||||
renderChildren={renderNodes}
|
||||
onFolderDragStart={onFolderDragStart}
|
||||
onFolderDragEnd={onFolderDragEnd}
|
||||
@@ -155,6 +182,7 @@ const Sidebar = ({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDeleteFolder,
|
||||
onRenameFolder,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
draggedFolderId,
|
||||
|
||||
@@ -438,6 +438,7 @@ const SkeuomorphicWorkspace = ({
|
||||
onRemoveTagFromDocument = null,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
prepareTagPayload = null,
|
||||
}) => {
|
||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||
const showingSearchResults = searchResults !== null;
|
||||
@@ -1732,7 +1733,11 @@ const SkeuomorphicWorkspace = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onCreateTag({ label, color: generateRandomTagColor() });
|
||||
const payload =
|
||||
typeof prepareTagPayload === 'function'
|
||||
? prepareTagPayload({ label })
|
||||
: { label, color: generateRandomTagColor() };
|
||||
await onCreateTag(payload);
|
||||
setActiveShelfTagId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to create tag', error);
|
||||
|
||||
+54
-4
@@ -27,6 +27,7 @@
|
||||
--accent-outline: rgba(63, 106, 216, 0.45);
|
||||
--accent-outline-strong: rgba(63, 106, 216, 0.9);
|
||||
--accent-focus: rgba(63, 106, 216, 0.85);
|
||||
--surface-overlay: rgba(255, 255, 255, 0.82);
|
||||
|
||||
/* States specific to explorer UX */
|
||||
--row-hover-bg: rgba(63, 106, 216, 0.06);
|
||||
@@ -681,6 +682,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-row span.name {
|
||||
@@ -691,6 +693,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.folder-row span.name .folder-icon {
|
||||
@@ -733,15 +736,27 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
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;
|
||||
pointer-events: none;
|
||||
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:focus-within .icon-button {
|
||||
.folder-row__actions .icon-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.folder-row:hover .folder-row__actions,
|
||||
.folder-row:focus-within .folder-row__actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -1548,3 +1563,38 @@ form.inline {
|
||||
.column-toolbar .filter-bar {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,50 @@ const oklchToHex = (l, c, h) => {
|
||||
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) => {
|
||||
if (!input) return null;
|
||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||
@@ -76,19 +120,15 @@ export const getTagColorStyle = (hex) => {
|
||||
};
|
||||
|
||||
export const generateRandomTagColor = () => {
|
||||
const lightness = 0.72 + (Math.random() - 0.5) * 0.08;
|
||||
let chroma = 0.8;
|
||||
const hue = Math.random() * 360;
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const hex = oklchToHex(lightness, chroma, hue);
|
||||
if (hex) {
|
||||
return hex;
|
||||
}
|
||||
chroma *= 0.82;
|
||||
}
|
||||
|
||||
return '#8c8982';
|
||||
const bucketCount = 8;
|
||||
const bucketWidth = 360 / bucketCount;
|
||||
const bucket = Math.floor(Math.random() * bucketCount);
|
||||
const baseHue = bucket * bucketWidth;
|
||||
const hueJitter = bucketWidth * 0.35;
|
||||
const hue = baseHue + (Math.random() * 2 - 1) * hueJitter;
|
||||
const saturation = 0.45 + Math.random() * 0.2; // 0.45 - 0.65
|
||||
const lightness = 0.55 + Math.random() * 0.1; // 0.55 - 0.65
|
||||
return hslToHex(hue, saturation, lightness);
|
||||
};
|
||||
|
||||
export { HEX_COLOR_PATTERN };
|
||||
|
||||
Reference in New Issue
Block a user