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() {
|
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,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,50 +599,69 @@ 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_id) = next_parent {
|
if let Some(parent_request) = payload.parent_id {
|
||||||
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
if parent_request == Some(folder_id) {
|
||||||
}
|
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||||
|
|
||||||
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_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 {
|
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)?;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
----
|
----
|
||||||
|
|||||||
@@ -598,39 +598,37 @@ 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
|
className="inline"
|
||||||
className="inline"
|
onSubmit={(event) => {
|
||||||
onSubmit={(event) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
const input = event.currentTarget.elements.tag;
|
||||||
const input = event.currentTarget.elements.tag;
|
const value = input.value.trim();
|
||||||
const value = input.value.trim();
|
if (!value) return;
|
||||||
if (!value) return;
|
onBulkTagAdd?.({ label: value, input });
|
||||||
onBulkTagAdd?.({ label: value, input });
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
||||||
<input name="tag" placeholder="Add tag to selection" list="tag-catalog" />
|
<button type="submit">Add tag</button>
|
||||||
<button type="submit">Add tag</button>
|
</form>
|
||||||
</form>
|
<form
|
||||||
<form
|
className="inline"
|
||||||
className="inline"
|
onSubmit={(event) => {
|
||||||
onSubmit={(event) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
const input = event.currentTarget.elements.tag;
|
||||||
const input = event.currentTarget.elements.tag;
|
const value = input.value.trim();
|
||||||
const value = input.value.trim();
|
if (!value) return;
|
||||||
if (!value) return;
|
onBulkTagRemove?.({ label: value, input });
|
||||||
onBulkTagRemove?.({ label: value, input });
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
||||||
<input name="tag" placeholder="Remove tag from selection" list="tag-catalog" />
|
<button type="submit" className="secondary">
|
||||||
<button type="submit" className="secondary">
|
Remove tag
|
||||||
Remove tag
|
</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} />
|
||||||
|
|||||||
@@ -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,25 +381,68 @@ 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">
|
||||||
<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 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
<div className="doc-name__tags">
|
<div className="doc-name__tags">
|
||||||
{(doc.tags || []).map((tag) => {
|
{(doc.tags || []).map((tag) => {
|
||||||
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
const style = getTagColorStyle(colorSource);
|
const style = getTagColorStyle(colorSource);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="badge tag-chip"
|
className="badge tag-chip"
|
||||||
style={style || undefined}
|
style={style || undefined}
|
||||||
title={tag.label}
|
title={tag.label}
|
||||||
>
|
onClick={(event) => {
|
||||||
{tag.label}
|
event.stopPropagation();
|
||||||
</span>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+92
-21
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +77,41 @@ const FolderNode = ({
|
|||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
{node.id !== 'root' && (
|
{node.id !== 'root' && (
|
||||||
<button
|
<div className="folder-row__actions">
|
||||||
type="button"
|
<button
|
||||||
className="icon-button ghost"
|
type="button"
|
||||||
onClick={(event) => {
|
className="icon-button ghost"
|
||||||
event.stopPropagation();
|
onClick={(event) => {
|
||||||
onDelete(node.id);
|
event.stopPropagation();
|
||||||
}}
|
if (!onRename) return;
|
||||||
title="Delete folder"
|
const nextName = window.prompt('Rename folder', node.name || '');
|
||||||
aria-label={`Delete folder ${node.name}`}
|
if (!nextName) {
|
||||||
>
|
return;
|
||||||
<TrashIcon className="icon-trash" size={18} />
|
}
|
||||||
</button>
|
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>
|
</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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user