TagManagementModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { useState, useEffect } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { TagInfo } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. interface TagManagementModalProps {
  10. onClose: () => void;
  11. }
  12. export function TagManagementModal({ onClose }: TagManagementModalProps) {
  13. const queryClient = useQueryClient();
  14. const { showToast } = useToast();
  15. const [search, setSearch] = useState('');
  16. const [editingTag, setEditingTag] = useState<string | null>(null);
  17. const [editValue, setEditValue] = useState('');
  18. const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
  19. const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
  20. // Close on Escape key
  21. useEffect(() => {
  22. const handleKeyDown = (e: KeyboardEvent) => {
  23. if (e.key === 'Escape') {
  24. if (editingTag) {
  25. setEditingTag(null);
  26. } else if (deleteConfirm) {
  27. setDeleteConfirm(null);
  28. } else {
  29. onClose();
  30. }
  31. }
  32. };
  33. window.addEventListener('keydown', handleKeyDown);
  34. return () => window.removeEventListener('keydown', handleKeyDown);
  35. }, [onClose, editingTag, deleteConfirm]);
  36. const { data: tags, isLoading } = useQuery({
  37. queryKey: ['tags'],
  38. queryFn: api.getTags,
  39. });
  40. const renameMutation = useMutation({
  41. mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
  42. api.renameTag(oldName, newName),
  43. onSuccess: (data, { oldName, newName }) => {
  44. queryClient.invalidateQueries({ queryKey: ['tags'] });
  45. queryClient.invalidateQueries({ queryKey: ['archives'] });
  46. showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
  47. setEditingTag(null);
  48. },
  49. onError: (error: Error) => {
  50. showToast(error.message || 'Failed to rename tag', 'error');
  51. },
  52. });
  53. const deleteMutation = useMutation({
  54. mutationFn: (name: string) => api.deleteTag(name),
  55. onSuccess: (data, name) => {
  56. queryClient.invalidateQueries({ queryKey: ['tags'] });
  57. queryClient.invalidateQueries({ queryKey: ['archives'] });
  58. showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
  59. setDeleteConfirm(null);
  60. },
  61. onError: (error: Error) => {
  62. showToast(error.message || 'Failed to delete tag', 'error');
  63. },
  64. });
  65. const startEdit = (tag: TagInfo) => {
  66. setEditingTag(tag.name);
  67. setEditValue(tag.name);
  68. setDeleteConfirm(null);
  69. };
  70. const cancelEdit = () => {
  71. setEditingTag(null);
  72. setEditValue('');
  73. };
  74. const submitEdit = () => {
  75. if (!editingTag || !editValue.trim()) return;
  76. const newName = editValue.trim();
  77. if (newName === editingTag) {
  78. cancelEdit();
  79. return;
  80. }
  81. renameMutation.mutate({ oldName: editingTag, newName });
  82. };
  83. const handleEditKeyDown = (e: React.KeyboardEvent) => {
  84. if (e.key === 'Enter') {
  85. e.preventDefault();
  86. submitEdit();
  87. } else if (e.key === 'Escape') {
  88. e.preventDefault();
  89. cancelEdit();
  90. }
  91. };
  92. const confirmDelete = (name: string) => {
  93. setDeleteConfirm(name);
  94. setEditingTag(null);
  95. };
  96. const executeDelete = () => {
  97. if (deleteConfirm) {
  98. deleteMutation.mutate(deleteConfirm);
  99. }
  100. };
  101. // Filter and sort tags
  102. const filteredTags = tags
  103. ?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
  104. .sort((a, b) => {
  105. if (sortBy === 'count') {
  106. return b.count - a.count || a.name.localeCompare(b.name);
  107. }
  108. return a.name.localeCompare(b.name);
  109. });
  110. const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
  111. return (
  112. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  113. <Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
  114. <CardContent className="p-0 flex flex-col min-h-0">
  115. {/* Header */}
  116. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  117. <div className="flex items-center gap-2">
  118. <Tag className="w-5 h-5 text-bambu-green" />
  119. <h2 className="text-xl font-semibold text-white">Manage Tags</h2>
  120. </div>
  121. <button
  122. onClick={onClose}
  123. className="text-bambu-gray hover:text-white transition-colors"
  124. >
  125. <X className="w-5 h-5" />
  126. </button>
  127. </div>
  128. {/* Search and sort */}
  129. <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  130. <div className="flex gap-2">
  131. <div className="relative flex-1">
  132. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  133. <input
  134. type="text"
  135. placeholder="Search tags..."
  136. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  137. value={search}
  138. onChange={(e) => setSearch(e.target.value)}
  139. />
  140. </div>
  141. <select
  142. className="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"
  143. value={sortBy}
  144. onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
  145. >
  146. <option value="count">Sort by Count</option>
  147. <option value="name">Sort by Name</option>
  148. </select>
  149. </div>
  150. {tags && (
  151. <p className="text-xs text-bambu-gray mt-2">
  152. {tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
  153. </p>
  154. )}
  155. </div>
  156. {/* Tags list */}
  157. <div className="flex-1 overflow-y-auto min-h-0 p-4">
  158. {isLoading ? (
  159. <div className="flex items-center justify-center py-8">
  160. <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
  161. </div>
  162. ) : !filteredTags?.length ? (
  163. <div className="text-center py-8 text-bambu-gray">
  164. {search ? 'No tags match your search' : 'No tags found'}
  165. </div>
  166. ) : (
  167. <div className="space-y-2">
  168. {filteredTags.map((tag) => (
  169. <div
  170. key={tag.name}
  171. className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
  172. >
  173. {editingTag === tag.name ? (
  174. // Edit mode
  175. <div className="flex-1 flex items-center gap-2">
  176. <input
  177. type="text"
  178. className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
  179. value={editValue}
  180. onChange={(e) => setEditValue(e.target.value)}
  181. onKeyDown={handleEditKeyDown}
  182. autoFocus
  183. />
  184. <Button
  185. size="sm"
  186. variant="primary"
  187. onClick={submitEdit}
  188. disabled={!editValue.trim() || renameMutation.isPending}
  189. className="p-1.5"
  190. >
  191. {renameMutation.isPending ? (
  192. <Loader2 className="w-4 h-4 animate-spin" />
  193. ) : (
  194. <Check className="w-4 h-4" />
  195. )}
  196. </Button>
  197. <Button
  198. size="sm"
  199. variant="ghost"
  200. onClick={cancelEdit}
  201. className="p-1.5"
  202. >
  203. <X className="w-4 h-4" />
  204. </Button>
  205. </div>
  206. ) : deleteConfirm === tag.name ? (
  207. // Delete confirmation
  208. <div className="flex-1 flex items-center gap-2">
  209. <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
  210. <span className="text-sm text-bambu-gray-light flex-1">
  211. Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
  212. </span>
  213. <Button
  214. size="sm"
  215. variant="danger"
  216. onClick={executeDelete}
  217. disabled={deleteMutation.isPending}
  218. className="p-1.5"
  219. >
  220. {deleteMutation.isPending ? (
  221. <Loader2 className="w-4 h-4 animate-spin" />
  222. ) : (
  223. <Trash2 className="w-4 h-4" />
  224. )}
  225. </Button>
  226. <Button
  227. size="sm"
  228. variant="ghost"
  229. onClick={() => setDeleteConfirm(null)}
  230. className="p-1.5"
  231. >
  232. <X className="w-4 h-4" />
  233. </Button>
  234. </div>
  235. ) : (
  236. // Normal display
  237. <>
  238. <Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  239. <span className="text-white flex-1 truncate">{tag.name}</span>
  240. <span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
  241. {tag.count}
  242. </span>
  243. <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
  244. <button
  245. onClick={() => startEdit(tag)}
  246. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  247. title="Rename tag"
  248. >
  249. <Pencil className="w-4 h-4" />
  250. </button>
  251. <button
  252. onClick={() => confirmDelete(tag.name)}
  253. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
  254. title="Delete tag"
  255. >
  256. <Trash2 className="w-4 h-4" />
  257. </button>
  258. </div>
  259. </>
  260. )}
  261. </div>
  262. ))}
  263. </div>
  264. )}
  265. </div>
  266. {/* Footer */}
  267. <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
  268. <Button variant="secondary" onClick={onClose} className="flex-1">
  269. Close
  270. </Button>
  271. </div>
  272. </CardContent>
  273. </Card>
  274. </div>
  275. );
  276. }