Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 345 lines 13 kB view raw
1import React, { useState, useEffect } from "react"; 2import { X, ShieldAlert } from "lucide-react"; 3import { 4 updateAnnotation, 5 updateHighlight, 6 updateBookmark, 7 sessionAtom, 8 getUserTags, 9 getTrendingTags, 10} from "../../api/client"; 11import type { AnnotationItem, ContentLabelValue } from "../../types"; 12import TagInput from "../ui/TagInput"; 13 14const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 15 { value: "sexual", label: "Sexual" }, 16 { value: "nudity", label: "Nudity" }, 17 { value: "violence", label: "Violence" }, 18 { value: "gore", label: "Gore" }, 19 { value: "spam", label: "Spam" }, 20 { value: "misleading", label: "Misleading" }, 21]; 22 23const HIGHLIGHT_COLORS = [ 24 { value: "yellow", bg: "bg-yellow-400", ring: "ring-yellow-500" }, 25 { value: "green", bg: "bg-green-400", ring: "ring-green-500" }, 26 { value: "blue", bg: "bg-blue-400", ring: "ring-blue-500" }, 27 { value: "red", bg: "bg-red-400", ring: "ring-red-500" }, 28]; 29 30interface EditItemModalProps { 31 isOpen: boolean; 32 onClose: () => void; 33 item: AnnotationItem; 34 type: "annotation" | "highlight" | "bookmark"; 35 onSaved?: (item: AnnotationItem) => void; 36} 37 38export default function EditItemModal({ 39 isOpen, 40 onClose, 41 item, 42 type, 43 onSaved, 44}: EditItemModalProps) { 45 if (!isOpen) return null; 46 return ( 47 <EditItemModalContent 48 key={item.uri || item.id || JSON.stringify(item)} 49 item={item} 50 type={type} 51 onClose={onClose} 52 onSaved={onSaved} 53 /> 54 ); 55} 56 57function EditItemModalContent({ 58 item, 59 type, 60 onClose, 61 onSaved, 62}: Omit<EditItemModalProps, "isOpen">) { 63 const [text, setText] = useState(item.body?.value || ""); 64 const [tags, setTags] = useState<string[]>(item.tags || []); 65 const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 66 const [color, setColor] = useState(item.color || "yellow"); 67 const [title, setTitle] = useState(item.title || item.target?.title || ""); 68 const [description, setDescription] = useState(item.description || ""); 69 const existingLabels = (item.labels || []) 70 .filter((l) => l.src === item.author?.did) 71 .map((l) => l.val as ContentLabelValue); 72 const [selfLabels, setSelfLabels] = 73 useState<ContentLabelValue[]>(existingLabels); 74 const [showLabelPicker, setShowLabelPicker] = useState( 75 existingLabels.length > 0, 76 ); 77 const [saving, setSaving] = useState(false); 78 const [error, setError] = useState<string | null>(null); 79 80 useEffect(() => { 81 const session = sessionAtom.get(); 82 if (session?.did) { 83 Promise.all([ 84 getUserTags(session.did).catch(() => [] as string[]), 85 getTrendingTags(50) 86 .then((tags) => tags.map((t) => t.tag)) 87 .catch(() => [] as string[]), 88 ]).then(([userTags, trendingTags]) => { 89 const seen = new Set(userTags); 90 const merged = [...userTags]; 91 for (const t of trendingTags) { 92 if (!seen.has(t)) { 93 merged.push(t); 94 seen.add(t); 95 } 96 } 97 setTagSuggestions(merged); 98 }); 99 } 100 }, []); 101 102 const toggleLabel = (val: ContentLabelValue) => { 103 setSelfLabels((prev) => 104 prev.includes(val) ? prev.filter((l) => l !== val) : [...prev, val], 105 ); 106 }; 107 108 const handleSave = async () => { 109 setSaving(true); 110 setError(null); 111 let success = false; 112 const labels = selfLabels.length > 0 ? selfLabels : []; 113 114 try { 115 if (type === "annotation") { 116 success = await updateAnnotation( 117 item.uri, 118 text, 119 tags.length > 0 ? tags : undefined, 120 labels, 121 ); 122 } else if (type === "highlight") { 123 success = await updateHighlight( 124 item.uri, 125 color, 126 tags.length > 0 ? tags : undefined, 127 labels, 128 ); 129 } else if (type === "bookmark") { 130 success = await updateBookmark( 131 item.uri, 132 title || undefined, 133 description || undefined, 134 tags.length > 0 ? tags : undefined, 135 labels, 136 ); 137 } 138 } catch (e) { 139 console.error("Edit save error:", e); 140 setError(e instanceof Error ? e.message : "Failed to save"); 141 setSaving(false); 142 return; 143 } 144 145 setSaving(false); 146 if (!success) { 147 setError("Failed to save changes. Please try again."); 148 return; 149 } 150 const updated = { ...item }; 151 if (type === "annotation") { 152 updated.body = { type: "TextualBody", value: text, format: "text/plain" }; 153 } else if (type === "highlight") { 154 updated.color = color; 155 } else if (type === "bookmark") { 156 updated.title = title; 157 updated.description = description; 158 } 159 updated.tags = tags; 160 const otherLabels = (item.labels || []).filter( 161 (l) => l.src !== item.author?.did, 162 ); 163 const newSelfLabels = selfLabels.map((val) => ({ 164 val, 165 src: item.author?.did || "", 166 scope: "content" as const, 167 })); 168 updated.labels = [...otherLabels, ...newSelfLabels]; 169 onSaved?.(updated); 170 onClose(); 171 }; 172 173 return ( 174 <div 175 className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" 176 onClick={onClose} 177 > 178 <div 179 className="bg-white dark:bg-surface-900 rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" 180 onClick={(e) => e.stopPropagation()} 181 > 182 <div className="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700"> 183 <h3 className="text-lg font-semibold text-surface-900 dark:text-surface-100"> 184 Edit{" "} 185 {type === "annotation" 186 ? "Annotation" 187 : type === "highlight" 188 ? "Highlight" 189 : "Bookmark"} 190 </h3> 191 <button 192 onClick={onClose} 193 className="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 194 > 195 <X size={18} /> 196 </button> 197 </div> 198 199 <div className="px-5 py-4 space-y-4"> 200 {type === "annotation" && ( 201 <div> 202 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 203 Text 204 </label> 205 <textarea 206 value={text} 207 onChange={(e) => setText(e.target.value)} 208 rows={4} 209 maxLength={3000} 210 className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" 211 placeholder="Write your annotation..." 212 /> 213 <p className="text-xs text-surface-400 mt-1"> 214 {text.length}/3000 215 </p> 216 </div> 217 )} 218 219 {type === "highlight" && ( 220 <div> 221 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 222 Color 223 </label> 224 <div className="flex gap-2"> 225 {HIGHLIGHT_COLORS.map((c) => ( 226 <button 227 key={c.value} 228 onClick={() => setColor(c.value)} 229 className={`w-8 h-8 rounded-full ${c.bg} transition-all ${ 230 color === c.value 231 ? `ring-2 ${c.ring} ring-offset-2 dark:ring-offset-surface-900 scale-110` 232 : "opacity-60 hover:opacity-100" 233 }`} 234 title={c.value} 235 /> 236 ))} 237 </div> 238 {item.target?.selector?.exact && ( 239 <blockquote className="mt-3 pl-3 py-2 border-l-2 border-surface-300 dark:border-surface-600 text-sm italic text-surface-500 dark:text-surface-400"> 240 {item.target.selector.exact} 241 </blockquote> 242 )} 243 </div> 244 )} 245 246 {type === "bookmark" && ( 247 <> 248 <div> 249 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 250 Title 251 </label> 252 <input 253 type="text" 254 value={title} 255 onChange={(e) => setTitle(e.target.value)} 256 className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" 257 placeholder="Bookmark title" 258 /> 259 </div> 260 <div> 261 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 262 Description 263 </label> 264 <textarea 265 value={description} 266 onChange={(e) => setDescription(e.target.value)} 267 rows={3} 268 className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" 269 placeholder="Optional description..." 270 /> 271 </div> 272 </> 273 )} 274 275 <div> 276 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 277 Tags 278 </label> 279 <TagInput 280 tags={tags} 281 onChange={setTags} 282 suggestions={tagSuggestions} 283 placeholder="Add a tag..." 284 /> 285 </div> 286 287 <div> 288 <button 289 onClick={() => setShowLabelPicker(!showLabelPicker)} 290 className={`flex items-center gap-2 text-sm font-medium transition-colors ${ 291 showLabelPicker || selfLabels.length > 0 292 ? "text-amber-600 dark:text-amber-400" 293 : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200" 294 }`} 295 > 296 <ShieldAlert size={16} /> 297 Content Warning 298 {selfLabels.length > 0 && ( 299 <span className="text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-1.5 py-0.5 rounded-full"> 300 {selfLabels.length} 301 </span> 302 )} 303 </button> 304 {showLabelPicker && ( 305 <div className="flex flex-wrap gap-1.5 mt-2"> 306 {SELF_LABEL_OPTIONS.map((opt) => ( 307 <button 308 key={opt.value} 309 onClick={() => toggleLabel(opt.value)} 310 className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${ 311 selfLabels.includes(opt.value) 312 ? "bg-amber-100 dark:bg-amber-900/40 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200" 313 : "bg-surface-50 dark:bg-surface-800 border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:border-amber-300 dark:hover:border-amber-700" 314 }`} 315 > 316 {opt.label} 317 </button> 318 ))} 319 </div> 320 )} 321 </div> 322 </div> 323 324 <div className="px-5 py-4 border-t border-surface-200 dark:border-surface-700"> 325 {error && <p className="text-sm text-red-500 mb-3">{error}</p>} 326 <div className="flex items-center justify-end gap-2"> 327 <button 328 onClick={onClose} 329 className="px-4 py-2 rounded-xl text-sm font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 330 > 331 Cancel 332 </button> 333 <button 334 onClick={handleSave} 335 disabled={saving || (type === "annotation" && !text.trim())} 336 className="px-4 py-2 rounded-xl bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 337 > 338 {saving ? "Saving..." : "Save"} 339 </button> 340 </div> 341 </div> 342 </div> 343 </div> 344 ); 345}