| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- import { useState, useEffect } from 'react';
- import { useMutation, useQueryClient } from '@tanstack/react-query';
- import { X, Tag, Plus, Loader2 } from 'lucide-react';
- import { api } from '../api/client';
- import { Card, CardContent } from './Card';
- import { Button } from './Button';
- import { useToast } from '../contexts/ToastContext';
- interface BatchTagModalProps {
- selectedIds: number[];
- existingTags: string[];
- onClose: () => void;
- }
- export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const [newTag, setNewTag] = useState('');
- const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
- const [mode, setMode] = useState<'add' | 'remove'>('add');
- // Close on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [onClose]);
- const batchTagMutation = useMutation({
- mutationFn: async () => {
- const tagsArray = Array.from(selectedTags);
- let successCount = 0;
- // Process sequentially to avoid SQLite database locks
- for (const id of selectedIds) {
- try {
- const archive = await api.getArchive(id);
- const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
- let newTags: string[];
- if (mode === 'add') {
- // Add tags that aren't already present
- newTags = [...new Set([...currentTags, ...tagsArray])];
- } else {
- // Remove selected tags
- newTags = currentTags.filter(t => !selectedTags.has(t));
- }
- await api.updateArchive(id, { tags: newTags.join(', ') });
- successCount++;
- } catch (err) {
- console.error(`Failed to update archive ${id}:`, err);
- throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
- }
- }
- return { count: successCount, mode, tags: tagsArray };
- },
- onSuccess: ({ count, mode, tags }) => {
- queryClient.invalidateQueries({ queryKey: ['archives'] });
- showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
- onClose();
- },
- onError: (error: Error) => {
- showToast(error.message || 'Failed to update tags', 'error');
- },
- });
- const toggleTag = (tag: string) => {
- setSelectedTags((prev) => {
- const next = new Set(prev);
- if (next.has(tag)) {
- next.delete(tag);
- } else {
- next.add(tag);
- }
- return next;
- });
- };
- const addNewTag = () => {
- if (newTag.trim() && !selectedTags.has(newTag.trim())) {
- setSelectedTags((prev) => new Set([...prev, newTag.trim()]));
- setNewTag('');
- }
- };
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addNewTag();
- }
- };
- return (
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
- <Card className="w-full max-w-md">
- <CardContent className="p-0">
- {/* Header */}
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div className="flex items-center gap-2">
- <Tag className="w-5 h-5 text-bambu-green" />
- <h2 className="text-xl font-semibold text-white">
- {mode === 'add' ? 'Add Tags' : 'Remove Tags'}
- </h2>
- </div>
- <button
- onClick={onClose}
- className="text-bambu-gray hover:text-white transition-colors"
- >
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Content */}
- <div className="p-4 space-y-4">
- <p className="text-sm text-bambu-gray">
- {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}
- </p>
- {/* Mode toggle */}
- <div className="flex gap-2">
- <Button
- size="sm"
- variant={mode === 'add' ? 'primary' : 'secondary'}
- onClick={() => setMode('add')}
- >
- Add Tags
- </Button>
- <Button
- size="sm"
- variant={mode === 'remove' ? 'primary' : 'secondary'}
- onClick={() => setMode('remove')}
- >
- Remove Tags
- </Button>
- </div>
- {/* New tag input (only for add mode) */}
- {mode === 'add' && (
- <div className="flex gap-2">
- <input
- type="text"
- placeholder="Enter new tag..."
- 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"
- value={newTag}
- onChange={(e) => setNewTag(e.target.value)}
- onKeyDown={handleKeyDown}
- />
- <Button size="sm" variant="secondary" onClick={addNewTag} disabled={!newTag.trim()}>
- <Plus className="w-4 h-4" />
- </Button>
- </div>
- )}
- {/* Existing tags */}
- {existingTags.length > 0 && (
- <div>
- <p className="text-xs text-bambu-gray mb-2">Existing tags:</p>
- <div className="flex flex-wrap gap-2">
- {existingTags.map((tag) => (
- <button
- key={tag}
- onClick={() => toggleTag(tag)}
- className={`px-2 py-1 rounded text-sm transition-colors ${
- selectedTags.has(tag)
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'
- }`}
- >
- {tag}
- </button>
- ))}
- </div>
- </div>
- )}
- {/* Selected tags preview */}
- {selectedTags.size > 0 && (
- <div>
- <p className="text-xs text-bambu-gray mb-2">
- Tags to {mode === 'add' ? 'add' : 'remove'}:
- </p>
- <div className="flex flex-wrap gap-2">
- {Array.from(selectedTags).map((tag) => (
- <span
- key={tag}
- className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${
- mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
- }`}
- >
- {tag}
- <button onClick={() => toggleTag(tag)} className="hover:opacity-70">
- <X className="w-3 h-3" />
- </button>
- </span>
- ))}
- </div>
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
- <Button variant="secondary" onClick={onClose} className="flex-1">
- Cancel
- </Button>
- <Button
- onClick={() => batchTagMutation.mutate()}
- disabled={selectedTags.size === 0 || batchTagMutation.isPending}
- className="flex-1"
- >
- {batchTagMutation.isPending ? (
- <>
- <Loader2 className="w-4 h-4 animate-spin" />
- Processing...
- </>
- ) : (
- <>
- <Tag className="w-4 h-4" />
- {mode === 'add' ? 'Add Tags' : 'Remove Tags'}
- </>
- )}
- </Button>
- </div>
- </CardContent>
- </Card>
- </div>
- );
- }
|