BatchTagModal.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { useState, useEffect } from 'react';
  2. import { useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { X, Tag, Plus, Loader2 } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import { Card, CardContent } from './Card';
  6. import { Button } from './Button';
  7. import { useToast } from '../contexts/ToastContext';
  8. interface BatchTagModalProps {
  9. selectedIds: number[];
  10. existingTags: string[];
  11. onClose: () => void;
  12. }
  13. export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {
  14. const queryClient = useQueryClient();
  15. const { showToast } = useToast();
  16. const [newTag, setNewTag] = useState('');
  17. const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
  18. const [mode, setMode] = useState<'add' | 'remove'>('add');
  19. // Close on Escape key
  20. useEffect(() => {
  21. const handleKeyDown = (e: KeyboardEvent) => {
  22. if (e.key === 'Escape') onClose();
  23. };
  24. window.addEventListener('keydown', handleKeyDown);
  25. return () => window.removeEventListener('keydown', handleKeyDown);
  26. }, [onClose]);
  27. const batchTagMutation = useMutation({
  28. mutationFn: async () => {
  29. const tagsArray = Array.from(selectedTags);
  30. let successCount = 0;
  31. // Process sequentially to avoid SQLite database locks
  32. for (const id of selectedIds) {
  33. try {
  34. const archive = await api.getArchive(id);
  35. const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
  36. let newTags: string[];
  37. if (mode === 'add') {
  38. // Add tags that aren't already present
  39. newTags = [...new Set([...currentTags, ...tagsArray])];
  40. } else {
  41. // Remove selected tags
  42. newTags = currentTags.filter(t => !selectedTags.has(t));
  43. }
  44. await api.updateArchive(id, { tags: newTags.join(', ') });
  45. successCount++;
  46. } catch (err) {
  47. console.error(`Failed to update archive ${id}:`, err);
  48. throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
  49. }
  50. }
  51. return { count: successCount, mode, tags: tagsArray };
  52. },
  53. onSuccess: ({ count, mode, tags }) => {
  54. queryClient.invalidateQueries({ queryKey: ['archives'] });
  55. showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
  56. onClose();
  57. },
  58. onError: (error: Error) => {
  59. showToast(error.message || 'Failed to update tags', 'error');
  60. },
  61. });
  62. const toggleTag = (tag: string) => {
  63. setSelectedTags((prev) => {
  64. const next = new Set(prev);
  65. if (next.has(tag)) {
  66. next.delete(tag);
  67. } else {
  68. next.add(tag);
  69. }
  70. return next;
  71. });
  72. };
  73. const addNewTag = () => {
  74. if (newTag.trim() && !selectedTags.has(newTag.trim())) {
  75. setSelectedTags((prev) => new Set([...prev, newTag.trim()]));
  76. setNewTag('');
  77. }
  78. };
  79. const handleKeyDown = (e: React.KeyboardEvent) => {
  80. if (e.key === 'Enter') {
  81. e.preventDefault();
  82. addNewTag();
  83. }
  84. };
  85. return (
  86. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  87. <Card className="w-full max-w-md">
  88. <CardContent className="p-0">
  89. {/* Header */}
  90. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  91. <div className="flex items-center gap-2">
  92. <Tag className="w-5 h-5 text-bambu-green" />
  93. <h2 className="text-xl font-semibold text-white">
  94. {mode === 'add' ? 'Add Tags' : 'Remove Tags'}
  95. </h2>
  96. </div>
  97. <button
  98. onClick={onClose}
  99. className="text-bambu-gray hover:text-white transition-colors"
  100. >
  101. <X className="w-5 h-5" />
  102. </button>
  103. </div>
  104. {/* Content */}
  105. <div className="p-4 space-y-4">
  106. <p className="text-sm text-bambu-gray">
  107. {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}
  108. </p>
  109. {/* Mode toggle */}
  110. <div className="flex gap-2">
  111. <Button
  112. size="sm"
  113. variant={mode === 'add' ? 'primary' : 'secondary'}
  114. onClick={() => setMode('add')}
  115. >
  116. Add Tags
  117. </Button>
  118. <Button
  119. size="sm"
  120. variant={mode === 'remove' ? 'primary' : 'secondary'}
  121. onClick={() => setMode('remove')}
  122. >
  123. Remove Tags
  124. </Button>
  125. </div>
  126. {/* New tag input (only for add mode) */}
  127. {mode === 'add' && (
  128. <div className="flex gap-2">
  129. <input
  130. type="text"
  131. placeholder="Enter new tag..."
  132. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  133. value={newTag}
  134. onChange={(e) => setNewTag(e.target.value)}
  135. onKeyDown={handleKeyDown}
  136. />
  137. <Button size="sm" variant="secondary" onClick={addNewTag} disabled={!newTag.trim()}>
  138. <Plus className="w-4 h-4" />
  139. </Button>
  140. </div>
  141. )}
  142. {/* Existing tags */}
  143. {existingTags.length > 0 && (
  144. <div>
  145. <p className="text-xs text-bambu-gray mb-2">Existing tags:</p>
  146. <div className="flex flex-wrap gap-2">
  147. {existingTags.map((tag) => (
  148. <button
  149. key={tag}
  150. onClick={() => toggleTag(tag)}
  151. className={`px-2 py-1 rounded text-sm transition-colors ${
  152. selectedTags.has(tag)
  153. ? 'bg-bambu-green text-white'
  154. : 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'
  155. }`}
  156. >
  157. {tag}
  158. </button>
  159. ))}
  160. </div>
  161. </div>
  162. )}
  163. {/* Selected tags preview */}
  164. {selectedTags.size > 0 && (
  165. <div>
  166. <p className="text-xs text-bambu-gray mb-2">
  167. Tags to {mode === 'add' ? 'add' : 'remove'}:
  168. </p>
  169. <div className="flex flex-wrap gap-2">
  170. {Array.from(selectedTags).map((tag) => (
  171. <span
  172. key={tag}
  173. className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${
  174. mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
  175. }`}
  176. >
  177. {tag}
  178. <button onClick={() => toggleTag(tag)} className="hover:opacity-70">
  179. <X className="w-3 h-3" />
  180. </button>
  181. </span>
  182. ))}
  183. </div>
  184. </div>
  185. )}
  186. </div>
  187. {/* Footer */}
  188. <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
  189. <Button variant="secondary" onClick={onClose} className="flex-1">
  190. Cancel
  191. </Button>
  192. <Button
  193. onClick={() => batchTagMutation.mutate()}
  194. disabled={selectedTags.size === 0 || batchTagMutation.isPending}
  195. className="flex-1"
  196. >
  197. {batchTagMutation.isPending ? (
  198. <>
  199. <Loader2 className="w-4 h-4 animate-spin" />
  200. Processing...
  201. </>
  202. ) : (
  203. <>
  204. <Tag className="w-4 h-4" />
  205. {mode === 'add' ? 'Add Tags' : 'Remove Tags'}
  206. </>
  207. )}
  208. </Button>
  209. </div>
  210. </CardContent>
  211. </Card>
  212. </div>
  213. );
  214. }