diff --git a/public/images/bookmark-permission-alert.jpg b/public/images/bookmark-permission-alert.jpg new file mode 100644 index 00000000..11f8a0a7 Binary files /dev/null and b/public/images/bookmark-permission-alert.jpg differ diff --git a/src/layouts/bookmark/bookmarks.tsx b/src/layouts/bookmark/bookmarks.tsx index 130b13b1..f63fd49a 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 { ImportBookmarkModal } from './components/modal/import-bookmark.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 [showImportBookmarkModal, setShowImportBookmarkModal] = useState(false) const [folderPath, setFolderPath] = useState([]) @@ -147,6 +149,17 @@ export function BookmarksList() { const currentFolderItems = getCurrentFolderItems(currentFolderId) + // Calculate available slots + const getAvailableSlots = (): number => { + if (!currentFolderId) { + return Math.max(0, TOTAL_BOOKMARKS - currentFolderItems.length) + } + // For folders, use a larger limit (e.g., 50) + return Math.max(0, 50 - currentFolderItems.length) + } + + const availableSlots = getAvailableSlots() + const getDisplayedBookmarks = (): Bookmark[] => { if (!currentFolderId) { const baseItems = currentFolderItems.slice(0, TOTAL_BOOKMARKS) @@ -193,7 +206,10 @@ export function BookmarksList() { displayedBookmarks={displayedBookmarks} folderPath={folderPath} setFolderPath={(path) => setFolderPath(path)} - openAddBookmarkModal={() => setShowAddBookmarkModal(true)} + openAddBookmarkModal={() => { + setShowAddBookmarkModal(true) + setShowImportBookmarkModal(false) + }} /> @@ -213,9 +229,21 @@ export function BookmarksList() { addBookmark(bookmark, () => setShowAddBookmarkModal(false)) } parentId={currentFolderId} + onOpenImportModal={() => { + setShowAddBookmarkModal(false) + setShowImportBookmarkModal(true) + }} /> ) )} + {showImportBookmarkModal && isAuthenticated && ( + setShowImportBookmarkModal(false)} + parentId={currentFolderId} + availableSlots={availableSlots} + /> + )} ) } diff --git a/src/layouts/bookmark/components/browser-bookmark-selector.tsx b/src/layouts/bookmark/components/browser-bookmark-selector.tsx new file mode 100644 index 00000000..03a168e3 --- /dev/null +++ b/src/layouts/bookmark/components/browser-bookmark-selector.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' +import { FaGlobe } from 'react-icons/fa' +import { FiFolder } from 'react-icons/fi' +import { getFaviconFromUrl } from '@/common/utils/icon' +import { SectionPanel } from '@/components/section-panel' +import { useGeneralSetting } from '@/context/general-setting.context' +import { + type FetchedBrowserBookmark, + getBrowserBookmarks, +} from '../utils/browser-bookmarks.util' + +interface BrowserBookmarkSelectorProps { + onSelect: (bookmark: { title: string; url: string; icon: string | null }) => void +} + +export function BrowserBookmarkSelector({ onSelect }: BrowserBookmarkSelectorProps) { + const { browserBookmarksEnabled } = useGeneralSetting() + const [bookmarks, setBookmarks] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + async function loadBookmarks() { + if (!browserBookmarksEnabled) return + + setLoading(true) + try { + const fetched = await getBrowserBookmarks({ includeFolders: false }) + // فقط بوکمارک‌ها (نه پوشه‌ها) و حداکثر 20 تا + const bookmarksOnly = fetched + .filter((b) => b.type === 'BOOKMARK' && b.url) + .slice(0, 20) + setBookmarks(bookmarksOnly) + } catch (error) { + console.error('Error loading browser bookmarks:', error) + } finally { + setLoading(false) + } + } + + loadBookmarks() + }, [browserBookmarksEnabled]) + + if (!browserBookmarksEnabled) { + return null + } + + if (loading) { + return ( +
+ +
+ در حال بارگذاری... +
+
+
+ ) + } + + if (bookmarks.length === 0) { + return null + } + + return ( +
+ +
+ {bookmarks.map((bookmark) => ( +
+ onSelect({ + title: bookmark.title, + url: bookmark.url || '', + icon: bookmark.url + ? getFaviconFromUrl(bookmark.url) + : null, + }) + } + 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" + > +
+ {bookmark.url ? ( + {bookmark.title} { + const target = e.target as HTMLImageElement + target.style.display = 'none' + target.nextElementSibling?.classList.remove( + 'hidden' + ) + }} + /> + ) : null} + +
+

+ {bookmark.title} +

+
+ ))} +
+
+
+ ) +} diff --git a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx index 78807055..9a68505a 100644 --- a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Button } from '@/components/button/button' import Modal from '@/components/modal' import { TextInput } from '@/components/text-input' @@ -19,6 +19,7 @@ interface AddBookmarkModalProps { onClose: () => void onAdd: (bookmark: BookmarkCreateFormFields) => void parentId: string | null + onOpenImportModal?: () => void } export interface BookmarkCreateFormFields { @@ -55,6 +56,7 @@ export function AddBookmarkModal({ onClose, onAdd, parentId = null, + onOpenImportModal, }: AddBookmarkModalProps) { const [type, setType] = useState('BOOKMARK') const [iconSource, setIconSource] = useState('auto') @@ -183,129 +185,149 @@ export function AddBookmarkModal({ }, [isOpen]) return ( - onCloseHandler()} - size="md" - title={`${type === 'FOLDER' ? 'پوشه جدید' : 'بوکمارک جدید'}`} - direction="rtl" - className="!overflow-y-hidden" - closeOnBackdropClick={false} - > -
+ onCloseHandler()} + size="md" + title={`${type === 'FOLDER' ? 'پوشه جدید' : 'بوکمارک جدید'}`} + direction="rtl" + className="!overflow-y-hidden" + closeOnBackdropClick={false} > -
-
- -
+ +
+
+ +
+ + {/* Import from browser bookmarks button */} + {type === 'BOOKMARK' && onOpenImportModal && ( +
+ +
+ )} -
- {' '} -
+ {' '} +
+ > + {type === 'BOOKMARK' && ( + + )} + {renderIconPreview( + formData.icon, + type === 'FOLDER' ? 'upload' : iconSource, + setIconSource, + (value) => updateFormData('icon', value) + )} +
+ + handleImageUpload( + e, + (file) => updateFormData('icon', file), + setIconSource + ) + } + /> + updateFormData('title', v)} + className={ + 'mt-2 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' + } + /> + )} +
{type === 'BOOKMARK' && ( - - )} - {renderIconPreview( - formData.icon, - type === 'FOLDER' ? 'upload' : iconSource, - setIconSource, - (value) => updateFormData('icon', value) + )}
- - handleImageUpload( - e, - (file) => updateFormData('icon', file), - setIconSource - ) - } - /> - updateFormData('title', v)} - className={ - 'mt-2 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' - } - /> - )} -
- {type === 'BOOKMARK' && ( - - )}
- -
- -
- - -
- - +
+ + +
+ + +
-
- - + + + ) } diff --git a/src/layouts/bookmark/components/modal/bookmark-permission-info.modal.tsx b/src/layouts/bookmark/components/modal/bookmark-permission-info.modal.tsx new file mode 100644 index 00000000..d6ee2ca2 --- /dev/null +++ b/src/layouts/bookmark/components/modal/bookmark-permission-info.modal.tsx @@ -0,0 +1,84 @@ +import Modal from '@/components/modal' +import { Button } from '@/components/button/button' + +interface BookmarkPermissionInfoModalProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void +} + +export function BookmarkPermissionInfoModal({ + isOpen, + onClose, + onConfirm, +}: BookmarkPermissionInfoModalProps) { + const handleConfirm = () => { + onConfirm() + onClose() + } + + return ( + +
+
+ {/* Type badge and title */} +
+

+ اجازه دسترسی به بوکمارک های مرورگر +

+
+ +
+
+ Browser permission dialog example +
+
+ + {/* Content */} +
+

+ برای وارد کردن بوکمارک‌های مرورگر به ویجتی‌فای، بعد از تایید + کردن این پیام، یک پیغام به شکل زیر روی صفحه شما ظاهر می‌شود که + باید روی دکمه{' '} + "Allow"{' '} + کلیک کنید.{' '} +

+
+
+ + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/src/layouts/bookmark/components/modal/import-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/import-bookmark.modal.tsx new file mode 100644 index 00000000..39243323 --- /dev/null +++ b/src/layouts/bookmark/components/modal/import-bookmark.modal.tsx @@ -0,0 +1,456 @@ +import { useEffect, useState } from 'react' +import { Button } from '@/components/button/button' +import Modal from '@/components/modal' +import { FiFolder, FiChevronLeft } from 'react-icons/fi' +import { getFaviconFromUrl } from '@/common/utils/icon' +import { useGeneralSetting } from '@/context/general-setting.context' +import { useBookmarkStore } from '../../context/bookmark.context' +import { + type FetchedBrowserBookmark, + getBrowserBookmarks, +} from '../../utils/browser-bookmarks.util' +import { BookmarkPermissionInfoModal } from './bookmark-permission-info.modal' +import { v4 as uuidv4 } from 'uuid' +import { toast } from 'react-hot-toast' +import { setToStorage } from '@/common/storage' +import { callEvent } from '@/common/utils/call-event' +import { SyncTarget } from '@/layouts/navbar/sync/sync' +import Analytics from '@/analytics' +import type { Bookmark, LocalBookmark } from '../../types/bookmark.types' + +interface ImportBookmarkModalProps { + isOpen: boolean + onClose: () => void + parentId: string | null + availableSlots: number +} + +interface SelectedItem { + id: string + title: string + url: string | null + type: 'BOOKMARK' | 'FOLDER' + children?: FetchedBrowserBookmark[] + importChildren?: boolean +} + +export function ImportBookmarkModal({ + isOpen, + onClose, + parentId, + availableSlots, +}: ImportBookmarkModalProps) { + const [browserBookmarks, setBrowserBookmarks] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedItems, setSelectedItems] = useState>( + new Map() + ) + const [expandedFolders, setExpandedFolders] = useState>(new Set()) + const [showPermissionInfoModal, setShowPermissionInfoModal] = useState(false) + const [isImporting, setIsImporting] = useState(false) + + const { browserBookmarksEnabled, setBrowserBookmarksEnabled } = useGeneralSetting() + const { + bookmarks: existingBookmarks, + setBookmarks, + getCurrentFolderItems, + } = useBookmarkStore() + + // Check permission when modal opens + useEffect(() => { + if (isOpen && !browserBookmarksEnabled) { + browser.permissions + .contains({ permissions: ['bookmarks'] }) + .then((hasPermission) => { + if (!hasPermission) { + setShowPermissionInfoModal(true) + } + }) + } + }, [isOpen, browserBookmarksEnabled]) + + useEffect(() => { + async function loadBookmarks() { + if (!browserBookmarksEnabled) return + + setLoading(true) + try { + const fetched = await getBrowserBookmarks({ + includeFolders: true, + asTree: true, + }) + // Filter out root folders (Bookmarks bar, Other bookmarks, etc.) + const filtered = fetched + .filter((root) => root.children && root.children.length > 0) + .flatMap((root) => root.children || []) + setBrowserBookmarks(filtered) + } catch (error) { + console.error('Error loading browser bookmarks:', error) + } finally { + setLoading(false) + } + } + + if (isOpen && browserBookmarksEnabled) { + loadBookmarks() + } + }, [isOpen, browserBookmarksEnabled]) + + const handlePermissionInfoConfirm = () => { + browser.permissions + .request({ permissions: ['bookmarks'] }) + .then((granted) => { + if (granted) { + setBrowserBookmarksEnabled(true) + setShowPermissionInfoModal(false) + } + }) + .catch((error) => { + console.error('Error requesting permission:', error) + }) + } + + const toggleItemSelection = (item: FetchedBrowserBookmark) => { + const newSelected = new Map(selectedItems) + + if (item.type === 'FOLDER') { + if (newSelected.has(item.id)) { + newSelected.delete(item.id) + } else { + newSelected.set(item.id, { + id: item.id, + title: item.title, + url: item.url, + type: item.type, + children: item.children, + importChildren: false, + }) + } + } else { + if (newSelected.has(item.id)) { + newSelected.delete(item.id) + } else { + if (getSelectedCount(newSelected) >= availableSlots) { + toast.error(`فقط ${availableSlots} جایگاه خالی دارید`) + return + } + newSelected.set(item.id, { + id: item.id, + title: item.title, + url: item.url, + type: item.type, + }) + } + } + + setSelectedItems(newSelected) + } + + const toggleFolderExpansion = (folderId: string) => { + const newExpanded = new Set(expandedFolders) + if (newExpanded.has(folderId)) { + newExpanded.delete(folderId) + } else { + newExpanded.add(folderId) + } + setExpandedFolders(newExpanded) + } + + const toggleImportChildren = (folderId: string) => { + const newSelected = new Map(selectedItems) + const item = newSelected.get(folderId) + if (item && item.type === 'FOLDER') { + const childrenCount = + item.children?.filter((c) => c.type === 'BOOKMARK').length || 0 + // Check limit based on the new folder's capacity (50 slots for folders) + const folderCapacity = 50 + const wouldExceed = !item.importChildren && childrenCount > folderCapacity + + if (wouldExceed) { + toast.error( + `این پوشه ${childrenCount} بوکمارک دارد که از ${folderCapacity} جایگاه مجاز بیشتر است` + ) + return + } + + newSelected.set(folderId, { + ...item, + importChildren: !item.importChildren, + }) + setSelectedItems(newSelected) + } + } + + const getSelectedCount = (selected: Map): number => { + let count = 0 + selected.forEach((item) => { + if (item.type === 'BOOKMARK') { + count++ + } else if (item.type === 'FOLDER' && item.importChildren) { + count += item.children?.filter((c) => c.type === 'BOOKMARK').length || 0 + } + }) + return count + } + + const renderBookmarkItem = (item: FetchedBrowserBookmark, level: number = 0) => { + const isSelected = selectedItems.has(item.id) + const isExpanded = expandedFolders.has(item.id) + const selectedItem = selectedItems.get(item.id) + const isFolder = item.type === 'FOLDER' + const hasChildren = item.children && item.children.length > 0 + + return ( +
+
+ toggleItemSelection(item)} + className="checkbox w-4 h-4 cursor-pointer" + disabled={ + !isFolder && + getSelectedCount(selectedItems) >= availableSlots && + !isSelected + } + /> + + {isFolder ? ( + + )} + + + ) : ( + + )} +
+ + {isFolder && isExpanded && hasChildren && ( +
+ {item.children?.map((child) => + renderBookmarkItem(child, level + 1) + )} +
+ )} +
+ ) + } + + const handleImport = async () => { + if (selectedItems.size === 0) { + toast.error('لطفاً حداقل یک مورد را انتخاب کنید') + return + } + + setIsImporting(true) + try { + const currentFolderItems = getCurrentFolderItems(parentId) + const maxOrder = currentFolderItems.reduce( + (max, item) => Math.max(max, item.order || 0), + -1 + ) + + const newBookmarks: LocalBookmark[] = [] + let currentOrder = maxOrder + 1 + + // Import folders first + for (const [id, selectedItem] of selectedItems) { + if (selectedItem.type === 'FOLDER') { + const newFolder: LocalBookmark = { + id: uuidv4(), + order: currentOrder++, + isLocal: true, + onlineId: null, + parentId: parentId, + title: selectedItem.title, + type: 'FOLDER', + url: null, + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + } + newBookmarks.push(newFolder) + + // Import children if requested + if (selectedItem.importChildren && selectedItem.children) { + const childBookmarks = selectedItem.children.filter( + (c) => c.type === 'BOOKMARK' + ) + for (const child of childBookmarks) { + if (child.url) { + const newBookmark: LocalBookmark = { + id: uuidv4(), + order: currentOrder++, + isLocal: true, + onlineId: null, + parentId: newFolder.id, + title: child.title, + type: 'BOOKMARK', + url: child.url, + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + } + newBookmarks.push(newBookmark) + } + } + } + } + } + + // Import bookmarks + for (const [id, selectedItem] of selectedItems) { + if (selectedItem.type === 'BOOKMARK' && selectedItem.url) { + const newBookmark: LocalBookmark = { + id: uuidv4(), + order: currentOrder++, + isLocal: true, + onlineId: null, + parentId: parentId, + title: selectedItem.title, + type: 'BOOKMARK', + url: selectedItem.url, + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + } + newBookmarks.push(newBookmark) + } + } + + // Add all new bookmarks to existing bookmarks + const updatedBookmarks = [...existingBookmarks, ...newBookmarks] + setBookmarks(updatedBookmarks) + const localBookmarks = updatedBookmarks.filter((b) => b.isLocal) + await setToStorage('bookmarks', localBookmarks) + + Analytics.event('import_bookmarks') + callEvent('startSync', SyncTarget.BOOKMARKS) + + toast.success(`${newBookmarks.length} مورد با موفقیت اضافه شد`) + onClose() + } catch (error) { + console.error('Error importing bookmarks:', error) + toast.error('خطا در import کردن بوکمارک‌ها') + } finally { + setIsImporting(false) + } + } + + const selectedCount = getSelectedCount(selectedItems) + + if (!browserBookmarksEnabled || showPermissionInfoModal) { + return ( + { + setShowPermissionInfoModal(false) + onClose() + }} + onConfirm={handlePermissionInfoConfirm} + /> + ) + } + + return ( + +
+
+ {loading ? ( +
+ در حال بارگذاری... +
+ ) : browserBookmarks.length === 0 ? ( +
+ بوکمارکی یافت نشد +
+ ) : ( +
+ {browserBookmarks.map((item) => renderBookmarkItem(item))} +
+ )} +
+ +
+ + {selectedCount} از {availableSlots} جایگاه پر شده + + {selectedCount > 0 && ( + + )} +
+
+
+ ) +}