From effee3656903accce2ff95ecb25916992143083b Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Tue, 21 Jul 2026 08:55:36 +0330 Subject: [PATCH 1/4] feat(bookmarks): add browser bookmarks import functionality --- src/layouts/bookmark/bookmarks.tsx | 13 + .../components/bookmark-icon.picker.tsx | 2 +- .../components/bookmark-suggestions.tsx | 6 +- .../components/modal/add-bookmark.modal.tsx | 64 +-- .../modal/import-browser-bookmarks.modal.tsx | 394 ++++++++++++++++++ .../bookmark/context/bookmark.context.tsx | 103 ++++- src/layouts/bookmark/types/bookmark.types.ts | 7 + 7 files changed, 556 insertions(+), 33 deletions(-) create mode 100644 src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx diff --git a/src/layouts/bookmark/bookmarks.tsx b/src/layouts/bookmark/bookmarks.tsx index 4d442a1e..33d7fef3 100644 --- a/src/layouts/bookmark/bookmarks.tsx +++ b/src/layouts/bookmark/bookmarks.tsx @@ -10,6 +10,7 @@ import { useState } from 'react' import Analytics from '@/analytics' import { FolderHeader } from './components/folder-header' import { AddBookmarkModal } from './components/modal/add-bookmark.modal' +import { ImportBrowserBookmarksModal } from './components/modal/import-browser-bookmarks.modal' import type { Bookmark, FolderPathItem } from './types/bookmark.types' import { BookmarkGrid } from './bookmark-grid' import { useBookmarkStore } from './context/bookmark.context' @@ -30,6 +31,7 @@ export function BookmarksList() { const { isAuthenticated } = useAuth() const [showAddBookmarkModal, setShowAddBookmarkModal] = useState(false) + const [showImportBookmarksModal, setShowImportBookmarksModal] = useState(false) const { mutateAsync: updateOrder } = useUpdateBookmarkOrder() const [folderPath, setFolderPath] = useState([]) @@ -206,9 +208,20 @@ export function BookmarksList() { addBookmark(bookmark, () => setShowAddBookmarkModal(false)) } parentId={currentFolderId} + onOpenImport={() => { + setShowAddBookmarkModal(false) + setShowImportBookmarksModal(true) + }} /> ) )} + {showImportBookmarksModal && ( + setShowImportBookmarksModal(false)} + parentId={currentFolderId} + /> + )} ) } diff --git a/src/layouts/bookmark/components/bookmark-icon.picker.tsx b/src/layouts/bookmark/components/bookmark-icon.picker.tsx index a6b69aac..fa9024a4 100644 --- a/src/layouts/bookmark/components/bookmark-icon.picker.tsx +++ b/src/layouts/bookmark/components/bookmark-icon.picker.tsx @@ -71,7 +71,7 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} - className={`relative w-12 h-12 flex items-center justify-center cursor-pointer border-2 rounded-xl transition + className={`relative w-12 h-10 flex items-center justify-center cursor-pointer border rounded-xl transition ${isDragging ? 'border-blue-400' : 'border-base-300'} `} > diff --git a/src/layouts/bookmark/components/bookmark-suggestions.tsx b/src/layouts/bookmark/components/bookmark-suggestions.tsx index d56caa27..3958664d 100644 --- a/src/layouts/bookmark/components/bookmark-suggestions.tsx +++ b/src/layouts/bookmark/components/bookmark-suggestions.tsx @@ -23,13 +23,13 @@ export function BookmarkSuggestions({ onSelect }: BookmarkSuggestionsProps) {
onSelect(suggestion)} - className="p-2 flex flex-col items-center gap-y-0.5 text-center transition-colors duration-200 bg-content hover:!bg-base-300/75 border border-base-300/40 rounded-xl cursor-pointer" + className="p-1 flex flex-col items-center justify-center text-center transition-colors duration-200 bg-content hover:!bg-base-300/75 h-12 border border-base-300/40 rounded-xl cursor-pointer" > -
+
{suggestion.title} { const target = e.target as HTMLImageElement target.style.display = 'none' diff --git a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx index a29d5813..3ec6a727 100644 --- a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Button } from '@/components/button/button' import Modal from '@/components/modal' import { TextInput } from '@/components/text-input' +import { Icon } from '@/src/icons' import type { BookmarkType } from '../../types/bookmark.types' import { BookmarkSuggestions } from '../bookmark-suggestions' import { ShowAdvancedButton, TypeSelector } from '../shared' @@ -14,6 +15,7 @@ interface AddBookmarkModalProps { onClose: () => void onAdd: (bookmark: BookmarkCreateFormFields) => void parentId: string | null + onOpenImport?: () => void } export interface BookmarkCreateFormFields { @@ -50,6 +52,7 @@ export function AddBookmarkModal({ onClose, onAdd, parentId = null, + onOpenImport, }: AddBookmarkModalProps) { const [type, setType] = useState('BOOKMARK') const [showAdvanced, setShowAdvanced] = useState(false) @@ -181,17 +184,16 @@ export function AddBookmarkModal({
-
-
- updateFormData('icon', value)} - value={formData.icon} - url={formData.url} - /> -
+ + +
updateFormData('title', v)} className={ - 'mt-2 w-full px-4 py-3 text-right rounded-lg transition-all duration-200 ' + 'w-full px-4 py-3 text-right rounded-lg transition-all duration-200 ' } /> -
- {type === 'BOOKMARK' && ( - handleUrlChange(v)} - className={ - 'mt-2 w-full px-4 py-3 text-right absolute rounded-lg transition-all duration-300' - } - /> - )} -
+ + updateFormData('icon', value)} + value={formData.icon} + url={formData.url} + /> +
+
{type === 'BOOKMARK' && ( - + handleUrlChange(v)} + className={ + 'mt-2 w-full px-4 py-3 text-right absolute rounded-lg transition-all duration-300' + } + /> )}
+ {type === 'BOOKMARK' && ( + + )} void + parentId: string | null + onImported?: () => void +} + +function collectDescendantIds(node: FetchedBrowserBookmark, acc: Set) { + acc.add(node.id) + node.children?.forEach((child) => collectDescendantIds(child, acc)) +} + +function buildImportNodes( + nodes: FetchedBrowserBookmark[], + selectedIds: Set +): BrowserImportNode[] { + const result: BrowserImportNode[] = [] + + for (const node of nodes) { + if (selectedIds.has(node.id)) { + if (node.type === 'FOLDER') { + result.push({ + title: node.title || 'پوشه بدون عنوان', + type: 'FOLDER', + url: null, + children: node.children + ? buildImportNodes(node.children, selectedIds) + : [], + }) + } else if (node.url) { + result.push({ + title: node.title || node.url, + type: 'BOOKMARK', + url: node.url, + }) + } + } else if (node.children?.length) { + result.push(...buildImportNodes(node.children, selectedIds)) + } + } + + return result +} + +function countSelectedBookmarks( + nodes: FetchedBrowserBookmark[], + selectedIds: Set +): number { + let count = 0 + for (const node of nodes) { + if (selectedIds.has(node.id) && node.type === 'BOOKMARK') count++ + if (node.children?.length) + count += countSelectedBookmarks(node.children, selectedIds) + } + return count +} + +interface TreeNodeProps { + node: FetchedBrowserBookmark + depth: number + selectedIds: Set + expandedIds: Set + onToggleSelect: (node: FetchedBrowserBookmark) => void + onToggleExpand: (id: string) => void +} + +function TreeNode({ + node, + depth, + selectedIds, + expandedIds, + onToggleSelect, + onToggleExpand, +}: TreeNodeProps) { + const isFolder = node.type === 'FOLDER' + const isExpanded = expandedIds.has(node.id) + const isChecked = selectedIds.has(node.id) + + const descendantIds = useMemo(() => { + const acc = new Set() + if (isFolder) collectDescendantIds(node, acc) + acc.delete(node.id) + return acc + }, [node, isFolder]) + + const selectedDescendantsCount = isFolder + ? [...descendantIds].filter((id) => selectedIds.has(id)).length + : 0 + const isIndeterminate = + isFolder && + !isChecked && + selectedDescendantsCount > 0 && + selectedDescendantsCount < descendantIds.size + + return ( +
+
onToggleSelect(node)} + > + {isFolder ? ( + + ) : ( + + )} + + + {(isChecked || isIndeterminate) && ( + + )} + + + {isFolder ? ( + + ) : ( + + )} + + + {node.title || 'بدون عنوان'} + +
+ + {isFolder && isExpanded && node.children && node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +export function ImportBrowserBookmarksModal({ + isOpen, + onClose, + parentId, + onImported, +}: ImportBrowserBookmarksModalProps) { + const { browserBookmarksEnabled, setBrowserBookmarksEnabled } = useGeneralSetting() + const { importBrowserBookmarks } = useBookmarkStore() + + const [rootNodes, setRootNodes] = useState([]) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [expandedIds, setExpandedIds] = useState>(new Set()) + const [isLoadingTree, setIsLoadingTree] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) + + useEffect(() => { + if (!isOpen) return + + setSelectedIds(new Set()) + setProgress(null) + }, [isOpen]) + + useEffect(() => { + if (!isOpen || !browserBookmarksEnabled) return + + let cancelled = false + async function fetchTree() { + setIsLoadingTree(true) + const tree = await getBrowserBookmarks({ asTree: true }) + if (cancelled) return + + const initialNodes = tree[0]?.children?.length ? tree[0].children : tree + setRootNodes(initialNodes) + setExpandedIds(new Set(initialNodes.map((n) => n.id))) + setIsLoadingTree(false) + } + fetchTree() + + return () => { + cancelled = true + } + }, [isOpen, browserBookmarksEnabled]) + + const handlePermission = () => { + Analytics.event('browser_bookmark_import_permission_clicked') + setBrowserBookmarksEnabled(true) + } + + const handleToggleExpand = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const handleToggleSelect = (node: FetchedBrowserBookmark) => { + setSelectedIds((prev) => { + const next = new Set(prev) + const ids = new Set() + collectDescendantIds(node, ids) + + const shouldSelect = !next.has(node.id) + if (shouldSelect) { + ids.forEach((id) => next.add(id)) + } else { + ids.forEach((id) => next.delete(id)) + } + return next + }) + } + + const selectedBookmarkCount = useMemo( + () => countSelectedBookmarks(rootNodes, selectedIds), + [rootNodes, selectedIds] + ) + + const handleImport = async () => { + const importNodes = buildImportNodes(rootNodes, selectedIds) + if (importNodes.length === 0) { + showToast('حداقل یک بوکمارک را انتخاب کنید.', 'error') + return + } + + setIsImporting(true) + setProgress({ done: 0, total: 0 }) + + const { successCount, failCount } = await importBrowserBookmarks( + importNodes, + parentId, + (done, total) => setProgress({ done, total }) + ) + + setIsImporting(false) + + if (successCount > 0) { + showToast( + failCount > 0 + ? `${successCount} مورد درون‌ریزی شد، ${failCount} مورد ناموفق بود.` + : `${successCount} مورد با موفقیت درون‌ریزی شد.`, + failCount > 0 ? 'warning' : 'success' + ) + onImported?.() + onClose() + } else { + showToast('درون‌ریزی بوکمارک‌ها ناموفق بود.', 'error') + } + } + + return ( + + {!browserBookmarksEnabled ? ( +
+
+ +
+

دسترسی به بوکمارک‌ها

+

+ برای درون‌ریزی بوکمارک‌های مرورگر، نیاز به دسترسی شما داریم. +

+ +
+ ) : ( +
+
+ + {selectedBookmarkCount > 0 + ? `${selectedBookmarkCount} بوکمارک انتخاب شده` + : 'موردی انتخاب نشده'} + +
+ +
+ {isLoadingTree ? ( +
+ +
+ ) : rootNodes.length > 0 ? ( + rootNodes.map((node) => ( + + )) + ) : ( +
+ بوکمارکی در مرورگر شما یافت نشد. +
+ )} +
+ +
+ + +
+
+ )} +
+ ) +} diff --git a/src/layouts/bookmark/context/bookmark.context.tsx b/src/layouts/bookmark/context/bookmark.context.tsx index 1232faf2..5e6a3716 100644 --- a/src/layouts/bookmark/context/bookmark.context.tsx +++ b/src/layouts/bookmark/context/bookmark.context.tsx @@ -3,7 +3,7 @@ import React, { createContext, useEffect, useState } from 'react' import Analytics from '@/analytics' import { getFromStorage, setToStorage } from '@/common/storage' import { callEvent, listenEvent } from '@/common/utils/call-event' -import type { Bookmark } from '@/layouts/bookmark/types/bookmark.types' +import type { Bookmark, BrowserImportNode } from '@/layouts/bookmark/types/bookmark.types' import { safeAwait } from '@/services/api' import { useRemoveBookmark } from '@/services/hooks/bookmark/remove-bookmark.hook' import { translateError } from '@/utils/translate-error' @@ -26,6 +26,11 @@ export interface BookmarkStoreContext { setBookmarks: (bookmarks: Bookmark[]) => void getCurrentFolderItems: (parentId: string | null) => Bookmark[] addBookmark: (bookmark: BookmarkCreateFormFields, cb: () => void) => Promise + importBrowserBookmarks: ( + nodes: BrowserImportNode[], + parentId: string | null, + onProgress?: (done: number, total: number) => void + ) => Promise<{ successCount: number; failCount: number }> editBookmark: (bookmark: BookmarkUpdateFormFields, cb: () => void) => void deleteBookmark: (id: string, cb: () => void) => void currentFolderId: string | null @@ -37,6 +42,7 @@ const bookmarkContext = createContext({ setBookmarks: () => {}, getCurrentFolderItems: () => [], addBookmark: async () => {}, + importBrowserBookmarks: async () => ({ successCount: 0, failCount: 0 }), editBookmark: () => {}, deleteBookmark: () => {}, currentFolderId: null, @@ -215,6 +221,100 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ } } + const importBrowserBookmarks = async ( + nodes: BrowserImportNode[], + parentId: string | null, + onProgress?: (done: number, total: number) => void + ): Promise<{ successCount: number; failCount: number }> => { + if (!isAuthenticated) { + showToast('برای درون‌ریزی بوکمارک‌ها باید وارد شوید.', 'error') + return { successCount: 0, failCount: 0 } + } + + const countNodes = (list: BrowserImportNode[]): number => + list.reduce( + (sum, node) => sum + 1 + (node.children ? countNodes(node.children) : 0), + 0 + ) + const total = countNodes(nodes) + + let done = 0 + let successCount = 0 + let failCount = 0 + + let resolvedParentId = parentId + if (parentId && validate(parentId)) { + const parentBookmark = bookmarks?.find( + (b) => b.id === parentId || b.onlineId === parentId + ) + if (parentBookmark?.onlineId) { + resolvedParentId = parentBookmark.onlineId + } + } + + const orderCounters = new Map() + const getNextOrder = (folderId: string | null) => { + const key = folderId || 'root' + if (!orderCounters.has(key)) { + const items = getCurrentFolderItems(folderId) + const maxOrder = items.reduce( + (max, item) => Math.max(max, item.order || 0), + -1 + ) + orderCounters.set(key, maxOrder + 1) + } + const next = orderCounters.get(key) as number + orderCounters.set(key, next + 1) + return next + } + + const importNode = async ( + node: BrowserImportNode, + targetParentId: string | null + ) => { + const [err, created] = await safeAwait( + addBookmarkAsync({ + order: getNextOrder(targetParentId), + parentId: targetParentId, + title: node.title, + customBackground: null, + customTextColor: null, + sticker: null, + type: node.type, + url: node.type === 'BOOKMARK' ? node.url : null, + icon: null, + }) + ) + + done += 1 + onProgress?.(done, total) + + if (err || !created) { + failCount += 1 + return + } + + successCount += 1 + + if (node.type === 'FOLDER' && node.children?.length) { + for (const child of node.children) { + await importNode(child, created.id) + } + } + } + + for (const node of nodes) { + await importNode(node, resolvedParentId) + } + + if (successCount > 0) { + await refetch() + Analytics.event('import_browser_bookmarks') + } + + return { successCount, failCount } + } + const editBookmark = async (input: BookmarkUpdateFormFields, cb: () => void) => { if (!isAuthenticated) return @@ -311,6 +411,7 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ setBookmarks, getCurrentFolderItems, addBookmark: addBookmark as any, + importBrowserBookmarks, editBookmark, deleteBookmark, currentFolderId, diff --git a/src/layouts/bookmark/types/bookmark.types.ts b/src/layouts/bookmark/types/bookmark.types.ts index 10773388..082116b4 100644 --- a/src/layouts/bookmark/types/bookmark.types.ts +++ b/src/layouts/bookmark/types/bookmark.types.ts @@ -23,3 +23,10 @@ export interface FolderPathItem { id: string title: string } + +export interface BrowserImportNode { + title: string + type: BookmarkType + url: string | null + children?: BrowserImportNode[] +} From 7acc204961c3e46b5d42dc72a050a0226d78c2b8 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 22 Jul 2026 12:06:54 +0330 Subject: [PATCH 2/4] feat(bookmark): limit browser import to 50 items and use bulk API --- .../modal/import-browser-bookmarks.modal.tsx | 120 +++++++++++------- .../constants/browser-import.constant.ts | 1 + .../bookmark/context/bookmark.context.tsx | 110 ++++++---------- .../bookmark/import-browser-bookmarks.hook.ts | 48 +++++++ 4 files changed, 162 insertions(+), 117 deletions(-) create mode 100644 src/layouts/bookmark/constants/browser-import.constant.ts create mode 100644 src/services/hooks/bookmark/import-browser-bookmarks.hook.ts diff --git a/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx b/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx index 96ee729c..b6ac8786 100644 --- a/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx +++ b/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx @@ -7,6 +7,7 @@ import { Icon } from '@/src/icons' import { Button } from '@/components/button/button' import Modal from '@/components/modal' import { useBookmarkStore } from '../../context/bookmark.context' +import { MAX_BROWSER_IMPORT_ITEMS } from '../../constants/browser-import.constant' import type { BrowserImportNode } from '../../types/bookmark.types' import { type FetchedBrowserBookmark, @@ -57,15 +58,11 @@ function buildImportNodes( return result } -function countSelectedBookmarks( - nodes: FetchedBrowserBookmark[], - selectedIds: Set -): number { +function countAllNodes(nodes: FetchedBrowserBookmark[]): number { let count = 0 for (const node of nodes) { - if (selectedIds.has(node.id) && node.type === 'BOOKMARK') count++ - if (node.children?.length) - count += countSelectedBookmarks(node.children, selectedIds) + count += 1 + if (node.children?.length) count += countAllNodes(node.children) } return count } @@ -197,13 +194,11 @@ export function ImportBrowserBookmarksModal({ const [expandedIds, setExpandedIds] = useState>(new Set()) const [isLoadingTree, setIsLoadingTree] = useState(false) const [isImporting, setIsImporting] = useState(false) - const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) useEffect(() => { if (!isOpen) return setSelectedIds(new Set()) - setProgress(null) }, [isOpen]) useEffect(() => { @@ -243,24 +238,59 @@ export function ImportBrowserBookmarksModal({ const handleToggleSelect = (node: FetchedBrowserBookmark) => { setSelectedIds((prev) => { - const next = new Set(prev) const ids = new Set() collectDescendantIds(node, ids) - const shouldSelect = !next.has(node.id) + const shouldSelect = !prev.has(node.id) + if (shouldSelect) { - ids.forEach((id) => next.add(id)) - } else { - ids.forEach((id) => next.delete(id)) + const newIds = [...ids].filter((id) => !prev.has(id)) + if (prev.size + newIds.length > MAX_BROWSER_IMPORT_ITEMS) { + showToast( + `حداکثر ${MAX_BROWSER_IMPORT_ITEMS} مورد در هر بار قابل انتخاب است.`, + 'error' + ) + return prev + } + + const next = new Set(prev) + newIds.forEach((id) => next.add(id)) + return next } + + const next = new Set(prev) + ids.forEach((id) => next.delete(id)) return next }) } - const selectedBookmarkCount = useMemo( - () => countSelectedBookmarks(rootNodes, selectedIds), - [rootNodes, selectedIds] - ) + const handleSelectAll = () => { + const totalAvailable = countAllNodes(rootNodes) + + if (totalAvailable <= MAX_BROWSER_IMPORT_ITEMS) { + const all = new Set() + rootNodes.forEach((n) => collectDescendantIds(n, all)) + setSelectedIds(all) + return + } + + const capped = new Set() + const visit = (nodes: FetchedBrowserBookmark[]) => { + for (const node of nodes) { + if (capped.size >= MAX_BROWSER_IMPORT_ITEMS) return + capped.add(node.id) + if (node.children?.length) visit(node.children) + } + } + visit(rootNodes) + setSelectedIds(capped) + showToast( + `تعداد بوکمارک‌های شما بیشتر از حد مجاز است؛ فقط ${MAX_BROWSER_IMPORT_ITEMS} مورد اول انتخاب شد.`, + 'warning' + ) + } + + const handleClearAll = () => setSelectedIds(new Set()) const handleImport = async () => { const importNodes = buildImportNodes(rootNodes, selectedIds) @@ -270,27 +300,17 @@ export function ImportBrowserBookmarksModal({ } setIsImporting(true) - setProgress({ done: 0, total: 0 }) - const { successCount, failCount } = await importBrowserBookmarks( - importNodes, - parentId, - (done, total) => setProgress({ done, total }) - ) + const result = await importBrowserBookmarks(importNodes, parentId) setIsImporting(false) - if (successCount > 0) { - showToast( - failCount > 0 - ? `${successCount} مورد درون‌ریزی شد، ${failCount} مورد ناموفق بود.` - : `${successCount} مورد با موفقیت درون‌ریزی شد.`, - failCount > 0 ? 'warning' : 'success' - ) + if (result && result.importedCount > 0) { + showToast(`${result.importedCount} مورد با موفقیت درون‌ریزی شد.`, 'success') onImported?.() onClose() - } else { - showToast('درون‌ریزی بوکمارک‌ها ناموفق بود.', 'error') + } else if (result) { + showToast('موردی برای درون‌ریزی یافت نشد.', 'error') } } @@ -326,10 +346,26 @@ export function ImportBrowserBookmarksModal({
- {selectedBookmarkCount > 0 - ? `${selectedBookmarkCount} بوکمارک انتخاب شده` - : 'موردی انتخاب نشده'} + {selectedIds.size > 0 + ? `${selectedIds.size} از ${MAX_BROWSER_IMPORT_ITEMS} مورد انتخاب شده` + : `حداکثر ${MAX_BROWSER_IMPORT_ITEMS} مورد قابل انتخاب است`} +
+ + +
@@ -365,7 +401,7 @@ export function ImportBrowserBookmarksModal({ onClick={onClose} size="md" disabled={isImporting} - className="btn btn-circle bg-base-300! hover:bg-error/10! text-muted hover:text-error! px-8 border-none shadow-none rounded-xl transition-colors duration-300 ease-in-out" + className="btn btn-circle !bg-base-300 hover:!bg-error/10 text-muted hover:!text-error px-8 border-none shadow-none rounded-xl transition-colors duration-300 ease-in-out" > انصراف @@ -373,16 +409,12 @@ export function ImportBrowserBookmarksModal({ onClick={handleImport} size="md" isPrimary - disabled={selectedBookmarkCount === 0 || isImporting} + disabled={selectedIds.size === 0 || isImporting} loading={isImporting} loadingText={ - - {progress && progress.total > 0 - ? `در حال درون‌ریزی... (${progress.done}/${progress.total})` - : 'در حال آماده‌سازی...'} - + در حال درون‌ریزی... } - className="btn btn-circle w-fit! px-8 border-none shadow-none text-secondary rounded-xl transition-colors duration-300 ease-in-out" + className="btn btn-circle !w-fit px-8 border-none shadow-none text-secondary rounded-xl transition-colors duration-300 ease-in-out" > درون‌ریزی diff --git a/src/layouts/bookmark/constants/browser-import.constant.ts b/src/layouts/bookmark/constants/browser-import.constant.ts new file mode 100644 index 00000000..891f9113 --- /dev/null +++ b/src/layouts/bookmark/constants/browser-import.constant.ts @@ -0,0 +1 @@ +export const MAX_BROWSER_IMPORT_ITEMS = 50 diff --git a/src/layouts/bookmark/context/bookmark.context.tsx b/src/layouts/bookmark/context/bookmark.context.tsx index 5e6a3716..76ef2a76 100644 --- a/src/layouts/bookmark/context/bookmark.context.tsx +++ b/src/layouts/bookmark/context/bookmark.context.tsx @@ -9,6 +9,8 @@ import { useRemoveBookmark } from '@/services/hooks/bookmark/remove-bookmark.hoo import { translateError } from '@/utils/translate-error' import { useAuth } from '@/context/auth.context' import { useAddBookmark } from '@/services/hooks/bookmark/add-bookmark.hook' +import { useImportBrowserBookmarks } from '@/services/hooks/bookmark/import-browser-bookmarks.hook' +import type { BulkImportBookmarkNode } from '@/services/hooks/bookmark/import-browser-bookmarks.hook' import type { AxiosError } from 'axios' import type { BookmarkCreateFormFields } from '../components/modal/add-bookmark.modal' import type { BookmarkUpdateFormFields } from '../components/modal/edit-bookmark.modal' @@ -28,9 +30,8 @@ export interface BookmarkStoreContext { addBookmark: (bookmark: BookmarkCreateFormFields, cb: () => void) => Promise importBrowserBookmarks: ( nodes: BrowserImportNode[], - parentId: string | null, - onProgress?: (done: number, total: number) => void - ) => Promise<{ successCount: number; failCount: number }> + parentId: string | null + ) => Promise<{ importedCount: number; createdFolders: number } | null> editBookmark: (bookmark: BookmarkUpdateFormFields, cb: () => void) => void deleteBookmark: (id: string, cb: () => void) => void currentFolderId: string | null @@ -42,7 +43,7 @@ const bookmarkContext = createContext({ setBookmarks: () => {}, getCurrentFolderItems: () => [], addBookmark: async () => {}, - importBrowserBookmarks: async () => ({ successCount: 0, failCount: 0 }), + importBrowserBookmarks: async () => null, editBookmark: () => {}, deleteBookmark: () => {}, currentFolderId: null, @@ -59,6 +60,7 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ const { mutateAsync: removeBookmarkAsync } = useRemoveBookmark() const { mutateAsync: addBookmarkAsync } = useAddBookmark() + const { mutateAsync: importBrowserBookmarksAsync } = useImportBrowserBookmarks() const { mutateAsync: updateBookmarkAsync } = useUpdateBookmark() useEffect(() => { @@ -223,25 +225,13 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ const importBrowserBookmarks = async ( nodes: BrowserImportNode[], - parentId: string | null, - onProgress?: (done: number, total: number) => void - ): Promise<{ successCount: number; failCount: number }> => { + parentId: string | null + ): Promise<{ importedCount: number; createdFolders: number } | null> => { if (!isAuthenticated) { showToast('برای درون‌ریزی بوکمارک‌ها باید وارد شوید.', 'error') - return { successCount: 0, failCount: 0 } + return null } - const countNodes = (list: BrowserImportNode[]): number => - list.reduce( - (sum, node) => sum + 1 + (node.children ? countNodes(node.children) : 0), - 0 - ) - const total = countNodes(nodes) - - let done = 0 - let successCount = 0 - let failCount = 0 - let resolvedParentId = parentId if (parentId && validate(parentId)) { const parentBookmark = bookmarks?.find( @@ -252,67 +242,41 @@ export const BookmarkProvider: React.FC<{ children: React.ReactNode }> = ({ } } - const orderCounters = new Map() - const getNextOrder = (folderId: string | null) => { - const key = folderId || 'root' - if (!orderCounters.has(key)) { - const items = getCurrentFolderItems(folderId) - const maxOrder = items.reduce( - (max, item) => Math.max(max, item.order || 0), - -1 - ) - orderCounters.set(key, maxOrder + 1) - } - const next = orderCounters.get(key) as number - orderCounters.set(key, next + 1) - return next - } - - const importNode = async ( - node: BrowserImportNode, - targetParentId: string | null - ) => { - const [err, created] = await safeAwait( - addBookmarkAsync({ - order: getNextOrder(targetParentId), - parentId: targetParentId, - title: node.title, - customBackground: null, - customTextColor: null, - sticker: null, - type: node.type, - url: node.type === 'BOOKMARK' ? node.url : null, - icon: null, - }) + if (resolvedParentId && validate(resolvedParentId)) { + showToast( + 'برای درون‌ریزی در این پوشه، لطفا ابتدا بوکمارک‌های خود را همگام‌سازی کنید.', + 'error', + { duration: 8000 } ) + return null + } - done += 1 - onProgress?.(done, total) - - if (err || !created) { - failCount += 1 - return - } - - successCount += 1 + const toApiNode = (node: BrowserImportNode): BulkImportBookmarkNode => ({ + title: node.title, + type: node.type, + url: node.type === 'BOOKMARK' ? node.url : null, + children: node.children?.map(toApiNode), + }) - if (node.type === 'FOLDER' && node.children?.length) { - for (const child of node.children) { - await importNode(child, created.id) - } - } - } + const [err, result] = await safeAwait< + AxiosError, + { importedCount: number; createdFolders: number } + >( + importBrowserBookmarksAsync({ + parentId: resolvedParentId, + items: nodes.map(toApiNode), + }) + ) - for (const node of nodes) { - await importNode(node, resolvedParentId) + if (err || !result) { + autoFormatErrorToast(err) + return null } - if (successCount > 0) { - await refetch() - Analytics.event('import_browser_bookmarks') - } + await refetch() + Analytics.event('import_browser_bookmarks') - return { successCount, failCount } + return result } const editBookmark = async (input: BookmarkUpdateFormFields, cb: () => void) => { diff --git a/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts b/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts new file mode 100644 index 00000000..73945713 --- /dev/null +++ b/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts @@ -0,0 +1,48 @@ +import { useMutation } from '@tanstack/react-query' +import { getMainClient } from '@/services/api' +import type { BookmarkType } from '@/layouts/bookmark/types/bookmark.types' + +export interface BulkImportBookmarkNode { + title: string + type: BookmarkType + url?: string | null + children?: BulkImportBookmarkNode[] +} + +export interface BulkImportBookmarksPayload { + parentId: string | null + items: BulkImportBookmarkNode[] +} + +export interface BulkImportBookmarksResult { + message: string + importedCount: number + createdFolders: number +} + +export const useImportBrowserBookmarks = () => { + return useMutation({ + mutationKey: ['importBrowserBookmarks'], + mutationFn: async ( + input: BulkImportBookmarksPayload + ): Promise => { + return await ImportBrowserBookmarksApi(input) + }, + }) +} + +export async function ImportBrowserBookmarksApi( + input: BulkImportBookmarksPayload +): Promise { + const client = getMainClient() + + const { data } = await client.post( + '/bookmarks/import', + { + parentId: input.parentId || undefined, + items: input.items, + } + ) + + return data +} From 6c61d5fcf8d30cc3daec304a54a52fb93304c623 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 22 Jul 2026 21:59:47 +0330 Subject: [PATCH 3/4] fix(bookmarks): fix placeholder rendering when folder reaches max bookmarks --- src/layouts/bookmark/bookmark-grid.tsx | 1 - src/layouts/bookmark/bookmarks.tsx | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/layouts/bookmark/bookmark-grid.tsx b/src/layouts/bookmark/bookmark-grid.tsx index 16412331..250e9cb2 100644 --- a/src/layouts/bookmark/bookmark-grid.tsx +++ b/src/layouts/bookmark/bookmark-grid.tsx @@ -169,7 +169,6 @@ export function BookmarkGrid({ document.removeEventListener('click', handleClickOutside) } }, []) - // todo: padding: 0px 8px; return (
minBookmarks) { + if (bookmarkCount >= maxBookmarks) { folderItems.push(null) } return folderItems From 074cca2a29b6818fe65c3faa4709b57b90c187c0 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 22 Jul 2026 22:53:58 +0330 Subject: [PATCH 4/4] fix(bookmarks): improve UI consistency and fix import API response handling --- .../components/modal/add-bookmark.modal.tsx | 8 +-- .../components/modal/edit-bookmark.modal.tsx | 14 ++--- .../modal/import-browser-bookmarks.modal.tsx | 57 +------------------ .../bookmark/import-browser-bookmarks.hook.ts | 4 +- src/utils/translate-error.ts | 4 ++ 5 files changed, 16 insertions(+), 71 deletions(-) diff --git a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx index 1401cc58..846303be 100644 --- a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx @@ -187,7 +187,7 @@ export function AddBookmarkModal({ @@ -264,7 +262,7 @@ export function AddBookmarkModal({ isPrimary={true} loading={isAdding} className={ - 'btn btn-circle !w-fit px-8 border-none shadow-none text-secondary rounded-xl transition-colors duration-300 ease-in-out' + 'btn w-28 border-none shadow-none text-secondary rounded-2xl transition-colors duration-300 ease-in-out' } > ذخیره diff --git a/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx index 8a622fa1..603620ff 100644 --- a/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx @@ -154,10 +154,10 @@ export function EditBookmarkModal({ size="md" title={`ویرایش ${type === 'FOLDER' ? 'پوشه' : 'بوکمارک'}`} direction="rtl" - className="!overflow-y-hidden" + className="overflow-y-hidden!" closeOnBackdropClick={false} > -
+
@@ -189,7 +189,7 @@ export function EditBookmarkModal({ } /> -
+
{type === 'BOOKMARK' && ( لغو @@ -232,9 +230,7 @@ export function EditBookmarkModal({ size="md" isPrimary={true} loading={isUpdating} - className={ - 'btn btn-circle !w-fit px-8 border-none shadow-none text-secondary !rounded-2xl transition-colors duration-300 ease-in-out' - } + className="transition-colors duration-300 ease-in-out border-none shadow-none btn w-28 text-secondary rounded-2xl" > ذخیره diff --git a/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx b/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx index b6ac8786..61ff111f 100644 --- a/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx +++ b/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx @@ -58,15 +58,6 @@ function buildImportNodes( return result } -function countAllNodes(nodes: FetchedBrowserBookmark[]): number { - let count = 0 - for (const node of nodes) { - count += 1 - if (node.children?.length) count += countAllNodes(node.children) - } - return count -} - interface TreeNodeProps { node: FetchedBrowserBookmark depth: number @@ -264,34 +255,6 @@ export function ImportBrowserBookmarksModal({ }) } - const handleSelectAll = () => { - const totalAvailable = countAllNodes(rootNodes) - - if (totalAvailable <= MAX_BROWSER_IMPORT_ITEMS) { - const all = new Set() - rootNodes.forEach((n) => collectDescendantIds(n, all)) - setSelectedIds(all) - return - } - - const capped = new Set() - const visit = (nodes: FetchedBrowserBookmark[]) => { - for (const node of nodes) { - if (capped.size >= MAX_BROWSER_IMPORT_ITEMS) return - capped.add(node.id) - if (node.children?.length) visit(node.children) - } - } - visit(rootNodes) - setSelectedIds(capped) - showToast( - `تعداد بوکمارک‌های شما بیشتر از حد مجاز است؛ فقط ${MAX_BROWSER_IMPORT_ITEMS} مورد اول انتخاب شد.`, - 'warning' - ) - } - - const handleClearAll = () => setSelectedIds(new Set()) - const handleImport = async () => { const importNodes = buildImportNodes(rootNodes, selectedIds) if (importNodes.length === 0) { @@ -350,22 +313,6 @@ export function ImportBrowserBookmarksModal({ ? `${selectedIds.size} از ${MAX_BROWSER_IMPORT_ITEMS} مورد انتخاب شده` : `حداکثر ${MAX_BROWSER_IMPORT_ITEMS} مورد قابل انتخاب است`} -
- - -
@@ -401,7 +348,7 @@ export function ImportBrowserBookmarksModal({ onClick={onClose} size="md" disabled={isImporting} - className="btn btn-circle !bg-base-300 hover:!bg-error/10 text-muted hover:!text-error px-8 border-none shadow-none rounded-xl transition-colors duration-300 ease-in-out" + className="w-20 transition-colors duration-300 ease-in-out border-none shadow-none btn bg-base-300 hover:bg-error/10 text-base-content/80 hover:text-error rounded-2xl" > انصراف @@ -414,7 +361,7 @@ export function ImportBrowserBookmarksModal({ loadingText={ در حال درون‌ریزی... } - className="btn btn-circle !w-fit px-8 border-none shadow-none text-secondary rounded-xl transition-colors duration-300 ease-in-out" + className="w-32 transition-colors duration-300 ease-in-out border-none shadow-none btn btn-circle text-secondary rounded-2xl" > درون‌ریزی diff --git a/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts b/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts index 73945713..efc24370 100644 --- a/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts +++ b/src/services/hooks/bookmark/import-browser-bookmarks.hook.ts @@ -36,7 +36,7 @@ export async function ImportBrowserBookmarksApi( ): Promise { const client = getMainClient() - const { data } = await client.post( + const { data } = await client.post<{ data: BulkImportBookmarksResult }>( '/bookmarks/import', { parentId: input.parentId || undefined, @@ -44,5 +44,5 @@ export async function ImportBrowserBookmarksApi( } ) - return data + return data.data } diff --git a/src/utils/translate-error.ts b/src/utils/translate-error.ts index 3fbc612b..bf3e176c 100644 --- a/src/utils/translate-error.ts +++ b/src/utils/translate-error.ts @@ -110,6 +110,10 @@ const errorTranslations: Record = { TRY_NEXT_TIME: 'چیزی اشتباه پیش رفت، لطفا بعدا دوباره تلاش کنید', TOO_MANY_ATTEMPTS_HABIT: 'بیش از حد نمیتونید بسازید.', + + FOLDER_STRUCTURE_TOO_DEEP: 'FOLDER_STRUCTURE_TOO_DEEP', + BULK_IMPORT_LIMIT_EXCEEDED: `تعداد آیتم درون‌ریزی بیش‌از حد هست`, + NO_VALID_ITEMS_TO_IMPORT: 'هیچ آیتم معتبری برای درون‌ریزی وجود نداره', } const validationTranslations: Record = {