(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 269 lines 11 kB view raw
1import React, { useState, useEffect } from "react"; 2import { X, Loader2 } from "lucide-react"; 3import { useTranslation } from "react-i18next"; 4import CollectionIcon from "../common/CollectionIcon"; 5import { ICON_MAP } from "../common/iconMap"; 6import { Theme } from "emoji-picker-react"; 7const EmojiPicker = React.lazy(() => import("emoji-picker-react")); 8import { updateCollection, type Collection } from "../../api/client"; 9import { useStore } from "@nanostores/react"; 10import { $theme } from "../../store/theme"; 11 12interface EditCollectionModalProps { 13 isOpen: boolean; 14 onClose: () => void; 15 collection: Collection; 16 onUpdate: (updatedCollection: Collection) => void; 17} 18 19export default function EditCollectionModal({ 20 isOpen, 21 onClose, 22 collection, 23 onUpdate, 24}: EditCollectionModalProps) { 25 const [name, setName] = useState(collection.name); 26 const [description, setDescription] = useState(collection.description || ""); 27 const initialIsIcon = collection.icon?.startsWith("icon:") ?? false; 28 const initialIconValue = collection.icon?.replace("icon:", "") || ""; 29 30 const [activeTab, setActiveTab] = useState<"icon" | "emoji">( 31 initialIsIcon || !collection.icon ? "icon" : "emoji", 32 ); 33 const [icon, setIcon] = useState(initialIconValue); 34 const { t } = useTranslation(); 35 const [loading, setLoading] = useState(false); 36 const [error, setError] = useState<string | null>(null); 37 const theme = useStore($theme); 38 39 useEffect(() => { 40 if (isOpen) { 41 setName(collection.name); 42 setDescription(collection.description || ""); 43 44 const isIcon = collection.icon?.startsWith("icon:") ?? false; 45 setActiveTab(isIcon || !collection.icon ? "icon" : "emoji"); 46 setIcon(collection.icon?.replace("icon:", "") || ""); 47 48 setError(null); 49 document.body.style.overflow = "hidden"; 50 } 51 return () => { 52 document.body.style.overflow = "unset"; 53 }; 54 }, [isOpen, collection]); 55 56 const handleSubmit = async (e: React.FormEvent) => { 57 e.preventDefault(); 58 if (!name.trim()) return; 59 60 try { 61 setLoading(true); 62 setError(null); 63 const iconValue = icon 64 ? ICON_MAP[icon] 65 ? `icon:${icon}` 66 : icon 67 : undefined; 68 const updated = await updateCollection( 69 collection.uri, 70 name.trim(), 71 description.trim() || undefined, 72 iconValue, 73 ); 74 75 if (updated) { 76 onUpdate(updated); 77 onClose(); 78 } else { 79 setError(t("editCollection.failedUpdate")); 80 } 81 } catch (err) { 82 console.error(err); 83 setError(t("editCollection.errorUpdating")); 84 } finally { 85 setLoading(false); 86 } 87 }; 88 89 if (!isOpen) return null; 90 91 return ( 92 <div 93 className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" 94 onClick={onClose} 95 > 96 <div 97 className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden" 98 onClick={(e) => e.stopPropagation()} 99 > 100 <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 101 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 102 {t("editCollection.title")} 103 </h2> 104 <button 105 onClick={onClose} 106 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" 107 > 108 <X size={20} /> 109 </button> 110 </div> 111 112 <div className="p-6"> 113 <form onSubmit={handleSubmit} className="space-y-4"> 114 <div> 115 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 116 {t("editCollection.nameLabel")} 117 </label> 118 <input 119 type="text" 120 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" 121 value={name} 122 onChange={(e) => setName(e.target.value)} 123 placeholder={t("editCollection.namePlaceholder")} 124 autoFocus 125 /> 126 </div> 127 128 <div> 129 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 130 {t("editCollection.descriptionLabel")} 131 </label> 132 <textarea 133 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" 134 value={description} 135 onChange={(e) => setDescription(e.target.value)} 136 placeholder={t("editCollection.descriptionPlaceholder")} 137 rows={3} 138 /> 139 </div> 140 141 <div> 142 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 143 {t("editCollection.iconLabel")} 144 </label> 145 146 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl"> 147 <button 148 type="button" 149 onClick={() => setActiveTab("icon")} 150 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${ 151 activeTab === "icon" 152 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 153 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 154 }`} 155 > 156 {t("editCollection.iconsTab")} 157 </button> 158 <button 159 type="button" 160 onClick={() => setActiveTab("emoji")} 161 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${ 162 activeTab === "emoji" 163 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 164 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 165 }`} 166 > 167 {t("editCollection.emojisTab")} 168 </button> 169 </div> 170 171 {activeTab === "icon" ? ( 172 <div className="grid grid-cols-8 gap-1.5 max-h-60 overflow-y-auto p-2 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 custom-scrollbar"> 173 {Object.keys(ICON_MAP).map((iconName) => { 174 const isSelected = icon === iconName; 175 return ( 176 <button 177 key={iconName} 178 type="button" 179 onClick={() => setIcon(isSelected ? "" : iconName)} 180 className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${ 181 isSelected 182 ? "bg-primary-600 text-white" 183 : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400" 184 }`} 185 title={iconName} 186 > 187 <CollectionIcon icon={`icon:${iconName}`} size={16} /> 188 </button> 189 ); 190 })} 191 </div> 192 ) : ( 193 <div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden"> 194 <React.Suspense 195 fallback={ 196 <div className="flex items-center justify-center h-[300px]"> 197 <Loader2 198 className="animate-spin text-surface-400" 199 size={24} 200 /> 201 </div> 202 } 203 > 204 <EmojiPicker 205 className="custom-emoji-picker" 206 onEmojiClick={(emojiData) => setIcon(emojiData.emoji)} 207 autoFocusSearch={false} 208 width="100%" 209 height={300} 210 previewConfig={{ showPreview: false }} 211 skinTonesDisabled 212 lazyLoadEmojis 213 theme={ 214 theme === "dark" || 215 (theme === "system" && 216 window.matchMedia("(prefers-color-scheme: dark)") 217 .matches) 218 ? (Theme.DARK as Theme) 219 : (Theme.LIGHT as Theme) 220 } 221 /> 222 </React.Suspense> 223 </div> 224 )} 225 226 {icon && ( 227 <p className="mt-2 text-sm text-surface-600 dark:text-surface-300 flex items-center gap-2"> 228 {t("editCollection.selected")} 229 <span className="inline-flex items-center justify-center w-8 h-8 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700"> 230 <CollectionIcon 231 icon={ICON_MAP[icon] ? `icon:${icon}` : icon} 232 size={18} 233 /> 234 </span> 235 </p> 236 )} 237 </div> 238 239 {error && ( 240 <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 241 {error} 242 </div> 243 )} 244 245 <div className="flex gap-3 pt-2"> 246 <button 247 type="button" 248 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" 249 onClick={onClose} 250 > 251 {t("editCollection.cancel")} 252 </button> 253 <button 254 type="submit" 255 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" 256 disabled={!name.trim() || loading} 257 > 258 {loading && <Loader2 size={16} className="animate-spin" />} 259 {loading 260 ? t("editCollection.saving") 261 : t("editCollection.save")} 262 </button> 263 </div> 264 </form> 265 </div> 266 </div> 267 </div> 268 ); 269}