From 947029ab52d9e5118679679145af37bd67ae2e04 Mon Sep 17 00:00:00 2001 From: SLcode777 Date: Fri, 2 Jan 2026 18:58:25 +0100 Subject: [PATCH] feat: added bookmark manager module --- components/modules/bookmarks-module.tsx | 680 ++++++++ package.json | 2 + pnpm-lock.yaml | 164 ++ prisma/generated/prisma/browser.ts | 5 + prisma/generated/prisma/client.ts | 5 + prisma/generated/prisma/internal/class.ts | 14 +- .../prisma/internal/prismaNamespace.ts | 95 +- .../prisma/internal/prismaNamespaceBrowser.ts | 18 + prisma/generated/prisma/models.ts | 1 + prisma/generated/prisma/models/Bookmark.ts | 1553 +++++++++++++++++ prisma/generated/prisma/models/User.ts | 194 ++ .../migration.sql | 28 + prisma/schema.prisma | 21 + src/config/modules.tsx | 11 + src/server/root.ts | 2 + src/server/routers/bookmarks.ts | 191 ++ src/server/services/metadata-extractor.ts | 77 + 17 files changed, 3058 insertions(+), 3 deletions(-) create mode 100644 components/modules/bookmarks-module.tsx create mode 100644 prisma/generated/prisma/models/Bookmark.ts create mode 100644 prisma/migrations/20260102170840_add_bookmark_model/migration.sql create mode 100644 src/server/routers/bookmarks.ts create mode 100644 src/server/services/metadata-extractor.ts diff --git a/components/modules/bookmarks-module.tsx b/components/modules/bookmarks-module.tsx new file mode 100644 index 0000000..855af4e --- /dev/null +++ b/components/modules/bookmarks-module.tsx @@ -0,0 +1,680 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/src/lib/trpc/client"; +import { + AlertCircle, + Bookmark, + Edit, + ExternalLink, + Loader2, + Plus, + Search, + Star, + Trash2, + X, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Module } from "../dashboard/module"; + +interface BookmarksModuleProps { + isPinned?: boolean; + onTogglePin?: () => void; + isAuthenticated?: boolean; + onAuthRequired?: () => void; +} + +interface BookmarkFormData { + url: string; + title: string; + description: string; + image: string; + favicon: string; + tags: string[]; + isFavorite: boolean; +} + +export function BookmarksModule({ + isPinned, + onTogglePin, + isAuthenticated = true, + onAuthRequired, +}: BookmarksModuleProps) { + // State + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [editingBookmark, setEditingBookmark] = useState(null); + const [bookmarkToDelete, setBookmarkToDelete] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [tagFilter, setTagFilter] = useState(""); + const [favoritesOnly, setFavoritesOnly] = useState(false); + const [newTag, setNewTag] = useState(""); + const [isFetchingMetadata, setIsFetchingMetadata] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + url: "", + title: "", + description: "", + image: "", + favicon: "", + tags: [], + isFavorite: false, + }); + + // Queries + const { data: bookmarks, isLoading } = api.bookmarks.getAll.useQuery( + { + search: searchQuery || undefined, + tag: tagFilter || undefined, + favoritesOnly, + }, + { enabled: isAuthenticated } + ); + + const { data: tags } = api.bookmarks.getTags.useQuery(undefined, { + enabled: isAuthenticated, + }); + + const displayBookmarks = !isAuthenticated ? [] : bookmarks; + const displayTags = !isAuthenticated ? [] : tags; + + // Mutations + const utils = api.useUtils(); + + const fetchMetadataMutation = api.bookmarks.fetchMetadata.useMutation({ + onSuccess: (metadata) => { + // Auto-populate fields but allow manual override + setFormData((prev) => ({ + ...prev, + title: metadata.title || prev.title, + description: metadata.description || prev.description, + image: metadata.image || prev.image, + favicon: metadata.favicon || prev.favicon, + })); + setIsFetchingMetadata(false); + toast.success("Metadata fetched successfully"); + }, + onError: (error) => { + setIsFetchingMetadata(false); + toast.error("Could not fetch metadata. Please enter manually."); + }, + }); + + const createMutation = api.bookmarks.create.useMutation({ + onSuccess: () => { + toast.success("Bookmark created successfully"); + utils.bookmarks.getAll.invalidate(); + utils.bookmarks.getTags.invalidate(); + handleCloseDialog(); + }, + onError: (error) => { + toast.error(error.message || "Failed to create bookmark"); + }, + }); + + const updateMutation = api.bookmarks.update.useMutation({ + onSuccess: () => { + toast.success("Bookmark updated successfully"); + utils.bookmarks.getAll.invalidate(); + utils.bookmarks.getTags.invalidate(); + handleCloseDialog(); + }, + onError: (error) => { + toast.error(error.message || "Failed to update bookmark"); + }, + }); + + const deleteMutation = api.bookmarks.delete.useMutation({ + onSuccess: () => { + toast.success("Bookmark deleted successfully"); + utils.bookmarks.getAll.invalidate(); + utils.bookmarks.getTags.invalidate(); + setDeleteDialogOpen(false); + setBookmarkToDelete(null); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete bookmark"); + }, + }); + + const toggleFavoriteMutation = api.bookmarks.toggleFavorite.useMutation({ + onSuccess: () => { + utils.bookmarks.getAll.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Failed to toggle favorite"); + }, + }); + + // Handlers + const handleFetchMetadata = () => { + if (!formData.url.trim()) { + toast.error("Please enter a URL first"); + return; + } + + try { + new URL(formData.url); // Validate URL + setIsFetchingMetadata(true); + fetchMetadataMutation.mutate({ url: formData.url }); + } catch { + toast.error("Invalid URL format"); + } + }; + + const handleOpenDialog = (bookmarkId?: string) => { + if (!isAuthenticated) { + onAuthRequired?.(); + return; + } + if (bookmarkId) { + const bookmark = displayBookmarks?.find((b) => b.id === bookmarkId); + if (bookmark) { + setFormData({ + url: bookmark.url, + title: bookmark.title, + description: bookmark.description || "", + image: bookmark.image || "", + favicon: bookmark.favicon || "", + tags: bookmark.tags, + isFavorite: bookmark.isFavorite, + }); + setEditingBookmark(bookmarkId); + } + } else { + setFormData({ + url: "", + title: "", + description: "", + image: "", + favicon: "", + tags: [], + isFavorite: false, + }); + setEditingBookmark(null); + } + setDialogOpen(true); + }; + + const handleCloseDialog = () => { + setDialogOpen(false); + setEditingBookmark(null); + setFormData({ + url: "", + title: "", + description: "", + image: "", + favicon: "", + tags: [], + isFavorite: false, + }); + setNewTag(""); + setIsFetchingMetadata(false); + }; + + const handleSubmit = () => { + if (!formData.url.trim()) { + toast.error("URL is required"); + return; + } + if (!formData.title.trim()) { + toast.error("Title is required"); + return; + } + + if (editingBookmark) { + updateMutation.mutate({ + id: editingBookmark, + data: formData, + }); + } else { + createMutation.mutate(formData); + } + }; + + const handleDeleteClick = (id: string) => { + if (!isAuthenticated) { + onAuthRequired?.(); + return; + } + setBookmarkToDelete(id); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = () => { + if (bookmarkToDelete) { + deleteMutation.mutate({ id: bookmarkToDelete }); + } + }; + + const handleToggleFavorite = (id: string) => { + if (!isAuthenticated) { + onAuthRequired?.(); + return; + } + toggleFavoriteMutation.mutate({ id }); + }; + + const handleAddTag = () => { + if (newTag.trim() && !formData.tags.includes(newTag.trim())) { + setFormData({ + ...formData, + tags: [...formData.tags, newTag.trim()], + }); + setNewTag(""); + } + }; + + const handleRemoveTag = (tag: string) => { + setFormData({ + ...formData, + tags: formData.tags.filter((t) => t !== tag), + }); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddTag(); + } + }; + + return ( + } + isPinned={isPinned} + onTogglePin={onTogglePin} + isAuthenticated={isAuthenticated} + > +
+ {/* Header Actions */} + + + {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 border-primary/50" + /> +
+ +
+ + + {tagFilter && ( + + )} +
+ + +
+ + + + {/* Bookmarks List */} +
+ {isLoading ? ( +
+ Loading bookmarks... +
+ ) : displayBookmarks && displayBookmarks.length > 0 ? ( + displayBookmarks.map((bookmark) => ( +
+
+
+ {/* Favicon */} + {bookmark.favicon && ( + { + e.currentTarget.style.display = "none"; + }} + /> + )} +
+
+

+ {bookmark.title} +

+ +
+ {bookmark.description && ( +

+ {bookmark.description} +

+ )} + + {bookmark.url} + + +
+
+
+ + +
+
+ + {/* Image preview */} + {bookmark.image && ( +
+ {bookmark.title} { + e.currentTarget.style.display = "none"; + }} + /> +
+ )} + + {/* Tags */} + {bookmark.tags.length > 0 && ( +
+ {bookmark.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ )) + ) : ( + + + + No bookmarks found. Create your first bookmark to get started! + + + )} +
+ + {/* Create/Edit Dialog */} + + + + + {editingBookmark ? "Edit Bookmark" : "New Bookmark"} + + + {editingBookmark + ? "Update your bookmark" + : "Create a new bookmark"} + + + +
+ {/* URL with Fetch Metadata Button */} +
+ +
+ + setFormData({ ...formData, url: e.target.value }) + } + placeholder="https://example.com" + className="flex-1" + /> + +
+
+ +
+ + + setFormData({ ...formData, title: e.target.value }) + } + placeholder="Bookmark title" + /> +
+ +
+ +