diff --git a/src/components/text-input.tsx b/src/components/text-input.tsx index 74b24e23..443a8005 100644 --- a/src/components/text-input.tsx +++ b/src/components/text-input.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, forwardRef } from 'react' enum TextInputSize { XS = 'xs', @@ -19,31 +19,32 @@ interface TextInputProps { type?: string direction?: 'rtl' | 'ltr' | '' className?: string - ref?: React.RefObject debounce?: boolean debounceTime?: number maxLength?: number size?: TextInputSize } -export function TextInput({ - onChange, - value, - placeholder, - onFocus, - onKeyDown, - disabled = false, - name, - id, - type = 'text', - direction = '', - className = '', - ref, - debounce = false, - debounceTime = 150, - maxLength = 1000, - size = TextInputSize.MD, -}: TextInputProps) { +export const TextInput = forwardRef(function TextInput( + { + onChange, + value, + placeholder, + onFocus, + onKeyDown, + disabled = false, + name, + id, + type = 'text', + direction = '', + className = '', + debounce = false, + debounceTime = 150, + maxLength = 1000, + size = TextInputSize.MD, + }, + ref +) { const sizes: Record = { [TextInputSize.XS]: 'input-xs', [TextInputSize.SM]: 'input-sm', @@ -101,4 +102,4 @@ export function TextInput({ autoComplete="off" /> ) -} +}) diff --git a/src/layouts/bookmark/bookmarks.tsx b/src/layouts/bookmark/bookmarks.tsx index a5cbda15..59658e17 100644 --- a/src/layouts/bookmark/bookmarks.tsx +++ b/src/layouts/bookmark/bookmarks.tsx @@ -3,15 +3,96 @@ import { useBookmarkStore } from '@/context/bookmark.context' import Analytics from '@/analytics' import { callEvent } from '@/common/utils/call-event' import { SyncTarget } from '@/layouts/navbar/sync/sync' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, useMemo } from 'react' import { BookmarkItem } from './components/bookmark-item' import { FolderPath } from './components/folder-path' import { AddBookmarkModal } from './components/modal/add-bookmark.modal' import { BookmarkContextMenu } from './components/modal/bookmark-context-menu' import { EditBookmarkModal } from './components/modal/edit-bookmark.modal' import type { Bookmark, FolderPathItem } from './types/bookmark.types' +import { FolderPasswordModal } from './components/modal/folder-password.modal' + +// --- BookmarkGrid: Extracted for clarity --- +function BookmarkGrid({ + displayedBookmarks, + dragOverIndex, + draggedBookmarkId, + isManageable, + handleBookmarkClick, + handleMenuClick, + handleDragStart, + handleDragOver, + handleDragEnd, + handleDrop, + setShowAddBookmarkModal, + currentFolderIsManageable, +}: { + displayedBookmarks: (Bookmark | null)[] + dragOverIndex: number | null + draggedBookmarkId: string | null + isManageable: (bookmark: Bookmark) => boolean + handleBookmarkClick: ( + bookmark: Bookmark, + e?: React.MouseEvent + ) => void + handleMenuClick: ( + e: React.MouseEvent, + bookmark: Bookmark + ) => void + handleDragStart: (e: React.DragEvent, bookmarkId: string) => void + handleDragOver: (e: React.DragEvent, index: number) => void + handleDragEnd: (e: React.DragEvent) => void + handleDrop: (e: React.DragEvent, index: number) => void + setShowAddBookmarkModal: (show: boolean) => void + currentFolderIsManageable: boolean +}) { + return ( +
+ {displayedBookmarks.map((bookmark, i) => + bookmark ? ( +
+ handleBookmarkClick(bookmark, e)} + onClick={(e) => { + if (e && e.button === 0) handleBookmarkClick(bookmark, e) + }} + canAdd={true} + draggable={isManageable(bookmark)} + isDragging={draggedBookmarkId === bookmark.id} + onDragStart={(e) => handleDragStart(e, bookmark.id)} + onDragOver={(e) => handleDragOver(e, i)} + onMenuClick={ + isManageable(bookmark) + ? (e) => handleMenuClick(e, bookmark) + : undefined + } + onDragEnd={handleDragEnd} + onDrop={(e) => handleDrop(e, i)} + /> +
+ ) : ( + setShowAddBookmarkModal(true)} + canAdd={currentFolderIsManageable} + /> + ) + )} +
+ ) +} export function BookmarksComponent() { + // --- State --- const { bookmarks, getCurrentFolderItems, @@ -21,324 +102,336 @@ export function BookmarksComponent() { setBookmarks, } = useBookmarkStore() - const [showAddBookmarkModal, setShowAddBookmarkModal] = useState(false) - const [showEditBookmarkModal, setShowEditBookmarkModal] = useState(false) + // Modal and selection state + const [modals, setModals] = useState({ + add: false, + edit: false, + folderPassword: false, + }) const [bookmarkToEdit, setBookmarkToEdit] = useState(null) - + const [folderPassword, setFolderPassword] = useState('') + const [folderToOpen, setFolderToOpen] = useState(null) const [selectedBookmark, setSelectedBookmark] = useState(null) - const [contextMenuPos, setContextMenuPos] = useState({ x: 0, y: 0 }) - const [currentFolderId, setCurrentFolderId] = useState(null) - const [folderPath, setFolderPath] = useState([]) - const [currentFolderIsManageable, setCurrentFolderIsManageable] = useState(true) - const [draggedBookmarkId, setDraggedBookmarkId] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) - + const [pendingEditBookmark, setPendingEditBookmark] = useState(null) + const [isPasswordForEdit, setIsPasswordForEdit] = useState(false) const syncTimeoutRef = useRef(null) + // --- Constants --- const BOOKMARKS_PER_ROW = 5 const TOTAL_BOOKMARKS = BOOKMARKS_PER_ROW * 2 - const debouncedSync = useCallback(() => { - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current) - } + // --- Utility functions --- + const isManageable = (bookmark: Bookmark) => + 'isManageable' in bookmark && typeof bookmark.isManageable === 'boolean' + ? bookmark.isManageable + : true + // --- Debounced sync --- + const debouncedSync = useCallback(() => { + if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current) syncTimeoutRef.current = setTimeout(() => { callEvent('startSync', SyncTarget.BOOKMARKS) Analytics.featureUsed('drag-and-drop-bookmark', {}, 'drag') syncTimeoutRef.current = null - }, 1000) // Wait 1 second before triggering sync + }, 1000) }, []) + // --- Effects --- useEffect(() => { const handleClickOutside = () => setSelectedBookmark(null) document.addEventListener('click', handleClickOutside) - return () => { document.removeEventListener('click', handleClickOutside) - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current) - } + if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current) } }, []) - const isManageable = (bookmark: Bookmark) => { - if ('isManageable' in bookmark && typeof bookmark.isManageable === 'boolean') { - return bookmark.isManageable - } - - return true - } - const handleMenuClick = (e: React.MouseEvent, bookmark: Bookmark) => { - e.preventDefault() - if (isManageable(bookmark)) { - setSelectedBookmark(bookmark) - const button = e.currentTarget - if (button) { - const rect = button.getBoundingClientRect() + // --- Handlers --- + const handleMenuClick = useCallback( + (e: React.MouseEvent, bookmark: Bookmark) => { + e.preventDefault() + if (isManageable(bookmark)) { + setSelectedBookmark(bookmark) + const rect = e.currentTarget.getBoundingClientRect() setContextMenuPos({ x: rect.left - 110, y: rect.bottom + 5 }) } - } - } - - const handleEditBookmark = (bookmark: Bookmark) => { - setBookmarkToEdit(bookmark) - setShowEditBookmarkModal(true) - setSelectedBookmark(null) - } - const handleBookmarkClick = (bookmark: Bookmark, e?: React.MouseEvent) => { - if (e) { - e.preventDefault() - } - if (e?.button === 2) return + }, + [] + ) - if (e?.button === 1) { - if (bookmark.type === 'FOLDER') { - openBookmarks(bookmark) - } else { - e.preventDefault() - window.open(bookmark.url) - } + const handleEditBookmark = useCallback((bookmark: Bookmark) => { + if (bookmark.type === 'FOLDER' && bookmark.password) { + setFolderPassword(bookmark.password ?? '') + setPendingEditBookmark(bookmark) + setIsPasswordForEdit(true) + setModals((m) => ({ ...m, folderPassword: true })) + setSelectedBookmark(null) return } + setBookmarkToEdit(bookmark) + setModals((m) => ({ ...m, edit: true })) + setSelectedBookmark(null) + }, []) - if (bookmark.type === 'FOLDER') { + const handleOpenFolder = useCallback( + (bookmark: Bookmark, e?: React.MouseEvent) => { if (e?.ctrlKey || e?.metaKey) { openBookmarks(bookmark) } else { setCurrentFolderId(bookmark.id) - setFolderPath([...folderPath, { id: bookmark.id, title: bookmark.title }]) - + setFolderPath((prev) => [ + ...prev, + { + id: bookmark.id, + title: bookmark.title, + password: bookmark.password, + }, + ]) setCurrentFolderIsManageable(isManageable(bookmark)) } - } else { - if (e?.ctrlKey || e?.metaKey) { - window.open(bookmark.url) + }, + [isManageable] + ) + + const handleBookmarkClick = useCallback( + (bookmark: Bookmark, e?: React.MouseEvent) => { + if (e) e.preventDefault() + if (e?.button === 2) return + if (e?.button === 1) { + if (bookmark.type === 'FOLDER') openBookmarks(bookmark) + else { + e.preventDefault() + window.open(bookmark.url) + } + return + } + if (bookmark.type === 'FOLDER') { + setFolderPassword(bookmark.password ?? '') + if (bookmark.password) { + setFolderToOpen(bookmark) + setModals((m) => ({ ...m, folderPassword: true })) + } else { + handleOpenFolder(bookmark, e) + } } else { - window.location.href = bookmark.url + if (e?.ctrlKey || e?.metaKey) window.open(bookmark.url) + else window.location.href = bookmark.url } - } - } - - const handleDragStart = (e: React.DragEvent, bookmarkId: string) => { - if (bookmarks.find((b) => b.id === bookmarkId && !isManageable(b))) return - - setDraggedBookmarkId(bookmarkId) - e.dataTransfer.effectAllowed = 'move' - - const dragImage = document.createElement('div') - dragImage.style.width = '80px' - dragImage.style.height = '90px' - dragImage.style.opacity = '0.5' - dragImage.style.position = 'absolute' - dragImage.style.top = '-1000px' - document.body.appendChild(dragImage) - - e.dataTransfer.setDragImage(dragImage, 40, 45) + }, + [handleOpenFolder] + ) - setTimeout(() => { - document.body.removeChild(dragImage) - }, 0) - } + const handleDragStart = useCallback( + (e: React.DragEvent, bookmarkId: string) => { + if (bookmarks.find((b) => b.id === bookmarkId && !isManageable(b))) return + setDraggedBookmarkId(bookmarkId) + e.dataTransfer.effectAllowed = 'move' + const dragImage = document.createElement('div') + dragImage.style.width = '80px' + dragImage.style.height = '90px' + dragImage.style.opacity = '0.5' + dragImage.style.position = 'absolute' + dragImage.style.top = '-1000px' + document.body.appendChild(dragImage) + e.dataTransfer.setDragImage(dragImage, 40, 45) + setTimeout(() => { + document.body.removeChild(dragImage) + }, 0) + }, + [bookmarks, isManageable] + ) - const handleDragOver = (e: React.DragEvent, index: number) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - setDragOverIndex(index) - } + const handleDragOver = useCallback( + (e: React.DragEvent, index: number) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDragOverIndex(index) + }, + [] + ) - const handleDragEnd = () => { + const handleDragEnd = useCallback(() => { setDraggedBookmarkId(null) setDragOverIndex(null) - } - - const handleDrop = (e: React.DragEvent, targetIndex: number) => { - e.preventDefault() - - if (!draggedBookmarkId) return - - const currentItems = getCurrentFolderItems(currentFolderId) - const sourceIndex = currentItems.findIndex( - (item) => item.id === draggedBookmarkId - ) + }, []) - if (sourceIndex === -1 || sourceIndex === targetIndex) { + const handleDrop = useCallback( + (e: React.DragEvent, targetIndex: number) => { + e.preventDefault() + if (!draggedBookmarkId) return + const currentItems = getCurrentFolderItems(currentFolderId) + const sourceIndex = currentItems.findIndex( + (item) => item.id === draggedBookmarkId + ) + if (sourceIndex === -1 || sourceIndex === targetIndex) { + setDraggedBookmarkId(null) + setDragOverIndex(null) + return + } + const allBookmarks = [...bookmarks] + const sourceBookmark = currentItems[sourceIndex] + const actualSourceIndex = allBookmarks.findIndex( + (b) => b.id === sourceBookmark.id + ) + const targetBookmark = currentItems[targetIndex] + const actualTargetIndex = allBookmarks.findIndex( + (b) => b.id === targetBookmark.id + ) + if (actualSourceIndex !== -1 && actualTargetIndex !== -1) { + const [movedBookmark] = allBookmarks.splice(actualSourceIndex, 1) + allBookmarks.splice(actualTargetIndex, 0, movedBookmark) + const updatedBookmarks = allBookmarks.map((bookmark) => { + if (bookmark.parentId === currentFolderId) { + const newIndex = allBookmarks.findIndex( + (b) => b.id === bookmark.id + ) + return { ...bookmark, order: newIndex } + } + return bookmark + }) + setBookmarks(updatedBookmarks) + debouncedSync() + } setDraggedBookmarkId(null) setDragOverIndex(null) - return - } - - const allBookmarks = [...bookmarks] - - const sourceBookmark = currentItems[sourceIndex] - const actualSourceIndex = allBookmarks.findIndex( - (b) => b.id === sourceBookmark.id - ) - const targetBookmark = currentItems[targetIndex] - const actualTargetIndex = allBookmarks.findIndex( - (b) => b.id === targetBookmark.id - ) - - if (actualSourceIndex !== -1 && actualTargetIndex !== -1) { - const [movedBookmark] = allBookmarks.splice(actualSourceIndex, 1) - - allBookmarks.splice(actualTargetIndex, 0, movedBookmark) - - const updatedBookmarks = allBookmarks.map((bookmark) => { - if (bookmark.parentId === currentFolderId) { - const newIndex = allBookmarks.findIndex((b) => b.id === bookmark.id) - return { ...bookmark, order: newIndex } - } - return bookmark - }) - - setBookmarks(updatedBookmarks) - - debouncedSync() - } - - setDraggedBookmarkId(null) - setDragOverIndex(null) - } - - const handleNavigate = (folderId: string | null, depth: number) => { - if (depth === -1) { - setFolderPath([]) - setCurrentFolderId(null) - setCurrentFolderIsManageable(true) - return - } - - const newPath = folderPath.slice(0, depth + 1) - setFolderPath(newPath) - setCurrentFolderId(folderId) + }, + [ + draggedBookmarkId, + bookmarks, + getCurrentFolderItems, + currentFolderId, + setBookmarks, + debouncedSync, + ] + ) - if (folderId) { - const folder = bookmarks.find((b) => b.id === folderId) - if (folder) { - setCurrentFolderIsManageable(isManageable(folder)) + const handleNavigate = useCallback( + (folderId: string | null, depth: number) => { + if (depth === -1) { + setFolderPath([]) + setCurrentFolderId(null) + setCurrentFolderIsManageable(true) + return } - } else { - setCurrentFolderIsManageable(true) - } - } + const newPath = folderPath.slice(0, depth + 1) + setFolderPath(newPath) + setCurrentFolderId(folderId) + if (folderId) { + const folder = bookmarks.find((b) => b.id === folderId) + if (folder) setCurrentFolderIsManageable(isManageable(folder)) + } else { + setCurrentFolderIsManageable(true) + } + }, + [folderPath, bookmarks, isManageable] + ) function openBookmarks(bookmark: Bookmark) { const children = getCurrentFolderItems(bookmark.id) const bookmarks = children.filter((b) => b.type === 'BOOKMARK') - for (const b of bookmarks) { - window.open(b.url) - } + for (const b of bookmarks) window.open(b.url) } async function onOpenInNewTab(bookmark: Bookmark) { - if (bookmark?.type === 'FOLDER') { - openBookmarks(bookmark) - } - - if (bookmark && bookmark.type === 'BOOKMARK') { - window.open(bookmark.url) - } - + if (bookmark?.type === 'FOLDER') openBookmarks(bookmark) + if (bookmark && bookmark.type === 'BOOKMARK') window.open(bookmark.url) setSelectedBookmark(null) } + // --- Derived values --- const currentFolderItems = getCurrentFolderItems(currentFolderId) - - const displayedBookmarks = currentFolderItems - .slice(0, TOTAL_BOOKMARKS) - .concat( - new Array( - Math.max( - 0, - TOTAL_BOOKMARKS - - currentFolderItems.length - - (currentFolderIsManageable ? 1 : 0) - ) - ).fill(null) - ) - .concat( - //@ts-ignore - currentFolderIsManageable && currentFolderItems.length < TOTAL_BOOKMARKS - ? [null] - : [] - ) - .slice(0, TOTAL_BOOKMARKS) - + const displayedBookmarks = useMemo(() => { + return currentFolderItems + .slice(0, TOTAL_BOOKMARKS) + .concat( + new Array( + Math.max( + 0, + TOTAL_BOOKMARKS - + currentFolderItems.length - + (currentFolderIsManageable ? 1 : 0) + ) + ).fill(null) + ) + .concat( + //@ts-ignore + currentFolderIsManageable && currentFolderItems.length < TOTAL_BOOKMARKS + ? [null] + : [] + ) + .slice(0, TOTAL_BOOKMARKS) + }, [currentFolderItems, TOTAL_BOOKMARKS, currentFolderIsManageable]) + + // --- Render --- return ( <> -
+ setModals((m) => ({ ...m, add: show })) } - > - {' '} - {displayedBookmarks.map((bookmark, i) => - bookmark ? ( -
- handleBookmarkClick(bookmark, e)} - onClick={(e) => { - if (e?.button === 0) handleBookmarkClick(bookmark, e) - }} - canAdd={true} - draggable={isManageable(bookmark)} - isDragging={draggedBookmarkId === bookmark.id} - onDragStart={(e) => handleDragStart(e, bookmark.id)} - onDragOver={(e) => handleDragOver(e, i)} - onMenuClick={ - isManageable(bookmark) - ? (e) => handleMenuClick(e, bookmark) - : undefined - } - onDragEnd={handleDragEnd} - onDrop={(e) => handleDrop(e, i)} - /> -
- ) : ( - setShowAddBookmarkModal(true)} - canAdd={currentFolderIsManageable} - /> - ) - )} - {selectedBookmark && ( - { - deleteBookmark(selectedBookmark.id) - setSelectedBookmark(null) - }} - onEdit={() => handleEditBookmark(selectedBookmark)} - onOpenInNewTab={() => onOpenInNewTab(selectedBookmark)} - /> - )} -
+ currentFolderIsManageable={currentFolderIsManageable} + /> + {selectedBookmark && ( + { + deleteBookmark(selectedBookmark.id) + setSelectedBookmark(null) + }} + onEdit={() => handleEditBookmark(selectedBookmark)} + onOpenInNewTab={() => onOpenInNewTab(selectedBookmark)} + /> + )}
+ { + if (isPasswordForEdit && pendingEditBookmark) { + setBookmarkToEdit(pendingEditBookmark) + setModals((m) => ({ ...m, edit: true, folderPassword: false })) + setPendingEditBookmark(null) + setIsPasswordForEdit(false) + } else if (folderToOpen) { + handleOpenFolder(folderToOpen) + setModals((m) => ({ ...m, folderPassword: false })) + } + }} + folderPassword={folderPassword} + isOpen={modals.folderPassword} + onClose={() => { + setModals((m) => ({ ...m, folderPassword: false })) + setPendingEditBookmark(null) + setIsPasswordForEdit(false) + }} + /> setShowAddBookmarkModal(false)} + isOpen={modals.add} + onClose={() => setModals((m) => ({ ...m, add: false }))} onAdd={(bookmark) => addBookmark(bookmark)} parentId={currentFolderId} /> {bookmarkToEdit && ( setShowEditBookmarkModal(false)} + isOpen={modals.edit} + onClose={() => setModals((m) => ({ ...m, edit: false }))} onSave={(bookmark) => editBookmark(bookmark)} bookmark={bookmarkToEdit} /> diff --git a/src/layouts/bookmark/components/bookmark-folder.tsx b/src/layouts/bookmark/components/bookmark-folder.tsx index e139cc2f..db1e89dd 100644 --- a/src/layouts/bookmark/components/bookmark-folder.tsx +++ b/src/layouts/bookmark/components/bookmark-folder.tsx @@ -2,6 +2,7 @@ import { addOpacityToColor } from '@/common/color' import Tooltip from '@/components/toolTip' import { useState } from 'react' import { FaFolder, FaFolderOpen } from 'react-icons/fa' +import { LuFolderLock } from 'react-icons/lu' import { SlOptions } from 'react-icons/sl' import type { Bookmark } from '../types/bookmark.types' import { RenderStickerPattern } from './bookmark/bookmark-sticker' @@ -37,7 +38,11 @@ export function FolderBookmarkItem({ const displayIcon = bookmark.customImage || (isHovered ? ( - + bookmark.password ? ( + + ) : ( + + ) ) : ( )) @@ -58,14 +63,18 @@ export function FolderBookmarkItem({ onDrop={onDrop} className={`relative ${isDragging ? 'opacity-50' : ''}`} > - + + + + {errorMessage && ( +

+ {errorMessage} +

+ )} + + ) +} diff --git a/src/layouts/bookmark/components/shared.tsx b/src/layouts/bookmark/components/shared.tsx index db8fe21a..69bbfdfd 100644 --- a/src/layouts/bookmark/components/shared.tsx +++ b/src/layouts/bookmark/components/shared.tsx @@ -16,6 +16,7 @@ export interface BookmarkFormData { customTextColor: string sticker: string touched?: boolean + password?: string } export function IconSourceSelector({ diff --git a/src/layouts/bookmark/types/bookmark.types.ts b/src/layouts/bookmark/types/bookmark.types.ts index ea5e5389..c96e83d4 100644 --- a/src/layouts/bookmark/types/bookmark.types.ts +++ b/src/layouts/bookmark/types/bookmark.types.ts @@ -15,9 +15,11 @@ export interface Bookmark { customTextColor?: string sticker?: string order?: number + password?: string } export interface FolderPathItem { id: string title: string + password?: string; }