diff --git a/apps/backend/app/api/routes/bookmarks/schema.py b/apps/backend/app/api/routes/bookmarks/schema.py index 99552e74..d0684c52 100644 --- a/apps/backend/app/api/routes/bookmarks/schema.py +++ b/apps/backend/app/api/routes/bookmarks/schema.py @@ -67,7 +67,6 @@ class BookmarkOut(BookmarkBase): id: str createdAt: int updatedAt: int - created_by: str class BookmarkFolderBase(BaseModel): @@ -101,14 +100,13 @@ class BookmarkFolderExpanded(BaseModel): class BookmarkFolderOut(BookmarkFolderBase): id: str createdAt: int - created_by: str class BookmarkImportBody(BaseModel): """Same shape as ``exportBookmarksToJSON`` / ``parseBookmarkJSON`` output.""" - bookmarks: list[dict] = Field(default_factory=list) - folders: list[dict] = Field(default_factory=list) + bookmarks: list[BookmarkCreate] = Field(default_factory=list) + folders: list[BookmarkFolderCreate] = Field(default_factory=list) class BookmarkSnapshotOut(BaseModel): diff --git a/apps/backend/app/api/routes/bookmarks/services.py b/apps/backend/app/api/routes/bookmarks/services.py index c5cb4769..ee8f8799 100644 --- a/apps/backend/app/api/routes/bookmarks/services.py +++ b/apps/backend/app/api/routes/bookmarks/services.py @@ -33,7 +33,6 @@ def _bookmark_doc_to_out(doc: dict[str, Any]) -> BookmarkOut: folderId=doc.get("folderId"), createdAt=int(doc.get("createdAt", 0)), updatedAt=int(doc.get("updatedAt", 0)), - created_by=doc.get("created_by", ""), ) @@ -47,7 +46,6 @@ def _folder_doc_to_out(doc: dict[str, Any]) -> BookmarkFolderOut: icon=doc.get("icon"), isExpanded=doc.get("isExpanded"), createdAt=int(doc.get("createdAt", 0)), - created_by=doc.get("created_by", ""), ) @@ -137,63 +135,39 @@ async def delete_bookmark(uid: str, bookmark_id: str) -> None: async def import_bookmarks(uid: str, body: BookmarkImportBody) -> dict[str, int]: - folder_count = 0 - bookmark_count = 0 folder_ops: list[ReplaceOne] = [] bookmark_ops: list[ReplaceOne] = [] try: - for raw in body.folders: - row = dict(raw) - row.pop("created_by", None) - fid = row.pop("id", None) or row.pop("_id", None) - if not fid: - fid = new_id() + for folder in body.folders: + fid = str(folder.id or new_id()) doc = { - "_id": str(fid), + "_id": fid, "created_by": uid, - "name": row.get("name", "Untitled"), - "parentId": row.get("parentId"), - "color": row.get("color"), - "icon": row.get("icon"), - "isExpanded": row.get("isExpanded", False), - "createdAt": int(row.get("createdAt", create_timestamp())), + "name": folder.name, + "parentId": folder.parentId, + "color": folder.color, + "icon": folder.icon, + "isExpanded": folder.isExpanded or False, + "createdAt": create_timestamp(), } - folder_ops.append( - ReplaceOne( - {"_id": doc["_id"], "created_by": uid}, - doc, - upsert=True, - ) - ) - folder_count += 1 - - for raw in body.bookmarks: - row = dict(raw) - row.pop("created_by", None) - bid = row.pop("id", None) or row.pop("_id", None) - if not bid: - bid = new_id() + folder_ops.append(ReplaceOne({"_id": fid, "created_by": uid}, doc, upsert=True)) + + for bookmark in body.bookmarks: + bid = str(bookmark.id or new_id()) ts = create_timestamp() doc = { - "_id": str(bid), + "_id": bid, "created_by": uid, - "title": row.get("title", ""), - "url": row.get("url", ""), - "description": row.get("description"), - "favicon": row.get("favicon"), - "tags": list(row.get("tags") or []), - "folderId": row.get("folderId"), - "createdAt": int(row.get("createdAt", ts)), - "updatedAt": int(row.get("updatedAt", ts)), + "title": bookmark.title, + "url": bookmark.url, + "description": bookmark.description, + "favicon": bookmark.favicon, + "tags": list(bookmark.tags), + "folderId": bookmark.folderId, + "createdAt": ts, + "updatedAt": ts, } - bookmark_ops.append( - ReplaceOne( - {"_id": doc["_id"], "created_by": uid}, - doc, - upsert=True, - ) - ) - bookmark_count += 1 + bookmark_ops.append(ReplaceOne({"_id": bid, "created_by": uid}, doc, upsert=True)) if folder_ops: await db_manager.bulk_write(FOLDERS, folder_ops, ordered=False) @@ -203,7 +177,7 @@ async def import_bookmarks(uid: str, body: BookmarkImportBody) -> dict[str, int] raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to import bookmarks." ) from exc - return {"foldersUpserted": folder_count, "bookmarksUpserted": bookmark_count} + return {"foldersUpserted": len(folder_ops), "bookmarksUpserted": len(bookmark_ops)} async def clear_all_bookmarks(uid: str) -> dict[str, int]: diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json index 69edabd4..4c0a859a 100644 --- a/apps/web/messages/af.json +++ b/apps/web/messages/af.json @@ -973,6 +973,7 @@ "foldersHeading": "Vouers", "tagsHeading": "Merkers", "tagsMore": "+{count} meer", + "tagsShowLess": "Show less", "searchPlaceholder": "Soek jou boekmerke...", "import": "Invoer", "export": "Uitvoer", diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index 5294cb16..ef789f4c 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -973,6 +973,7 @@ "foldersHeading": "المجلدات", "tagsHeading": "الوسوم", "tagsMore": "+{count} أخرى", + "tagsShowLess": "Show less", "searchPlaceholder": "ابحث في الإشارات المرجعية...", "import": "استيراد", "export": "تصدير", diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json index a6864153..b6d3040b 100644 --- a/apps/web/messages/ca.json +++ b/apps/web/messages/ca.json @@ -973,6 +973,7 @@ "foldersHeading": "Carpetes", "tagsHeading": "Etiquetes", "tagsMore": "+{count} més", + "tagsShowLess": "Show less", "searchPlaceholder": "Cerca marcadors...", "import": "Importa", "export": "Exporta", diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index be6385eb..2733dca8 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -973,6 +973,7 @@ "foldersHeading": "Složky", "tagsHeading": "Štítky", "tagsMore": "+{count} dalších", + "tagsShowLess": "Show less", "searchPlaceholder": "Hledat v záložkách...", "import": "Import", "export": "Export", diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json index 1de13405..a424ae1d 100644 --- a/apps/web/messages/da.json +++ b/apps/web/messages/da.json @@ -973,6 +973,7 @@ "foldersHeading": "Mapper", "tagsHeading": "Tags", "tagsMore": "+{count} mere", + "tagsShowLess": "Show less", "searchPlaceholder": "Søg i dine bogmærker...", "import": "Importere", "export": "Eksportere", diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 29198c38..0f694cc5 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -973,6 +973,7 @@ "foldersHeading": "Ordner", "tagsHeading": "Tags", "tagsMore": "+{count} weitere", + "tagsShowLess": "Show less", "searchPlaceholder": "Lesezeichen suchen...", "import": "Importieren", "export": "Exportieren", diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json index 672ba20f..21b63921 100644 --- a/apps/web/messages/el.json +++ b/apps/web/messages/el.json @@ -973,6 +973,7 @@ "foldersHeading": "Φάκελοι", "tagsHeading": "Ετικέτες", "tagsMore": "+{count} ακόμη", + "tagsShowLess": "Show less", "searchPlaceholder": "Αναζήτηση στους σελιδοδείκτες σας...", "import": "Εισαγωγή", "export": "Εξαγωγή", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a20da9cd..fd8206b6 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -975,6 +975,7 @@ "foldersHeading": "Folders", "tagsHeading": "Tags", "tagsMore": "+{count} more", + "tagsShowLess": "Show less", "searchPlaceholder": "Search your bookmarks...", "import": "Import", "export": "Export", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 23264195..eba69f2a 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -973,6 +973,7 @@ "foldersHeading": "Carpetas", "tagsHeading": "Etiquetas", "tagsMore": "+{count} más", + "tagsShowLess": "Show less", "searchPlaceholder": "Buscar en tus marcadores...", "import": "Importar", "export": "Exportar", diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index 9f7e0c76..73858be4 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -973,6 +973,7 @@ "foldersHeading": "پوشه ها", "tagsHeading": "برچسب ها", "tagsMore": "+{count} بیشتر", + "tagsShowLess": "Show less", "searchPlaceholder": "بوک مارک های خود را جستجو کنید...", "import": "واردات", "export": "صادرات", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index e50852c8..3e6790df 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -973,6 +973,7 @@ "foldersHeading": "Dossiers", "tagsHeading": "Étiquettes", "tagsMore": "+{count} de plus", + "tagsShowLess": "Show less", "searchPlaceholder": "Rechercher des signets…", "import": "Importer", "export": "Exporter", diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index a9202ec7..feb1e71a 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -973,6 +973,7 @@ "foldersHeading": "Folder", "tagsHeading": "Tag", "tagsMore": "+{count} lagi", + "tagsShowLess": "Show less", "searchPlaceholder": "Cari bookmark Anda...", "import": "Impor", "export": "Ekspor", diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 7adb2b0c..db76825b 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -973,6 +973,7 @@ "foldersHeading": "Cartelle", "tagsHeading": "Tag", "tagsMore": "+{count} altro", + "tagsShowLess": "Show less", "searchPlaceholder": "Cerca nei tuoi segnalibri...", "import": "Importare", "export": "Esportare", diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 3c88e664..1a3f43d7 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -973,6 +973,7 @@ "foldersHeading": "フォルダー", "tagsHeading": "タグ", "tagsMore": "+{count}件", + "tagsShowLess": "Show less", "searchPlaceholder": "ブックマークを検索...", "import": "インポート", "export": "エクスポート", diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index 5188a619..60fb1038 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -973,6 +973,7 @@ "foldersHeading": "폴더", "tagsHeading": "태그", "tagsMore": "+{count}개 더", + "tagsShowLess": "Show less", "searchPlaceholder": "북마크 검색...", "import": "가져오기", "export": "내보내기", diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json index a85361e6..fdffaa50 100644 --- a/apps/web/messages/ms.json +++ b/apps/web/messages/ms.json @@ -973,6 +973,7 @@ "foldersHeading": "Folder", "tagsHeading": "Tag", "tagsMore": "+{count} lagi", + "tagsShowLess": "Show less", "searchPlaceholder": "Cari penanda halaman anda...", "import": "Import", "export": "Eksport", diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json index 339600c7..e2be4c9c 100644 --- a/apps/web/messages/nb.json +++ b/apps/web/messages/nb.json @@ -973,6 +973,7 @@ "foldersHeading": "Mapper", "tagsHeading": "Etiketter", "tagsMore": "+{count} flere", + "tagsShowLess": "Show less", "searchPlaceholder": "Søk i bokmerkene dine...", "import": "Importer", "export": "Eksporter", diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 43aa434c..519e648e 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -973,6 +973,7 @@ "foldersHeading": "Mappen", "tagsHeading": "Tags", "tagsMore": "+{count} meer", + "tagsShowLess": "Show less", "searchPlaceholder": "Zoek je bladwijzers...", "import": "Importeren", "export": "Exporteren", diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index f093ca5a..92904895 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -973,6 +973,7 @@ "foldersHeading": "Folders", "tagsHeading": "Tags", "tagsMore": "+{count} more", + "tagsShowLess": "Show less", "searchPlaceholder": "Search your bookmarks...", "import": "Import", "export": "Export", diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index e5c28ab3..57901ee6 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -973,6 +973,7 @@ "foldersHeading": "Pastas", "tagsHeading": "Tags", "tagsMore": "+{count} mais", + "tagsShowLess": "Show less", "searchPlaceholder": "Buscar favoritos...", "import": "Importar", "export": "Exportar", diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index d3f4da00..4b483dfd 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -973,6 +973,7 @@ "foldersHeading": "Pastas", "tagsHeading": "Etiquetas", "tagsMore": "+{count} mais", + "tagsShowLess": "Show less", "searchPlaceholder": "Pesquisar marcadores...", "import": "Importar", "export": "Exportar", diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 4b278ada..972e8bf0 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -973,6 +973,7 @@ "foldersHeading": "Папки", "tagsHeading": "Теги", "tagsMore": "+{count} ещё", + "tagsShowLess": "Show less", "searchPlaceholder": "Поиск закладок...", "import": "Импорт", "export": "Экспорт", diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 1b53bf3d..18d63658 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -973,6 +973,7 @@ "foldersHeading": "Mappar", "tagsHeading": "Taggar", "tagsMore": "+{count} fler", + "tagsShowLess": "Show less", "searchPlaceholder": "Sök bland dina bokmärken...", "import": "Importera", "export": "Exportera", diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index c1f39661..3cc29c1a 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -973,6 +973,7 @@ "foldersHeading": "Klasörler", "tagsHeading": "Etiketler", "tagsMore": "+{count} daha", + "tagsShowLess": "Show less", "searchPlaceholder": "Yer imlerinizde arayın...", "import": "İçe Aktar", "export": "Dışa Aktar", diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 6b62afe4..7605a583 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -973,6 +973,7 @@ "foldersHeading": "Теки", "tagsHeading": "Теги", "tagsMore": "+{count} ще", + "tagsShowLess": "Show less", "searchPlaceholder": "Пошук закладок...", "import": "Імпорт", "export": "Експорт", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index a969524e..3712be44 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -973,6 +973,7 @@ "foldersHeading": "Thư mục", "tagsHeading": "Nhãn", "tagsMore": "+{count} thêm", + "tagsShowLess": "Show less", "searchPlaceholder": "Tìm kiếm dấu trang...", "import": "Nhập", "export": "Xuất", diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index ddfeed6d..941835ed 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -973,6 +973,7 @@ "foldersHeading": "文件夹", "tagsHeading": "标签", "tagsMore": "另外 {count} 个", + "tagsShowLess": "Show less", "searchPlaceholder": "搜索书签…", "import": "导入", "export": "导出", diff --git a/apps/web/src/components/bookmarks/add-bookmark-dialog.tsx b/apps/web/src/components/bookmarks/add-bookmark-dialog.tsx index 0f6c19f2..342073cc 100644 --- a/apps/web/src/components/bookmarks/add-bookmark-dialog.tsx +++ b/apps/web/src/components/bookmarks/add-bookmark-dialog.tsx @@ -1,9 +1,9 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, useMemo } from "react" import { IconLink, IconTag, IconFolder, IconX, IconLoader2 } from "@tabler/icons-react" import { useBookmarkStore, Bookmark, useAllTags } from "@/store/bookmark-store" -import { getFaviconUrl, normalizeUrl, isValidUrl } from "@/lib/favicon-utils" +import { normalizeUrl, isValidUrl } from "@/lib/favicon-utils" import { Dialog, DialogContent, @@ -81,17 +81,7 @@ export default function AddBookmarkDialog({ open, onOpenChange, editingId }: Add setUrlError("") setUrl(normalizedUrl) - // Auto-fill title if empty - if (!title) { - try { - // Try to extract domain as title - const urlObj = new URL(normalizedUrl) - const domain = urlObj.hostname.replace(/^www\./, '') - setTitle(domain.charAt(0).toUpperCase() + domain.slice(1)) - } catch { - // Ignore errors - } - } + // Leave title empty — domain fallback produces low-quality titles }, [url, title, t]) const handleAddTag = useCallback(() => { @@ -126,12 +116,22 @@ export default function AddBookmarkDialog({ open, onOpenChange, editingId }: Add return } + if (!editingId) { + const duplicate = bookmarks.find(b => b.url === normalizedUrl) + if (duplicate) { + toast.error(`Already saved as "${duplicate.title}"`) + return + } + } + + setIsLoading(true) + const bookmarkData: Bookmark = { - id: editingId || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: editingId || `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, url: normalizedUrl, title: title.trim(), description: description.trim() || undefined, - favicon: getFaviconUrl(normalizedUrl), + favicon: undefined, folderId: folderId === "none" ? null : folderId, tags, createdAt: editingId ? bookmarks.find(b => b.id === editingId)?.createdAt || Date.now() : Date.now(), @@ -146,27 +146,22 @@ export default function AddBookmarkDialog({ open, onOpenChange, editingId }: Add toast.success(t("toastAdded")) } + setIsLoading(false) onOpenChange(false) }, [url, title, description, folderId, tags, editingId, bookmarks, addBookmark, updateBookmark, onOpenChange, t]) - // Build folder options with hierarchy - const buildFolderOptions = useCallback(() => { + const folderOptions = useMemo(() => { const options: { id: string; name: string; depth: number }[] = [] - const addFolder = (parentId: string | null, depth: number) => { - const children = folders.filter(f => f.parentId === parentId) - for (const folder of children) { + for (const folder of folders.filter(f => f.parentId === parentId)) { options.push({ id: folder.id, name: folder.name, depth }) addFolder(folder.id, depth + 1) } } - addFolder(null, 0) return options }, [folders]) - const folderOptions = buildFolderOptions() - return ( @@ -227,7 +222,7 @@ export default function AddBookmarkDialog({ open, onOpenChange, editingId }: Add - + {t("noFolder")} diff --git a/apps/web/src/components/bookmarks/bookmark-card.tsx b/apps/web/src/components/bookmarks/bookmark-card.tsx index 0d24f4e7..1e6e8e45 100644 --- a/apps/web/src/components/bookmarks/bookmark-card.tsx +++ b/apps/web/src/components/bookmarks/bookmark-card.tsx @@ -11,7 +11,7 @@ import { IconTag, IconFolder } from "@tabler/icons-react" -import { Bookmark, useBookmarkStore, useFolderById } from "@/store/bookmark-store" +import { Bookmark, useBookmarkStore } from "@/store/bookmark-store" import { getFaviconUrl, getDomainFromUrl } from "@/lib/favicon-utils" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -36,7 +36,6 @@ import { import { toast } from "sonner" import { useTranslations } from "next-intl" -import { useIsMobile } from "@/components/hooks/use-mobile" import { Checkbox } from "@/components/ui/checkbox" interface BookmarkCardProps { @@ -47,6 +46,8 @@ interface BookmarkCardProps { selectionMode: boolean isSelected: boolean onToggleSelect: (id: string) => void + isMobile: boolean + onTagClick?: (tag: string) => void } export default function BookmarkCard({ @@ -56,17 +57,18 @@ export default function BookmarkCard({ index, selectionMode, isSelected, - onToggleSelect + onToggleSelect, + isMobile, + onTagClick, }: BookmarkCardProps) { const t = useTranslations("Bookmarks.card") - const { deleteBookmark, folders } = useBookmarkStore() + const { deleteBookmark, addBookmark, folders } = useBookmarkStore() const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [imageError, setImageError] = useState(false) const folder = folders.find(f => f.id === bookmark.folderId) - const faviconUrl = bookmark.favicon || getFaviconUrl(bookmark.url) + const faviconUrl = getFaviconUrl(bookmark.url) const domain = getDomainFromUrl(bookmark.url) - const isMobile = useIsMobile() const handleOpenLink = useCallback(() => { window.open(bookmark.url, '_blank', 'noopener,noreferrer') @@ -78,10 +80,16 @@ export default function BookmarkCard({ }, [bookmark.url, t]) const handleDelete = useCallback(() => { + const snapshot = { ...bookmark } deleteBookmark(bookmark.id) setDeleteConfirmOpen(false) - toast.success(t("bookmarkDeleted")) - }, [bookmark.id, deleteBookmark, t]) + toast.success(t("bookmarkDeleted"), { + action: { + label: "Undo", + onClick: () => addBookmark(snapshot), + }, + }) + }, [bookmark, deleteBookmark, addBookmark, t]) const handlePrimaryAction = useCallback(() => { if (selectionMode) { @@ -97,7 +105,7 @@ export default function BookmarkCard({ {bookmark.tags.slice(0, 2).map(tag => ( - + { e.stopPropagation(); onTagClick?.(tag) }} + > {tag} ))} @@ -217,7 +230,7 @@ export default function BookmarkCard({ { e.stopPropagation(); onTagClick?.(tag) }} > {tag} diff --git a/apps/web/src/components/bookmarks/bookmark-grid.tsx b/apps/web/src/components/bookmarks/bookmark-grid.tsx index fd052c3c..17e8dc3f 100644 --- a/apps/web/src/components/bookmarks/bookmark-grid.tsx +++ b/apps/web/src/components/bookmarks/bookmark-grid.tsx @@ -6,6 +6,7 @@ import { motion } from "framer-motion" import { IconBookmarkOff } from "@tabler/icons-react" import { cn } from "@/lib/utils" import { useTranslations } from "next-intl" +import { useIsMobile } from "@/components/hooks/use-mobile" interface BookmarkGridProps { bookmarks: Bookmark[] @@ -14,6 +15,7 @@ interface BookmarkGridProps { selectionMode: boolean selectedBookmarkIds: Set onToggleSelect: (id: string) => void + onTagClick?: (tag: string) => void } export default function BookmarkGrid({ @@ -22,9 +24,11 @@ export default function BookmarkGrid({ onEdit, selectionMode, selectedBookmarkIds, - onToggleSelect + onToggleSelect, + onTagClick, }: BookmarkGridProps) { const tEmpty = useTranslations("Bookmarks.grid") + const isMobile = useIsMobile() if (bookmarks.length === 0) { return ( @@ -62,6 +66,8 @@ export default function BookmarkGrid({ selectionMode={selectionMode} isSelected={selectedBookmarkIds.has(bookmark.id)} onToggleSelect={onToggleSelect} + isMobile={isMobile} + onTagClick={onTagClick} /> ))} diff --git a/apps/web/src/components/bookmarks/bookmarks-manager.tsx b/apps/web/src/components/bookmarks/bookmarks-manager.tsx index 36636625..c044dd38 100644 --- a/apps/web/src/components/bookmarks/bookmarks-manager.tsx +++ b/apps/web/src/components/bookmarks/bookmarks-manager.tsx @@ -51,6 +51,7 @@ export default function BookmarksManager() { const [selectionMode, setSelectionMode] = useState(false) const [selectedBookmarkIds, setSelectedBookmarkIds] = useState>(new Set()) const [sortBy, setSortBy] = useState<"recent" | "alphabetical">("recent") + const [showAllTags, setShowAllTags] = useState(false) const searchInputRef = useRef(null) const bookmarkScrollRef = useRef(null) @@ -117,7 +118,7 @@ export default function BookmarksManager() { return list }, [filteredBookmarks, sortBy]) - const bookmarkScrollResetKey = `${selectedFolderId ?? 'all'}-${searchQuery}-${sortBy}-${viewMode}` + const bookmarkScrollResetKey = `${selectedFolderId ?? 'all'}-${searchQuery}-${sortBy}` const { displayCount: bmDisplayCount, sentinelRef: bmSentinelRef, hasMore: bmHasMore } = useInfiniteScroll({ totalCount: displayedBookmarks.length, resetKey: bookmarkScrollResetKey, @@ -255,7 +256,7 @@ export default function BookmarksManager() {

{t("tagsHeading")}

- {allTags.slice(0, 10).map(tag => ( + {(showAllTags ? allTags : allTags.slice(0, 10)).map(tag => ( ))} {allTags.length > 10 && ( - - {t("tagsMore", { count: allTags.length - 10 })} - + )}
@@ -519,6 +523,7 @@ export default function BookmarksManager() { selectionMode={selectionMode} selectedBookmarkIds={selectedBookmarkIds} onToggleSelect={toggleBookmarkSelected} + onTagClick={(tag) => setSearchQuery(`#${tag}`)} /> {bmHasMore && (
diff --git a/apps/web/src/components/bookmarks/folder-tree.tsx b/apps/web/src/components/bookmarks/folder-tree.tsx index 6aec7d69..593c70e7 100644 --- a/apps/web/src/components/bookmarks/folder-tree.tsx +++ b/apps/web/src/components/bookmarks/folder-tree.tsx @@ -7,8 +7,6 @@ import { IconFolderOpen, IconChevronRight, IconBookmarks, - IconInbox, - IconClock, IconDotsVertical, IconTrash, IconEdit, @@ -16,6 +14,8 @@ import { } from "@tabler/icons-react" import { useBookmarkStore, useChildFolders, useFolderBookmarkCount, BookmarkFolder } from "@/store/bookmark-store" import { cn } from "@/lib/utils" +import { useIsMobile } from "@/components/hooks/use-mobile" +import { Button } from "@/components/ui/button" import { ContextMenu, ContextMenuContent, @@ -23,6 +23,13 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { AlertDialog, AlertDialogAction, @@ -43,14 +50,9 @@ interface FolderTreeProps { export default function FolderTree({ onSelectFolder }: FolderTreeProps) { const t = useTranslations("Bookmarks.folderTree") - const { selectedFolderId, bookmarks, folders } = useBookmarkStore() - const [deleteConfirmFolder, setDeleteConfirmFolder] = useState(null) - const [editingFolder, setEditingFolder] = useState(null) - const [addingToFolder, setAddingToFolder] = useState(null) + const { selectedFolderId, bookmarks } = useBookmarkStore() const totalBookmarks = bookmarks.length - const uncategorizedCount = bookmarks.filter(b => b.folderId === null).length - const recentCount = bookmarks.filter(b => Date.now() - b.createdAt < 7 * 24 * 60 * 60 * 1000).length return (
@@ -63,33 +65,11 @@ export default function FolderTree({ onSelectFolder }: FolderTreeProps) { onClick={() => onSelectFolder(null)} /> - - - {/* Separator */}
{/* User Folders */} - - {/* Delete Confirmation */} - !open && setDeleteConfirmFolder(null)} - /> - - {/* Edit Folder Dialog */} - !open && setEditingFolder(null)} - /> - - {/* Add Subfolder Dialog */} - !open && setAddingToFolder(null)} - parentFolderId={addingToFolder || undefined} - />
) } @@ -126,6 +106,7 @@ function FolderNode({ folder, depth, onSelectFolder }: FolderNodeProps) { const hasChildren = childFolders.length > 0 const isSelected = selectedFolderId === folder.id const isExpanded = folder.isExpanded + const isMobile = useIsMobile() const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [editDialogOpen, setEditDialogOpen] = useState(false) @@ -136,85 +117,126 @@ function FolderNode({ folder, depth, onSelectFolder }: FolderNodeProps) { setDeleteConfirmOpen(false) } - return ( + const folderActions = ( <> - - - onSelectFolder(folder.id)} - > - {/* Expand/Collapse Arrow */} - + setAddSubfolderOpen(true)}> + + {t("addSubfolder")} + + setEditDialogOpen(true)}> + + {t("rename")} + + + setDeleteConfirmOpen(true)} + > + + {tCard("delete")} + + + ) + + const folderRow = ( + onSelectFolder(folder.id)} + > + {/* Expand/Collapse Arrow */} + + + {/* Folder Icon */} +
+ {isExpanded ? ( + + ) : ( + + )} +
- {/* Folder Icon */} -
- {isExpanded ? ( - - ) : ( - - )} -
+ {/* Folder Name */} + {folder.name} - {/* Folder Name */} - {folder.name} + {/* Bookmark Count */} + {bookmarkCount > 0 && ( + + {bookmarkCount} + + )} - {/* Bookmark Count */} - {bookmarkCount > 0 && ( - - {bookmarkCount} - - )} -
-
- - setAddSubfolderOpen(true)}> - - {t("addSubfolder")} - - setEditDialogOpen(true)}> - - {t("rename")} - - - setDeleteConfirmOpen(true)} - > - - {tCard("delete")} - - -
+ {/* Mobile dots menu — always visible on touch */} + {isMobile && ( + + e.stopPropagation()}> + + + + {folderActions} + + + )} + + ) + + return ( + <> + {isMobile ? ( + folderRow + ) : ( + + {folderRow} + + setAddSubfolderOpen(true)}> + + {t("addSubfolder")} + + setEditDialogOpen(true)}> + + {t("rename")} + + + setDeleteConfirmOpen(true)} + > + + {tCard("delete")} + + + + )} {/* Child Folders */} @@ -305,39 +327,3 @@ function FolderItem({ icon: Icon, label, count, isSelected, onClick }: FolderIte ) } -interface DeleteFolderDialogProps { - folder: BookmarkFolder | null - onOpenChange: (open: boolean) => void -} - -function DeleteFolderDialog({ folder, onOpenChange }: DeleteFolderDialogProps) { - const t = useTranslations("Bookmarks.folderTree") - const tCard = useTranslations("Bookmarks.card") - const { deleteFolder } = useBookmarkStore() - - const handleDelete = () => { - if (folder) { - deleteFolder(folder.id) - onOpenChange(false) - } - } - - return ( - - - - {t("deleteFolderTitle", { name: folder?.name ?? "" })} - - {t("deleteFolderDescription")} - - - - {tCard("cancel")} - - {tCard("delete")} - - - - - ) -} diff --git a/apps/web/src/store/bookmark-store.ts b/apps/web/src/store/bookmark-store.ts index b0862968..169a5dd4 100644 --- a/apps/web/src/store/bookmark-store.ts +++ b/apps/web/src/store/bookmark-store.ts @@ -424,24 +424,13 @@ export const useBookmarkStore = create()( folders: state.folders.map((f) => (f.id === id ? { ...f, isExpanded: nextExpanded } : f)), })) - void (async () => { - try { - const updated = await proxyRequest( - "PATCH", - `/api/v1/bookmark-folders/${id}/expanded`, - { isExpanded: nextExpanded } - ) - set((state) => ({ - folders: state.folders.map((f) => (f.id === updated.id ? mapFolderOut(updated) : f)), - })) - } catch (e) { - console.error("[Bookmarks] toggleFolderExpanded failed:", e) - if (syncTimer) clearTimeout(syncTimer) - syncTimer = setTimeout(() => { - void get().syncFromBackend(true) - }, 500) - } - })() + void proxyRequest( + "PATCH", + `/api/v1/bookmark-folders/${id}/expanded`, + { isExpanded: nextExpanded } + ).catch((e) => { + console.error("[Bookmarks] toggleFolderExpanded failed:", e) + }) }, // UI actions @@ -528,13 +517,19 @@ export const useFilteredBookmarks = () => { } if (searchQuery) { - const query = searchQuery.toLowerCase() - const matchesTitle = bookmark.title.toLowerCase().includes(query) - const matchesUrl = bookmark.url.toLowerCase().includes(query) - const matchesDescription = bookmark.description?.toLowerCase().includes(query) + const raw = searchQuery.toLowerCase() + const isTagSearch = raw.startsWith('#') + const query = isTagSearch ? raw.slice(1) : raw const matchesTags = bookmark.tags.some(tag => tag.toLowerCase().includes(query)) - if (!matchesTitle && !matchesUrl && !matchesDescription && !matchesTags) { - return false + if (isTagSearch) { + if (!matchesTags) return false + } else { + const matchesTitle = bookmark.title.toLowerCase().includes(query) + const matchesUrl = bookmark.url.toLowerCase().includes(query) + const matchesDescription = bookmark.description?.toLowerCase().includes(query) + if (!matchesTitle && !matchesUrl && !matchesDescription && !matchesTags) { + return false + } } }