diff --git a/src/components/bookmarks/BookmarkList.jsx b/src/components/bookmarks/BookmarkList.jsx index 9cfbb44..0e6caff 100644 --- a/src/components/bookmarks/BookmarkList.jsx +++ b/src/components/bookmarks/BookmarkList.jsx @@ -11,12 +11,14 @@ import { FilterBar } from './FilterBar' import { SelectionActionBar } from './SelectionActionBar' import { SettingsView } from '../ui/SettingsView' import { HelpModal } from '../ui/HelpModal' +import { QuickTagModal } from '../ui/QuickTagModal' import { ToastContainer } from '../ui/Toast' import { PackageOpen } from '../ui/Icons' import { getAllBookmarks, deleteBookmark, bulkDeleteBookmarks, + toggleReadLater, } from '../../services/bookmarks' export function BookmarkList() { @@ -52,6 +54,8 @@ export function BookmarkList() { const [searchQuery, setSearchQuery] = useState('') const [isSidebarOpen, setIsSidebarOpen] = useState(false) const [isHelpOpen, setIsHelpOpen] = useState(false) + const [isTagModalOpen, setIsTagModalOpen] = useState(false) + const [tagModalBookmark, setTagModalBookmark] = useState(null) const [selectedIndex, setSelectedIndex] = useState(-1) const [selectionMode, setSelectionMode] = useState(false) const [selectedIds, setSelectedIds] = useState(new Set()) @@ -335,6 +339,63 @@ export function BookmarkList() { } }, [selectedIds, addToast, exitSelectionMode]) + // Get the currently selected bookmark + const getSelectedBookmark = useCallback(() => { + if (selectedIndex >= 0 && selectedIndex < filteredBookmarks.length) { + return filteredBookmarks[selectedIndex] + } + return null + }, [selectedIndex, filteredBookmarks]) + + // Shift+T: Open tag edit modal for selected bookmark + const openTagModal = useCallback(() => { + if (filterView === 'inbox') return + if (isAddingNew || editingBookmarkId) return + const bookmark = getSelectedBookmark() + if (bookmark) { + setTagModalBookmark(bookmark) + setIsTagModalOpen(true) + } + }, [filterView, isAddingNew, editingBookmarkId, getSelectedBookmark]) + + const closeTagModal = useCallback(() => { + setIsTagModalOpen(false) + setTagModalBookmark(null) + }, []) + + // Shift+L: Toggle read later for selected bookmark + const toggleReadLaterSelected = useCallback(() => { + if (filterView === 'inbox') return + if (isAddingNew || editingBookmarkId) return + const bookmark = getSelectedBookmark() + if (bookmark) { + try { + const newValue = toggleReadLater(bookmark._id) + addToast({ + message: newValue ? 'Added to Read Later' : 'Removed from Read Later', + duration: 2000, + }) + } catch (error) { + console.error('Failed to toggle read later:', error) + } + } + }, [filterView, isAddingNew, editingBookmarkId, getSelectedBookmark, addToast]) + + // c: Copy URL to clipboard + const copySelectedUrl = useCallback(() => { + if (filterView === 'inbox') return + if (isAddingNew || editingBookmarkId) return + const bookmark = getSelectedBookmark() + if (bookmark) { + navigator.clipboard.writeText(bookmark.url).then(() => { + addToast({ message: 'URL copied to clipboard', duration: 2000 }) + }).catch((error) => { + console.error('Failed to copy URL:', error) + addToast({ message: 'Failed to copy URL', duration: 2000 }) + }) + } + }, [filterView, isAddingNew, editingBookmarkId, getSelectedBookmark, addToast]) + useEffect(() => { setSelectedIndex(-1) }, [filteredBookmarks.length, filterView, selectedTag, debouncedSearchQuery]) @@ -364,8 +425,12 @@ export function BookmarkList() { 'shift+j': selectNextWithShift, 'shift+k': selectPrevWithShift, 'enter': openSelected, + 'o': openSelected, 'e': editSelected, 'd': selectionMode && selectedIds.size > 0 ? handleBulkDelete : deleteSelected, + 't': openTagModal, + 'l': toggleReadLaterSelected, + 'c': copySelectedUrl, 'mod+k': focusSearch, 'q': exitInbox, 'mod+z': handleUndo, @@ -547,6 +612,12 @@ export function BookmarkList() { setIsHelpOpen(false)} /> + + { + if (isOpen && bookmark) { + setLocalTags(bookmark.tags || []) + } + }, [isOpen, bookmark?._id]) + + // Filter tags based on search, and include "create new" option + const filteredOptions = useMemo(() => { + const query = searchQuery.toLowerCase().trim() + + // Combine store tags with any new tags in localTags + const allAvailableTags = [...new Set([...allTags, ...localTags])] + + // Filter tags that match the query + const matchingTags = allAvailableTags + .filter(tag => !query || tag.toLowerCase().includes(query)) + .map(tag => ({ + type: 'existing', + value: tag, + isSelected: localTags.includes(tag), + })) + + // Add "create new" option if query doesn't match any available tag exactly + const exactMatch = allAvailableTags.some(tag => tag.toLowerCase() === query) + if (query && !exactMatch) { + matchingTags.push({ + type: 'create', + value: query, + isSelected: false, + }) + } + + return matchingTags + }, [allTags, localTags, searchQuery]) + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setSearchQuery('') + setSelectedIndex(0) + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [isOpen]) + + // Keep selected index in bounds + useEffect(() => { + if (selectedIndex >= filteredOptions.length) { + setSelectedIndex(Math.max(0, filteredOptions.length - 1)) + } + }, [filteredOptions.length, selectedIndex]) + + // Scroll selected item into view + useEffect(() => { + if (listRef.current && filteredOptions.length > 0) { + const selectedItem = listRef.current.children[selectedIndex] + selectedItem?.scrollIntoView({ block: 'nearest' }) + } + }, [selectedIndex, filteredOptions.length]) + + const toggleTag = useCallback((tagValue) => { + const normalizedTag = tagValue.toLowerCase() + setLocalTags(prev => + prev.includes(normalizedTag) + ? prev.filter(t => t !== normalizedTag) + : [...prev, normalizedTag] + ) + }, []) + + const saveAndClose = useCallback(() => { + if (!bookmark?._id) return + + try { + updateBookmark(bookmark._id, { tags: localTags }) + } catch (error) { + console.error('Failed to update tags:', error) + } + onClose() + }, [bookmark?._id, localTags, onClose]) + + const handleKeyDown = useCallback((e) => { + const isModified = e.ctrlKey || e.metaKey + + // Ctrl+j / Ctrl+k or Arrow keys for navigation + if (e.key === 'ArrowDown' || (isModified && e.key === 'j')) { + e.preventDefault() + setSelectedIndex(prev => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + return + } + + if (e.key === 'ArrowUp' || (isModified && e.key === 'k')) { + e.preventDefault() + setSelectedIndex(prev => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + return + } + + // Space to toggle selected tag + if (e.key === ' ') { + e.preventDefault() + if (filteredOptions[selectedIndex]) { + toggleTag(filteredOptions[selectedIndex].value) + } + return + } + + // Enter to save and close (also adds the tag if "Create" option is selected) + if (e.key === 'Enter') { + e.preventDefault() + const selectedOption = filteredOptions[selectedIndex] + if (selectedOption?.type === 'create') { + // Add the new tag before saving + const newTags = [...localTags, selectedOption.value.toLowerCase()] + setLocalTags(newTags) + // Save with the new tag included + if (bookmark?._id) { + try { + updateBookmark(bookmark._id, { tags: newTags }) + } catch (error) { + console.error('Failed to update tags:', error) + } + } + onClose() + } else { + saveAndClose() + } + return + } + + // Escape to close without saving + if (e.key === 'Escape') { + e.preventDefault() + onClose() + return + } + + // Number keys 1-9 for quick toggle + if (e.key >= '1' && e.key <= '9' && !isModified) { + const index = parseInt(e.key) - 1 + if (index < filteredOptions.length) { + e.preventDefault() + toggleTag(filteredOptions[index].value) + } + } + }, [filteredOptions, selectedIndex, toggleTag, saveAndClose, onClose]) + + if (!isOpen || !bookmark) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {/* Header - Bookmark title */} +
+ + {bookmark.title} + +
+ + {/* Search input */} +
+ setSearchQuery(e.target.value)} + placeholder="Change tags..." + className="w-full bg-transparent text-foreground placeholder:text-muted-foreground text-sm outline-none" + autoComplete="off" + /> +
+ + {/* Options list */} +
+ {filteredOptions.length === 0 ? ( +
+ No tags found +
+ ) : ( + filteredOptions.map((option, index) => ( +
toggleTag(option.value)} + onMouseEnter={() => setSelectedIndex(index)} + className={cn( + 'flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors', + index === selectedIndex + ? 'bg-accent' + : 'hover:bg-accent/50' + )} + > + {/* Checkbox */} +
+ {option.isSelected && ( + + )} +
+ + {/* Icon */} +
+ {option.type === 'create' ? ( + + ) : ( + + )} +
+ + {/* Label */} + + {option.type === 'create' ? ( + <>Create "{option.value}" + ) : ( + option.value + )} + + + {/* Number key hint (1-9) */} + {index < 9 && ( + + {index + 1} + + )} +
+ )) + )} +
+ + {/* Footer hints */} +
+ + ↑↓ + navigate + + + Space + toggle + + + Enter + save + + + Esc + discard + +
+
+
+ ) +}