| import { useState, useEffect } from "react"; | |
| import { X, FolderOpen, Loader2, Check, Plus, Search } from "lucide-react"; | |
| import api from "@/lib/api"; | |
| import { toast } from "react-toastify"; | |
| interface Collection { | |
| id: number; | |
| name: string; | |
| description: string; | |
| articleCount: number; | |
| isPublic: boolean; | |
| createdAt: string; | |
| } | |
| interface Props { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| articleId: number; | |
| articleTitle: string; | |
| onSuccess?: () => void; | |
| } | |
| export function AddToCollectionModal({ isOpen, onClose, articleId, articleTitle, onSuccess }: Props) { | |
| const [collections, setCollections] = useState<Collection[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [saving, setSaving] = useState<number | null>(null); | |
| const [selectedCollections, setSelectedCollections] = useState<Set<number>>(new Set()); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [showCreateForm, setShowCreateForm] = useState(false); | |
| const [newCollectionName, setNewCollectionName] = useState(""); | |
| const [newCollectionDesc, setNewCollectionDesc] = useState(""); | |
| const [creating, setCreating] = useState(false); | |
| // Fetch user's collections when modal opens | |
| useEffect(() => { | |
| if (isOpen) { | |
| fetchCollections(); | |
| fetchArticleCollections(); | |
| } | |
| }, [isOpen, articleId]); | |
| const fetchCollections = async () => { | |
| setLoading(true); | |
| try { | |
| const response = await api.get("/collections"); | |
| setCollections(response.data || []); | |
| } catch (err: any) { | |
| console.error("Failed to fetch collections:", err); | |
| toast.error("Could not load collections"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const fetchArticleCollections = async () => { | |
| try { | |
| // Fetch which collections already contain this article | |
| const response = await api.get(`/articles/${articleId}/collections`); | |
| const collectionIds = response.data?.map((c: any) => c.id) || []; | |
| setSelectedCollections(new Set(collectionIds)); | |
| } catch (err) { | |
| console.error("Failed to fetch article collections:", err); | |
| } | |
| }; | |
| const handleToggleCollection = async (collectionId: number) => { | |
| const isSelected = selectedCollections.has(collectionId); | |
| setSaving(collectionId); | |
| try { | |
| if (isSelected) { | |
| // Remove from collection | |
| await api.delete(`/collections/${collectionId}/articles/${articleId}`); | |
| setSelectedCollections(prev => { | |
| const newSet = new Set(prev); | |
| newSet.delete(collectionId); | |
| return newSet; | |
| }); | |
| toast.success("Removed from collection"); | |
| } else { | |
| // Add to collection | |
| await api.post(`/collections/${collectionId}/articles`, { articleId }); | |
| setSelectedCollections(prev => new Set([...prev, collectionId])); | |
| toast.success("Added to collection"); | |
| } | |
| onSuccess?.(); | |
| } catch (err: any) { | |
| console.error("Failed to update collection:", err); | |
| toast.error(err.response?.data?.error || "Operation failed"); | |
| } finally { | |
| setSaving(null); | |
| } | |
| }; | |
| const handleCreateCollection = async () => { | |
| if (!newCollectionName.trim()) { | |
| toast.warning("Please enter a collection name"); | |
| return; | |
| } | |
| setCreating(true); | |
| try { | |
| const response = await api.post("/collections", { | |
| name: newCollectionName.trim(), | |
| description: newCollectionDesc.trim() || undefined, | |
| isPublic: false, | |
| }); | |
| const newCollection = response.data; | |
| setCollections(prev => [...prev, newCollection]); | |
| setSelectedCollections(prev => new Set([...prev, newCollection.id])); | |
| setShowCreateForm(false); | |
| setNewCollectionName(""); | |
| setNewCollectionDesc(""); | |
| toast.success("Collection created and article added"); | |
| onSuccess?.(); | |
| } catch (err: any) { | |
| console.error("Failed to create collection:", err); | |
| toast.error(err.response?.data?.error || "Failed to create collection"); | |
| } finally { | |
| setCreating(false); | |
| } | |
| }; | |
| const filteredCollections = collections.filter((collection) => | |
| collection.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| collection.description?.toLowerCase().includes(searchQuery.toLowerCase()) | |
| ); | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> | |
| <div className="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 shadow-2xl"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 p-5"> | |
| <div> | |
| <h2 className="text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"> | |
| Save to Collection | |
| </h2> | |
| <p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-1"> | |
| {articleTitle} | |
| </p> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition" | |
| > | |
| <X className="w-5 h-5 text-gray-500" /> | |
| </button> | |
| </div> | |
| {/* Search */} | |
| <div className="p-4 border-b border-gray-200 dark:border-gray-700"> | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| <input | |
| type="text" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| placeholder="Search your collections..." | |
| className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary dark:bg-gray-700 transition" | |
| /> | |
| </div> | |
| </div> | |
| {/* Collections List */} | |
| <div className="max-h-80 overflow-y-auto p-2"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center py-12"> | |
| <Loader2 className="w-8 h-8 animate-spin text-primary" /> | |
| <span className="ml-3 text-gray-500">Loading collections...</span> | |
| </div> | |
| ) : filteredCollections.length === 0 ? ( | |
| <div className="text-center py-12"> | |
| <FolderOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" /> | |
| <p className="text-gray-500"> | |
| {searchQuery ? "No collections match your search" : "You don't have any collections yet"} | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-1"> | |
| {filteredCollections.map((collection) => { | |
| const isSelected = selectedCollections.has(collection.id); | |
| const isSaving = saving === collection.id; | |
| return ( | |
| <button | |
| key={collection.id} | |
| onClick={() => handleToggleCollection(collection.id)} | |
| disabled={isSaving} | |
| className={` | |
| w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left | |
| ${isSelected | |
| ? "bg-primary/10 border border-primary" | |
| : "hover:bg-gray-50 dark:hover:bg-gray-700/50 border border-transparent" | |
| } | |
| `} | |
| > | |
| <div className="flex-shrink-0"> | |
| <div className={`p-2 rounded-lg ${isSelected ? "bg-primary/20" : "bg-gray-100 dark:bg-gray-700"}`}> | |
| {isSaving ? ( | |
| <Loader2 className="w-5 h-5 animate-spin text-primary" /> | |
| ) : isSelected ? ( | |
| <Check className="w-5 h-5 text-primary" /> | |
| ) : ( | |
| <FolderOpen className="w-5 h-5 text-gray-500" /> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <h3 className="font-medium text-gray-900 dark:text-gray-100"> | |
| {collection.name} | |
| </h3> | |
| {collection.description && ( | |
| <p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1"> | |
| {collection.description} | |
| </p> | |
| )} | |
| <p className="text-xs text-gray-400 mt-0.5"> | |
| {collection.articleCount} paper{collection.articleCount !== 1 ? "s" : ""} | |
| </p> | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* Create New Collection */} | |
| {!showCreateForm ? ( | |
| <button | |
| onClick={() => setShowCreateForm(true)} | |
| className="mx-4 mb-4 flex items-center gap-2 px-4 py-2 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 text-gray-500 hover:text-primary hover:border-primary transition w-full justify-center" | |
| > | |
| <Plus className="w-4 h-4" /> | |
| Create new collection | |
| </button> | |
| ) : ( | |
| <div className="mx-4 mb-4 p-4 border rounded-lg border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50"> | |
| <input | |
| type="text" | |
| value={newCollectionName} | |
| onChange={(e) => setNewCollectionName(e.target.value)} | |
| placeholder="Collection name" | |
| className="w-full px-3 py-2 border rounded-lg mb-2 focus:ring-2 focus:ring-primary dark:bg-gray-800" | |
| autoFocus | |
| /> | |
| <textarea | |
| value={newCollectionDesc} | |
| onChange={(e) => setNewCollectionDesc(e.target.value)} | |
| placeholder="Description (optional)" | |
| rows={2} | |
| className="w-full px-3 py-2 border rounded-lg mb-3 focus:ring-2 focus:ring-primary dark:bg-gray-800 resize-none" | |
| /> | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={() => setShowCreateForm(false)} | |
| className="flex-1 px-3 py-1.5 rounded-lg border hover:bg-gray-100 dark:hover:bg-gray-600 transition" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={handleCreateCollection} | |
| disabled={creating || !newCollectionName.trim()} | |
| className="flex-1 px-3 py-1.5 rounded-lg bg-primary text-white hover:bg-primary/90 transition disabled:opacity-50 flex items-center justify-center gap-2" | |
| > | |
| {creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} | |
| Create | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Footer */} | |
| <div className="p-4 border-t border-gray-200 dark:border-gray-700"> | |
| <button | |
| onClick={onClose} | |
| className="w-full px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition" | |
| > | |
| Done | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 11.4 kB
- Xet hash:
- e0c0084a019dde6e137dbaf63f6f97a2316ae8d8568ac6df449efe1049740807
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.