tahamajs/IE / Projects /front /src /components /collections /AddToCollectionModal.tsx
tahamajs's picture
download
raw
11.4 kB
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.