diff --git a/src/components/bookmarks/BookmarkItem.jsx b/src/components/bookmarks/BookmarkItem.jsx index 983c1bb..852b7cc 100644 --- a/src/components/bookmarks/BookmarkItem.jsx +++ b/src/components/bookmarks/BookmarkItem.jsx @@ -1,8 +1,8 @@ import { forwardRef } from 'react' -import { Pencil, Trash } from '../ui/Icons' +import { Pencil, Trash, Square, CheckSquare } from '../ui/Icons' export const BookmarkItem = forwardRef(function BookmarkItem( - { bookmark, isSelected, onEdit, onDelete, onTagClick }, + { bookmark, isSelected, isChecked, selectionMode, onEdit, onDelete, onTagClick, onToggleSelect }, ref ) { const { title, url, tags } = bookmark @@ -16,20 +16,51 @@ export const BookmarkItem = forwardRef(function BookmarkItem( const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32` + const handleClick = (e) => { + if (selectionMode) { + e.preventDefault() + onToggleSelect?.(bookmark._id) + } else if (e.shiftKey) { + e.preventDefault() + onToggleSelect?.(bookmark._id, true) + } + } + return (
- { e.target.style.opacity = 0 }} + {selectionMode && ( + + )} + + { e.target.style.opacity = 0 }} />
@@ -38,13 +69,14 @@ export const BookmarkItem = forwardRef(function BookmarkItem( href={url} target="_blank" rel="noopener noreferrer" + onClick={(e) => selectionMode && e.preventDefault()} className="font-medium text-sm text-foreground truncate hover:text-primary transition-colors" > {title} {domain}
- + {tags && tags.length > 0 && (
{tags.map((tag) => ( @@ -52,7 +84,9 @@ export const BookmarkItem = forwardRef(function BookmarkItem( key={tag} onClick={(e) => { e.stopPropagation() - onTagClick && onTagClick(tag) + if (!selectionMode) { + onTagClick && onTagClick(tag) + } }} className="text-[10px] leading-tight px-1.5 py-0.5 rounded-[3px] bg-secondary text-secondary-foreground hover:text-primary hover:bg-accent cursor-pointer transition-colors" > @@ -63,22 +97,24 @@ export const BookmarkItem = forwardRef(function BookmarkItem( )}
-
- - -
+ {!selectionMode && ( +
+ + +
+ )}
) }) diff --git a/src/components/bookmarks/BookmarkList.jsx b/src/components/bookmarks/BookmarkList.jsx index f7c467f..b440ff8 100644 --- a/src/components/bookmarks/BookmarkList.jsx +++ b/src/components/bookmarks/BookmarkList.jsx @@ -8,6 +8,7 @@ import { BookmarkInlineCard } from './BookmarkInlineCard' import { InboxView } from './InboxView' import { TagSidebar } from './TagSidebar' import { FilterBar } from './FilterBar' +import { SelectionActionBar } from './SelectionActionBar' import { SettingsView } from '../ui/SettingsView' import { HelpModal } from '../ui/HelpModal' import { ToastContainer } from '../ui/Toast' @@ -15,6 +16,7 @@ import { PackageOpen } from '../ui/Icons' import { getAllBookmarks, deleteBookmark, + bulkDeleteBookmarks, } from '../../services/bookmarks' export function BookmarkList() { @@ -51,6 +53,8 @@ export function BookmarkList() { const [isSidebarOpen, setIsSidebarOpen] = useState(false) const [isHelpOpen, setIsHelpOpen] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) + const [selectionMode, setSelectionMode] = useState(false) + const [selectedIds, setSelectedIds] = useState(new Set()) const selectedItemRef = useRef(null) const searchInputRef = useRef(null) const inboxViewRef = useRef(null) @@ -211,15 +215,133 @@ export function BookmarkList() { } }, [addToast]) + const toggleSelectionMode = useCallback(() => { + setSelectionMode(prev => { + if (prev) { + setSelectedIds(new Set()) + } + return !prev + }) + }, []) + + const exitSelectionMode = useCallback(() => { + setSelectionMode(false) + setSelectedIds(new Set()) + }, []) + + const toggleSelectBookmark = useCallback((id, initiateSelection = false) => { + if (initiateSelection && !selectionMode) { + setSelectionMode(true) + setSelectedIds(new Set([id])) + return + } + + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }, [selectionMode]) + + const toggleSelectCurrent = useCallback(() => { + if (!selectionMode) return + if (selectedIndex >= 0 && selectedIndex < filteredBookmarks.length) { + const bookmark = filteredBookmarks[selectedIndex] + toggleSelectBookmark(bookmark._id) + } + }, [selectionMode, selectedIndex, filteredBookmarks, toggleSelectBookmark]) + + const selectAllBookmarks = useCallback(() => { + if (!selectionMode) { + setSelectionMode(true) + } + setSelectedIds(new Set(filteredBookmarks.map(b => b._id))) + }, [selectionMode, filteredBookmarks]) + + const selectNextWithShift = useCallback(() => { + if (filteredBookmarks.length === 0) return + + if (!selectionMode) { + setSelectionMode(true) + } + + // Select current item before moving + if (selectedIndex >= 0 && selectedIndex < filteredBookmarks.length) { + const currentBookmark = filteredBookmarks[selectedIndex] + setSelectedIds(prev => new Set(prev).add(currentBookmark._id)) + } + + // Move to next and select it + setSelectedIndex(prev => { + const maxIndex = filteredBookmarks.length - 1 + const nextIndex = prev < maxIndex ? prev + 1 : prev + if (nextIndex >= 0 && nextIndex < filteredBookmarks.length) { + const nextBookmark = filteredBookmarks[nextIndex] + setSelectedIds(p => new Set(p).add(nextBookmark._id)) + } + return nextIndex + }) + }, [selectionMode, selectedIndex, filteredBookmarks]) + + const selectPrevWithShift = useCallback(() => { + if (filteredBookmarks.length === 0) return + + if (!selectionMode) { + setSelectionMode(true) + } + + // Select current item before moving + if (selectedIndex >= 0 && selectedIndex < filteredBookmarks.length) { + const currentBookmark = filteredBookmarks[selectedIndex] + setSelectedIds(prev => new Set(prev).add(currentBookmark._id)) + } + + // Move to prev and select it + setSelectedIndex(prev => { + const prevIndex = prev > 0 ? prev - 1 : 0 + if (prevIndex >= 0 && prevIndex < filteredBookmarks.length) { + const prevBookmark = filteredBookmarks[prevIndex] + setSelectedIds(p => new Set(p).add(prevBookmark._id)) + } + return prevIndex + }) + }, [selectionMode, selectedIndex, filteredBookmarks]) + + const handleBulkDelete = useCallback(() => { + if (selectedIds.size === 0) return + + const count = selectedIds.size + try { + bulkDeleteBookmarks(Array.from(selectedIds)) + addToast({ + message: `Deleted ${count} bookmark${count > 1 ? 's' : ''}`, + action: () => { + undo() + }, + actionLabel: 'Undo', + duration: 5000, + }) + exitSelectionMode() + } catch (error) { + console.error('Failed to delete bookmarks:', error) + addToast({ message: 'Failed to delete bookmarks', duration: 3000 }) + } + }, [selectedIds, addToast, exitSelectionMode]) + useEffect(() => { setSelectedIndex(-1) }, [filteredBookmarks.length, filterView, selectedTag, debouncedSearchQuery]) - // Close inline card when view/filter changes + // Close inline card and exit selection mode when view/filter changes useEffect(() => { setIsAddingNew(false) setEditingBookmarkId(null) - }, [filterView, selectedTag, currentView]) + exitSelectionMode() + }, [filterView, selectedTag, currentView, exitSelectionMode]) useEffect(() => { if (selectedIndex >= 0 && selectedItemRef.current) { @@ -236,13 +358,18 @@ export function BookmarkList() { 'shift+?': showHelp, 'j': selectNext, 'k': selectPrev, + 'shift+j': selectNextWithShift, + 'shift+k': selectPrevWithShift, 'enter': openSelected, 'e': editSelected, - 'd': deleteSelected, + 'd': selectionMode && selectedIds.size > 0 ? handleBulkDelete : deleteSelected, 'mod+k': focusSearch, 'q': exitInbox, 'mod+z': handleUndo, 'mod+shift+z': handleRedo, + 'escape': exitSelectionMode, + 'space': toggleSelectCurrent, + 'mod+a': selectAllBookmarks, }) const handleAddNew = openNewBookmarkForm @@ -338,6 +465,8 @@ export function BookmarkList() { onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} onAddNew={handleAddNew} searchInputRef={searchInputRef} + selectionMode={selectionMode} + onToggleSelectionMode={toggleSelectionMode} />
@@ -389,9 +518,12 @@ export function BookmarkList() { ref={index === selectedIndex ? selectedItemRef : null} bookmark={bookmark} isSelected={index === selectedIndex} + isChecked={selectedIds.has(bookmark._id)} + selectionMode={selectionMode} onEdit={handleEdit} onDelete={handleDelete} onTagClick={handleTagClick} + onToggleSelect={toggleSelectBookmark} /> ) )) @@ -412,6 +544,12 @@ export function BookmarkList() { setIsHelpOpen(false)} /> + +
) diff --git a/src/components/bookmarks/FilterBar.jsx b/src/components/bookmarks/FilterBar.jsx index 1fe7448..68f2917 100644 --- a/src/components/bookmarks/FilterBar.jsx +++ b/src/components/bookmarks/FilterBar.jsx @@ -1,4 +1,4 @@ -import { X, ChevronDown, Menu, Search, Plus } from '../ui/Icons' +import { X, ChevronDown, Menu, Search, Plus, ListChecks } from '../ui/Icons' export function FilterBar({ searchQuery, @@ -9,6 +9,8 @@ export function FilterBar({ onToggleSidebar, onAddNew, searchInputRef, + selectionMode, + onToggleSelectionMode, }) { const handleKeyDown = (e) => { if (e.key === 'Enter') { @@ -69,9 +71,23 @@ export function FilterBar({ + + + + + + + ) +} diff --git a/src/components/ui/Icons.jsx b/src/components/ui/Icons.jsx index 72ed99c..f2c03ab 100644 --- a/src/components/ui/Icons.jsx +++ b/src/components/ui/Icons.jsx @@ -17,4 +17,7 @@ export { Smartphone, AlertCircle, Inbox, + Square, + CheckSquare, + ListChecks, } from 'lucide-react' diff --git a/src/services/bookmarks.js b/src/services/bookmarks.js index a3179e3..ed5bf30 100644 --- a/src/services/bookmarks.js +++ b/src/services/bookmarks.js @@ -226,6 +226,33 @@ export function deleteBookmark(id) { console.log('[Bookmarks] Deleted:', id) } +/** + * Delete multiple bookmarks in a single transaction (for undo support) + * @param {string[]} ids - Array of bookmark IDs to delete + * @returns {number} - Number of bookmarks deleted + */ +export function bulkDeleteBookmarks(ids) { + if (!Array.isArray(ids) || ids.length === 0) { + return 0 + } + + const doc = getYdoc() + const bookmarksMap = doc.getMap('bookmarks') + + let deletedCount = 0 + doc.transact(() => { + for (const id of ids) { + if (bookmarksMap.has(id)) { + bookmarksMap.delete(id) + deletedCount++ + } + } + }, LOCAL_ORIGIN) + + console.log('[Bookmarks] Bulk deleted:', deletedCount, 'bookmarks') + return deletedCount +} + /** * Toggle read-later status */