diff --git a/frontend/src/documents/data/useDocumentTagMutations.ts b/frontend/src/documents/data/useDocumentTagMutations.ts index a155464..aae44bf 100644 --- a/frontend/src/documents/data/useDocumentTagMutations.ts +++ b/frontend/src/documents/data/useDocumentTagMutations.ts @@ -31,9 +31,11 @@ export const useDocumentTagMutations = ({ async ({ documentId, tag, + silent = false, }: { documentId?: DocumentId; tag?: Tag | null; + silent?: boolean; }) => { if (!documentId || !tag?.id) { return false; @@ -52,14 +54,20 @@ export const useDocumentTagMutations = ({ } return { ...doc, tags: [...currentTags, tag.id] }; }); - showToast('Tag assigned.', 'success'); + + if (!silent) { + showToast('Tag assigned.', 'success'); + } return true; } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; - notifyApiError(error, message); + if (!silent) { + const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; + notifyApiError(error, message); + } return false; } }, + [notifyApiError, showToast, documentsState], ); @@ -103,7 +111,7 @@ export const useDocumentTagMutations = ({ ); const handleDocumentTagAttach = useCallback( - async (documentId: DocumentId, tagId: DocumentId) => { + async (documentId: DocumentId, tagId: DocumentId, options: { silent?: boolean } = {}) => { if (!documentId || !tagId) { return false; } @@ -121,6 +129,7 @@ export const useDocumentTagMutations = ({ return attachTagToDocument({ documentId, tag: resolvedTag, + silent: options.silent, }); }, [ @@ -130,7 +139,7 @@ export const useDocumentTagMutations = ({ ); const handleDocumentTagDetach = useCallback( - async (documentId?: DocumentId, tagId?: DocumentId) => { + async (documentId?: DocumentId, tagId?: DocumentId, options: { silent?: boolean } = {}) => { if (!documentId || !tagId) { return false; } @@ -152,11 +161,15 @@ export const useDocumentTagMutations = ({ } return { ...doc, tags: nextTags }; }); - showToast('Tag removed.', 'success'); + if (!options.silent) { + showToast('Tag removed.', 'success'); + } return true; } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to remove tag.'; - notifyApiError(error, message); + if (!options.silent) { + const message = (error as Record)?.response?.data?.error || 'Failed to remove tag.'; + notifyApiError(error, message); + } return false; } }, diff --git a/frontend/src/documents/features/tagging/tagTransfer.ts b/frontend/src/documents/features/tagging/tagTransfer.ts index 1777c35..c132b6c 100644 --- a/frontend/src/documents/features/tagging/tagTransfer.ts +++ b/frontend/src/documents/features/tagging/tagTransfer.ts @@ -44,10 +44,27 @@ interface ActiveDragState { } let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null }; +interface ActionResult { + type: 'attach' | 'detach'; + success: boolean; +} + + +let pendingActions = 0; +let actionResults: ActionResult[] = []; +let toastListener: ((message: string, type: 'success' | 'error' | 'info') => void) | null = null; + const listeners = new Set<(state: ActiveDragState) => void>(); export const getActiveDragState = (): ActiveDragState => activeDragState; +export const subscribeToToast = (callback: (message: string, type: 'success' | 'error' | 'info') => void): () => void => { + toastListener = callback; + return () => { + toastListener = null; + }; +}; + export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void): () => void => { listeners.add(callback); return () => { @@ -55,6 +72,41 @@ export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void): }; }; +const processResults = () => { + if (pendingActions > 0) return; + if (actionResults.length === 0) return; + + const successes = actionResults.filter(r => r.success); + const attached = successes.find(r => r.type === 'attach'); + const detached = successes.find(r => r.type === 'detach'); + + try { + if (attached && detached) { + toastListener?.('Tag moved.', 'success'); + } else if (attached) { + toastListener?.('Tag assigned.', 'success'); + } else if (detached) { + toastListener?.('Tag removed.', 'success'); + } else if (actionResults.some(r => !r.success)) { + // If we only had failures, or partial failures + toastListener?.('Action failed.', 'error'); + } + } finally { + actionResults = []; + } +}; + +export const beginAction = (): void => { + pendingActions++; +}; + +export const finishAction = (result: ActionResult): void => { + actionResults.push(result); + pendingActions--; + // Use timeout to allow batching if multiple actions finish closely or sequence gaps + setTimeout(processResults, 50); +}; + const notifyListeners = () => { listeners.forEach((cb) => cb(activeDragState)); }; diff --git a/frontend/src/documents/interactions/useTagInteractions.ts b/frontend/src/documents/interactions/useTagInteractions.ts index 8115d6c..f66564a 100644 --- a/frontend/src/documents/interactions/useTagInteractions.ts +++ b/frontend/src/documents/interactions/useTagInteractions.ts @@ -10,6 +10,8 @@ import { writeTagTransferData, getActiveDragState, clearTagTransferData, + beginAction, + finishAction, } from '../../documents/features/tagging/tagTransfer'; import type { Identifier } from '../../types/identifiers'; import type { Document, Tag } from '../../types/documents'; @@ -45,8 +47,8 @@ const cleanupPreview = (previewNode: HTMLElement | null) => { }; interface UseTagInteractionsArgs { - onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void; - onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void; + onAssignTagToDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise | void; + onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise | void; onTagClick?: (tagId: Identifier) => void; } @@ -132,37 +134,45 @@ export const useTagInteractions = ({ ); const onTagDrop = useCallback( - (event: React.DragEvent, doc: Document) => { - if (!doc || !doc.id) { + (event: React.DragEvent, doc: Document) => { + if (!event?.dataTransfer || !doc?.id) { return; } - if (!isTagTransfer(event)) { + + event.preventDefault(); + event.stopPropagation(); + + const isTagTransfer = isTagTransferEvent(event); + if (!isTagTransfer) { return; } - preventAll(event); - event.currentTarget.classList.remove('is-tag-target'); const payload = parseTagTransferPayload(event); - if (payload && payload.sourceDocId === doc.id) { - if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'; + const element = event.currentTarget as HTMLElement; + element.classList.remove('is-tag-target'); + + if (!payload || !payload.id) { return; } - setTimeout(() => { - if (!payload || !payload.id) { - return; - } - + setTimeout(async () => { // Double-check assignment (even though cursor logic tries to prevent it) const isAssigned = doc.tags?.some((t) => t === payload.id); if (isAssigned) return; if (onAssignTagToDocument && doc.id) { - onAssignTagToDocument(doc.id, payload.id); + // Queue Result Logic + beginAction(); + try { + await onAssignTagToDocument(doc.id, payload.id, { silent: true }); + finishAction({ type: 'attach', success: true }); + } catch { + finishAction({ type: 'attach', success: false }); + } } }, 0); }, - [isTagTransfer, onAssignTagToDocument], + [onAssignTagToDocument], ); const onTagDragStart = useCallback( @@ -198,11 +208,11 @@ export const useTagInteractions = ({ (event: React.DragEvent) => { event.stopPropagation(); const { sourceDocId, tagId } = getActiveDragState(); - clearTagTransferData(); - const dropEffect = event?.dataTransfer?.dropEffect; - setTimeout(() => { + clearTagTransferData(); + + setTimeout(async () => { const state = draggingTagRef.current; if (state) { const element = state.element; @@ -214,7 +224,13 @@ export const useTagInteractions = ({ // Remove if move operation completed if (dropEffect === 'move') { if (onRemoveTagFromDocument && sourceDocId && tagId) { - onRemoveTagFromDocument(sourceDocId, tagId); + beginAction(); + try { + await onRemoveTagFromDocument(sourceDocId, tagId, { silent: true }); + finishAction({ type: 'detach', success: true }); + } catch { + finishAction({ type: 'detach', success: false }); + } } } } diff --git a/frontend/src/documents/logic/useDocumentsPanelProps.ts b/frontend/src/documents/logic/useDocumentsPanelProps.ts index 5389fb6..75bfe24 100644 --- a/frontend/src/documents/logic/useDocumentsPanelProps.ts +++ b/frontend/src/documents/logic/useDocumentsPanelProps.ts @@ -40,8 +40,8 @@ interface UseDocumentsPanelPropsArgs { activeCorrespondentFilters?: Identifier[]; ensureAssetUrl?: (...args: unknown[]) => void; getDocumentAsset?: (...args: unknown[]) => unknown; - handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void; - handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void; + handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void; + handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void; documentsViewMode?: string; documentsSortField?: string; documentsSortDirection?: string; diff --git a/frontend/src/documents/panel/useDocumentsContextValues.ts b/frontend/src/documents/panel/useDocumentsContextValues.ts index 7d5ef65..1822a17 100644 --- a/frontend/src/documents/panel/useDocumentsContextValues.ts +++ b/frontend/src/documents/panel/useDocumentsContextValues.ts @@ -1,10 +1,12 @@ -import { useMemo, useCallback, useRef } from 'react'; +import { useMemo, useCallback, useRef, useEffect } from 'react'; import type { DocumentsPanelInnerProps } from './DocumentsPanel'; import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer'; import { useDocumentsFilter } from '../context/DocumentsFilterContext'; import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; import type { Identifier } from '../../types/identifiers'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; import { useTagInteractions } from '../interactions/useTagInteractions'; +import { subscribeToToast } from '../features/tagging/tagTransfer'; const EntryType = { folder: 'folder', @@ -40,6 +42,15 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => { const scrollRef = useRef(null); const suppressDocumentClickRef = useRef(false); + const { showToast } = useStatusToast(); + + // Subscribe to tag operation results + useEffect(() => { + return subscribeToToast((message, type) => { + showToast(message, type); + }); + }, [showToast]); + // Handlers const tagHandlers = useTagInteractions({ onAssignTagToDocument: props.onDocumentTagAttach,