Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 65 additions & 29 deletions src/components/bookmarks/BookmarkItem.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<div
ref={ref}
className={`group flex items-center gap-3 px-3 py-2.5 rounded-md transition-all duration-200 cursor-default ${
isSelected
? 'bg-accent ring-1 ring-ring'
: 'hover:bg-accent/50'
onClick={handleClick}
className={`group flex items-center gap-3 px-3 py-2.5 rounded-md transition-all duration-200 ${
selectionMode ? 'cursor-pointer' : 'cursor-default'
} ${
isChecked
? 'bg-primary/10 ring-1 ring-primary/30'
: isSelected
? 'bg-accent ring-1 ring-ring'
: 'hover:bg-accent/50'
}`}
>
<img
src={faviconUrl}
alt=""
className="w-4 h-4 rounded-[3px] flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
onError={(e) => { e.target.style.opacity = 0 }}
{selectionMode && (
<button
onClick={(e) => {
e.stopPropagation()
onToggleSelect?.(bookmark._id)
}}
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
{isChecked ? (
<CheckSquare className="w-4 h-4 text-primary" strokeWidth={1.5} />
) : (
<Square className="w-4 h-4" strokeWidth={1.5} />
)}
</button>
)}

<img
src={faviconUrl}
alt=""
className="w-4 h-4 rounded-[3px] flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
onError={(e) => { e.target.style.opacity = 0 }}
/>

<div className="flex-1 min-w-0 overflow-hidden">
Expand All @@ -38,21 +69,24 @@ 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}
</a>
<span className="text-xs text-muted-foreground truncate flex-shrink-0 font-normal">{domain}</span>
</div>

{tags && tags.length > 0 && (
<div className="flex gap-1.5 mt-1">
{tags.map((tag) => (
<span
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"
>
Expand All @@ -63,22 +97,24 @@ export const BookmarkItem = forwardRef(function BookmarkItem(
)}
</div>

<div className="flex items-center gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => onEdit(bookmark)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
<button
onClick={() => onDelete(bookmark._id)}
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title="Delete"
>
<Trash className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</div>
{!selectionMode && (
<div className="flex items-center gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => onEdit(bookmark)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
<button
onClick={() => onDelete(bookmark._id)}
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title="Delete"
>
<Trash className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</div>
)}
</div>
)
})
144 changes: 141 additions & 3 deletions src/components/bookmarks/BookmarkList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ 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'
import { PackageOpen } from '../ui/Icons'
import {
getAllBookmarks,
deleteBookmark,
bulkDeleteBookmarks,
} from '../../services/bookmarks'

export function BookmarkList() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -338,6 +465,8 @@ export function BookmarkList() {
onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
onAddNew={handleAddNew}
searchInputRef={searchInputRef}
selectionMode={selectionMode}
onToggleSelectionMode={toggleSelectionMode}
/>

<div className="flex-1 overflow-y-auto bg-background">
Expand Down Expand Up @@ -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}
/>
)
))
Expand All @@ -412,6 +544,12 @@ export function BookmarkList() {

<HelpModal isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />

<SelectionActionBar
selectedCount={selectedIds.size}
onDelete={handleBulkDelete}
onCancel={exitSelectionMode}
/>

<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
)
Expand Down
20 changes: 18 additions & 2 deletions src/components/bookmarks/FilterBar.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +9,8 @@ export function FilterBar({
onToggleSidebar,
onAddNew,
searchInputRef,
selectionMode,
onToggleSelectionMode,
}) {
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
Expand Down Expand Up @@ -69,9 +71,23 @@ export function FilterBar({
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" strokeWidth={1.5} />
</div>

<button
onClick={onToggleSelectionMode}
className={`h-9 px-3 rounded-md font-medium text-sm inline-flex items-center gap-1.5 transition-colors ${
selectionMode
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
aria-label={selectionMode ? 'Exit selection mode' : 'Select bookmarks'}
title={selectionMode ? 'Exit selection mode' : 'Select multiple bookmarks'}
>
<ListChecks className="w-4 h-4" strokeWidth={1.5} />
<span className="hidden sm:inline">{selectionMode ? 'Done' : 'Select'}</span>
</button>

<button
onClick={onAddNew}
className="ml-auto h-9 px-4 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm font-medium text-sm inline-flex items-center gap-1.5 transition-colors"
className="h-9 px-4 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm font-medium text-sm inline-flex items-center gap-1.5 transition-colors"
aria-label="Add bookmark"
>
<Plus className="w-4 h-4" strokeWidth={2} />
Expand Down
Loading