From 21c138a339aa0343819a3d31bdc35cd590d94add Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 10 Nov 2025 15:05:00 -0500 Subject: [PATCH] feat: Improve UI components for batch processing and tag management This commit enhances the user interface of the Process, Scan, Search, and Tags components. The input fields for batch size and button styles have been adjusted for better usability and consistency. The Search component now includes improved logic for handling removable tags, ensuring only single tags can be deleted. Additionally, a new dialog for tagging selected photos has been introduced, allowing users to manage tags more effectively. Documentation has been updated to reflect these changes. --- frontend/src/pages/Process.tsx | 9 +- frontend/src/pages/Scan.tsx | 4 +- frontend/src/pages/Search.tsx | 43 +++- frontend/src/pages/Tags.tsx | 420 ++++++++++++++++++++++++++++++--- 4 files changed, 427 insertions(+), 49 deletions(-) diff --git a/frontend/src/pages/Process.tsx b/frontend/src/pages/Process.tsx index 33604ef..d84ffe8 100644 --- a/frontend/src/pages/Process.tsx +++ b/frontend/src/pages/Process.tsx @@ -235,7 +235,7 @@ export default function Process() {
@@ -271,11 +271,10 @@ export default function Process() { e.preventDefault() } }} - placeholder="All unprocessed photos" - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + className="w-32 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" disabled={isProcessing} /> -

+

Leave empty to process all unprocessed photos

@@ -342,7 +341,7 @@ export default function Process() { type="button" onClick={handleStartProcessing} disabled={isProcessing} - className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" > {isProcessing ? 'Processing...' : 'Start Processing'} diff --git a/frontend/src/pages/Scan.tsx b/frontend/src/pages/Scan.tsx index 71d1856..9b15e59 100644 --- a/frontend/src/pages/Scan.tsx +++ b/frontend/src/pages/Scan.tsx @@ -289,9 +289,9 @@ export default function Scan() { type="button" onClick={handleScanFolder} disabled={isImporting || !folderPath.trim()} - className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" > - {isImporting ? 'Scanning...' : 'Start Scan'} + {isImporting ? 'Scanning...' : 'Start Scanning'} diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index e1a13d1..ee656d5 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -237,20 +237,36 @@ export default function Search() { }, [showTagModal, selectedPhotos.size]) // Get all unique tags from selected photos + // Only include tags that are single (linkage_type = 0) in ALL selected photos + // Tags that are bulk (linkage_type = 1) in ANY photo cannot be removed const allPhotoTags = useMemo(() => { + if (selectedPhotos.size === 0) return [] + + const photoIds = Array.from(selectedPhotos) const tagMap = new Map() + // First, collect all tags from all photos Object.values(photoTags).forEach(tags => { tags.forEach(tag => { - // Use tag_id as key to avoid duplicates if (!tagMap.has(tag.tag_id)) { tagMap.set(tag.tag_id, tag) } }) }) - return Array.from(tagMap.values()) - }, [photoTags]) + // Filter to only include tags that are single (linkage_type = 0) in ALL photos + const removableTags = Array.from(tagMap.values()).filter(tag => { + // Check if this tag is single in all selected photos + return photoIds.every(photoId => { + const photoTagList = photoTags[photoId] || [] + const photoTag = photoTagList.find(t => t.tag_id === tag.tag_id) + // Tag must exist in photo and be single (linkage_type = 0) + return photoTag && photoTag.linkage_type === 0 + }) + }) + + return removableTags + }, [photoTags, selectedPhotos]) const handleAddTag = async () => { if (selectedPhotos.size === 0) { @@ -311,7 +327,18 @@ export default function Search() { setShowDeleteConfirm(false) // Get tag names from selected tag IDs + // Filter to only include tags that are single (linkage_type = 0) in all photos + // This is a defensive check - allPhotoTags should already only contain single tags + const photoIds = Array.from(selectedPhotos) const tagNamesToDelete = Array.from(selectedTagIds) + .filter(tagId => { + // Check if tag is single in all selected photos + return photoIds.every(photoId => { + const photoTagList = photoTags[photoId] || [] + const photoTag = photoTagList.find(t => t.tag_id === tagId) + return photoTag && photoTag.linkage_type === 0 + }) + }) .map(tagId => { const tag = allPhotoTags.find(t => t.tag_id === tagId) return tag ? tag.tag_name : null @@ -319,7 +346,7 @@ export default function Search() { .filter(Boolean) as string[] if (tagNamesToDelete.length === 0) { - alert('No valid tags selected for deletion.') + alert('No removable tags selected. Bulk tags cannot be removed from this dialog.') return } @@ -804,12 +831,15 @@ export default function Search() {
+

+ Note: Bulk tags cannot be removed from this dialog +

{loadingTags ? (

Loading tags...

) : allPhotoTags.length === 0 ? ( -

No tags on selected photos

+

No removable tags on selected photos

) : (
{allPhotoTags.map(tag => ( @@ -830,7 +860,6 @@ export default function Search() { /> {tag.tag_name} - {tag.linkage_type === 1 && ' (bulk)'}
))} diff --git a/frontend/src/pages/Tags.tsx b/frontend/src/pages/Tags.tsx index 039d208..6b84e13 100644 --- a/frontend/src/pages/Tags.tsx +++ b/frontend/src/pages/Tags.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react' import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags' +import { useDeveloperMode } from '../context/DeveloperModeContext' type ViewMode = 'list' | 'icons' | 'compact' @@ -22,6 +23,7 @@ interface FolderGroup { } export default function Tags() { + const { isDeveloperMode } = useDeveloperMode() const [viewMode, setViewMode] = useState('list') const [photos, setPhotos] = useState([]) const [tags, setTags] = useState([]) @@ -34,6 +36,8 @@ export default function Tags() { const [showManageTags, setShowManageTags] = useState(false) const [showTagDialog, setShowTagDialog] = useState(null) const [showBulkTagDialog, setShowBulkTagDialog] = useState(null) + const [selectedPhotoIds, setSelectedPhotoIds] = useState>(new Set()) + const [showTagSelectedDialog, setShowTagSelectedDialog] = useState(false) // Load photos and tags useEffect(() => { @@ -332,41 +336,50 @@ export default function Tags() {

Photo Explorer - Tag Management

-
- -
- - - + {isDeveloperMode && ( +
+ +
+ + + +
-
+ )} +