tags, folder rename, other stuff

This commit is contained in:
2025-10-15 21:32:17 +02:00
parent b5119a8e6f
commit 068f96880b
13 changed files with 525 additions and 130 deletions
+2 -1
View File
@@ -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();
+46 -24
View File
@@ -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)?;
+1 -1
View File
@@ -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));
+20 -2
View File
@@ -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 {
+89
View File
@@ -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
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/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
----
+31 -33
View File
@@ -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} />
+90 -17
View File
@@ -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
View File
@@ -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,
],
);
+40 -12
View File
@@ -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,
+6 -1
View File
@@ -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
View File
@@ -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;
}
+53 -13
View File
@@ -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 };