(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
14 kB · 335 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336import React, { useState, useEffect, useCallback } from "react";import { X, Plus, Check, Loader2, ChevronRight, FolderPlus,} from "lucide-react";import CollectionIcon from "../common/CollectionIcon";import { ICON_MAP } from "../common/iconMap";import { useStore } from "@nanostores/react";import { $user } from "../../store/auth";import { getCollections, addCollectionItem, createCollection, getCollectionsContaining, type Collection,} from "../../api/client";
interface AddToCollectionModalProps { isOpen: boolean; onClose: () => void; annotationUri: string;}
export default function AddToCollectionModal({ isOpen, onClose, annotationUri,}: AddToCollectionModalProps) { const user = useStore($user); const [collections, setCollections] = useState<Collection[]>([]); const [loading, setLoading] = useState(true); const [addingTo, setAddingTo] = useState<string | null>(null); const [addedTo, setAddedTo] = useState<Set<string>>(new Set()); const [error, setError] = useState<string | null>(null);
const [showNewForm, setShowNewForm] = useState(false); const [newName, setNewName] = useState(""); const [newDescription, setNewDescription] = useState(""); const [newIcon, setNewIcon] = useState(""); const [creating, setCreating] = useState(false);
useEffect(() => { if (isOpen) { document.body.style.overflow = "hidden"; } return () => { document.body.style.overflow = "unset"; }; }, [isOpen]);
const loadCollections = useCallback(async () => { if (!user) return; try { setLoading(true); const data = await getCollections(user.did); setCollections(data); } catch (err) { console.error(err); setError("Failed to load collections"); } finally { setLoading(false); } }, [user]);
useEffect(() => { if (isOpen && user) { loadCollections(); setError(null); getCollectionsContaining(annotationUri).then((uris) => { setAddedTo(new Set(uris)); }); } }, [isOpen, user, loadCollections, annotationUri]);
const handleAdd = async (collectionUri: string) => { if (addedTo.has(collectionUri)) return;
try { setAddingTo(collectionUri); await addCollectionItem(collectionUri, annotationUri); setAddedTo((prev) => new Set([...prev, collectionUri])); } catch (err) { console.error(err); setError("Failed to add to collection"); } finally { setAddingTo(null); } };
const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); if (!newName.trim()) return; try { setCreating(true); const iconValue = newIcon ? `icon:${newIcon}` : undefined; const newCollection = await createCollection( newName.trim(), newDescription.trim() || undefined, iconValue, ); if (newCollection) { setCollections((prev) => [newCollection, ...prev]); setNewName(""); setNewDescription(""); setNewIcon(""); setShowNewForm(false); } } catch (err) { console.error(err); setError("Failed to create collection"); } finally { setCreating(false); } };
if (!isOpen) return null;
return ( <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose} > <div className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()} > <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> Add to Collection </h2> <button onClick={onClose} className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" > <X size={20} /> </button> </div>
<div className="px-6 pb-6 pt-4"> {loading ? ( <div className="text-center py-10"> <Loader2 size={32} className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-3" /> <p className="text-surface-500 dark:text-surface-400 font-medium"> Loading collections... </p> </div> ) : showNewForm ? ( <form onSubmit={handleCreate} className="space-y-4"> <div> <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> Collection name </label> <input type="text" className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="My Collection" autoFocus /> </div>
<div> <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> Description (optional) </label> <textarea className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none" value={newDescription} onChange={(e) => setNewDescription(e.target.value)} placeholder="What's this collection about?" rows={2} /> </div>
<div> <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> Icon </label> <div className="grid grid-cols-8 gap-1.5 max-h-32 overflow-y-auto p-2 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700"> {Object.keys(ICON_MAP).map((iconName) => { const isSelected = newIcon === iconName; return ( <button key={iconName} type="button" onClick={() => setNewIcon(isSelected ? "" : iconName)} className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${ isSelected ? "bg-primary-600 text-white" : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400" }`} title={iconName} > <CollectionIcon icon={`icon:${iconName}`} size={16} /> </button> ); })} </div> {newIcon && ( <p className="mt-1 text-xs text-surface-500"> Selected: {newIcon} </p> )} </div>
{error && ( <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> {error} </div> )}
<div className="flex gap-3 pt-2"> <button type="button" className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" onClick={() => { setShowNewForm(false); setNewDescription(""); setNewIcon(""); setError(null); }} > Back </button> <button type="submit" className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" disabled={!newName.trim() || creating} > {creating && <Loader2 size={16} className="animate-spin" />} {creating ? "Creating..." : "Create"} </button> </div> </form> ) : ( <div> {error && ( <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> {error} </div> )}
<button className="w-full flex items-center gap-4 p-4 bg-white dark:bg-surface-800 border-2 border-primary-100 dark:border-primary-900/50 hover:border-primary-300 dark:hover:border-primary-700 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left mb-4" onClick={() => setShowNewForm(true)} > <div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-full flex items-center justify-center text-primary-600 dark:text-primary-400 flex-shrink-0"> <FolderPlus size={20} /> </div> <div className="flex-1 min-w-0"> <h3 className="font-bold text-surface-900 dark:text-white group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors"> New Collection </h3> <span className="text-sm text-surface-500 dark:text-surface-400"> Create a new collection </span> </div> <ChevronRight size={20} className="text-surface-300 dark:text-surface-600 group-hover:text-primary-500 dark:group-hover:text-primary-400" /> </button>
{collections.length === 0 ? ( <div className="text-center py-6"> <p className="text-surface-500 dark:text-surface-400"> No collections yet </p> </div> ) : ( <div className="space-y-2 max-h-[300px] overflow-y-auto"> {collections.map((col) => { const isAdded = addedTo.has(col.uri); const isAdding = addingTo === col.uri;
return ( <button key={col.uri} onClick={() => handleAdd(col.uri)} disabled={isAdding || isAdded} className="w-full flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800/50 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors text-left group disabled:opacity-70" > <div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-surface-700 rounded-full shadow-sm text-surface-600 dark:text-surface-300"> <CollectionIcon icon={col.icon} size={18} /> </div> <div className="flex-1 min-w-0"> <h3 className="text-sm font-bold text-surface-900 dark:text-white"> {col.name} </h3> {col.description && ( <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1"> {col.description} </p> )} </div> {isAdding ? ( <Loader2 size={16} className="animate-spin text-surface-400" /> ) : isAdded ? ( <Check size={16} className="text-green-500" /> ) : ( <Plus size={16} className="text-surface-300 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300" /> )} </button> ); })} </div> )}
<button onClick={onClose} className="w-full mt-4 py-3 bg-surface-900 dark:bg-white text-white dark:text-surface-900 font-semibold rounded-xl hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors" > Done </button> </div> )} </div> </div> </div> );}