Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 294 lines 10 kB view raw
1import React, { useState, useEffect } from "react"; 2import { 3 createAnnotation, 4 createHighlight, 5 sessionAtom, 6 getUserTags, 7 getTrendingTags, 8} from "../../api/client"; 9import type { Selector, ContentLabelValue } from "../../types"; 10import { X, ShieldAlert } from "lucide-react"; 11import TagInput from "../ui/TagInput"; 12 13const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 14 { value: "sexual", label: "Sexual" }, 15 { value: "nudity", label: "Nudity" }, 16 { value: "violence", label: "Violence" }, 17 { value: "gore", label: "Gore" }, 18 { value: "spam", label: "Spam" }, 19 { value: "misleading", label: "Misleading" }, 20]; 21 22interface ComposerProps { 23 url: string; 24 selector?: Selector | null; 25 onSuccess?: () => void; 26 onCancel?: () => void; 27} 28 29export default function Composer({ 30 url, 31 selector: initialSelector, 32 onSuccess, 33 onCancel, 34}: ComposerProps) { 35 const [text, setText] = useState(""); 36 const [quoteText, setQuoteText] = useState(""); 37 const [tags, setTags] = useState<string[]>([]); 38 const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 39 const [selector, setSelector] = useState(initialSelector); 40 const [loading, setLoading] = useState(false); 41 const [error, setError] = useState<string | null>(null); 42 const [showQuoteInput, setShowQuoteInput] = useState(false); 43 const [selfLabels, setSelfLabels] = useState<ContentLabelValue[]>([]); 44 const [showLabelPicker, setShowLabelPicker] = useState(false); 45 46 useEffect(() => { 47 const session = sessionAtom.get(); 48 if (session?.did) { 49 Promise.all([ 50 getUserTags(session.did).catch(() => [] as string[]), 51 getTrendingTags(50) 52 .then((tags) => tags.map((t) => t.tag)) 53 .catch(() => [] as string[]), 54 ]).then(([userTags, trendingTags]) => { 55 const seen = new Set(userTags); 56 const merged = [...userTags]; 57 for (const t of trendingTags) { 58 if (!seen.has(t)) { 59 merged.push(t); 60 seen.add(t); 61 } 62 } 63 setTagSuggestions(merged); 64 }); 65 } 66 }, []); 67 68 const highlightedText = 69 selector?.type === "TextQuoteSelector" ? selector.exact : null; 70 71 const handleSubmit = async (e: React.FormEvent) => { 72 e.preventDefault(); 73 if (!text.trim() && !highlightedText && !quoteText.trim()) return; 74 75 try { 76 setLoading(true); 77 setError(null); 78 79 let finalSelector = selector; 80 if (!finalSelector && quoteText.trim()) { 81 finalSelector = { 82 type: "TextQuoteSelector", 83 exact: quoteText.trim(), 84 }; 85 } 86 87 const tagList = tags.filter(Boolean); 88 89 if (!text.trim()) { 90 if (!finalSelector) throw new Error("No text selected"); 91 await createHighlight({ 92 url, 93 selector: finalSelector as { 94 exact: string; 95 prefix?: string; 96 suffix?: string; 97 }, 98 color: "yellow", 99 tags: tagList, 100 labels: selfLabels.length > 0 ? selfLabels : undefined, 101 }); 102 } else { 103 await createAnnotation({ 104 url, 105 text: text.trim(), 106 selector: finalSelector || undefined, 107 tags: tagList, 108 labels: selfLabels.length > 0 ? selfLabels : undefined, 109 }); 110 } 111 112 setText(""); 113 setQuoteText(""); 114 setTags([]); 115 setSelector(null); 116 if (onSuccess) onSuccess(); 117 } catch (err) { 118 setError( 119 (err instanceof Error ? err.message : "Unknown error") || 120 "Failed to post", 121 ); 122 } finally { 123 setLoading(false); 124 } 125 }; 126 127 const handleRemoveSelector = () => { 128 setSelector(null); 129 setQuoteText(""); 130 setShowQuoteInput(false); 131 }; 132 133 return ( 134 <form onSubmit={handleSubmit} className="flex flex-col gap-4"> 135 <div className="flex items-center justify-between"> 136 <h3 className="text-lg font-bold text-surface-900 dark:text-white"> 137 New Annotation 138 </h3> 139 {url && ( 140 <div className="text-xs text-surface-400 dark:text-surface-500 max-w-[200px] truncate"> 141 {url} 142 </div> 143 )} 144 </div> 145 146 {highlightedText && ( 147 <div className="relative p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg"> 148 <button 149 type="button" 150 className="absolute top-2 right-2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300" 151 onClick={handleRemoveSelector} 152 > 153 <X size={16} /> 154 </button> 155 <blockquote className="italic text-surface-600 dark:text-surface-300 border-l-2 border-primary-400 dark:border-primary-500 pl-3 text-sm"> 156 "{highlightedText}" 157 </blockquote> 158 </div> 159 )} 160 161 {!highlightedText && ( 162 <> 163 {!showQuoteInput ? ( 164 <button 165 type="button" 166 className="text-left text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium py-1" 167 onClick={() => setShowQuoteInput(true)} 168 > 169 + Add a quote from the page 170 </button> 171 ) : ( 172 <div className="flex flex-col gap-2"> 173 <textarea 174 value={quoteText} 175 onChange={(e) => setQuoteText(e.target.value)} 176 placeholder="Paste or type the text you're annotating..." 177 className="w-full text-sm p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none" 178 rows={2} 179 /> 180 <div className="flex justify-end"> 181 <button 182 type="button" 183 className="text-xs text-red-500 dark:text-red-400 font-medium" 184 onClick={handleRemoveSelector} 185 > 186 Remove Quote 187 </button> 188 </div> 189 </div> 190 )} 191 </> 192 )} 193 194 <textarea 195 value={text} 196 onChange={(e) => setText(e.target.value)} 197 placeholder={ 198 highlightedText || quoteText 199 ? "Add your comment..." 200 : "Write your annotation..." 201 } 202 className="w-full p-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none min-h-[100px] resize-none" 203 maxLength={3000} 204 disabled={loading} 205 /> 206 207 <TagInput 208 tags={tags} 209 onChange={setTags} 210 suggestions={tagSuggestions} 211 placeholder="Add tags..." 212 disabled={loading} 213 /> 214 215 <div> 216 <button 217 type="button" 218 onClick={() => setShowLabelPicker(!showLabelPicker)} 219 className="flex items-center gap-1.5 text-sm text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors" 220 > 221 <ShieldAlert size={14} /> 222 <span> 223 Content Warning 224 {selfLabels.length > 0 ? ` (${selfLabels.length})` : ""} 225 </span> 226 </button> 227 228 {showLabelPicker && ( 229 <div className="mt-2 flex flex-wrap gap-1.5"> 230 {SELF_LABEL_OPTIONS.map((opt) => ( 231 <button 232 key={opt.value} 233 type="button" 234 onClick={() => 235 setSelfLabels((prev) => 236 prev.includes(opt.value) 237 ? prev.filter((v) => v !== opt.value) 238 : [...prev, opt.value], 239 ) 240 } 241 className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${ 242 selfLabels.includes(opt.value) 243 ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 ring-1 ring-amber-300 dark:ring-amber-700" 244 : "bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700" 245 }`} 246 > 247 {opt.label} 248 </button> 249 ))} 250 </div> 251 )} 252 </div> 253 254 <div className="flex items-center justify-between pt-2"> 255 <span 256 className={ 257 text.length > 2900 258 ? "text-red-500 dark:text-red-400 text-xs font-medium" 259 : "text-surface-400 dark:text-surface-500 text-xs" 260 } 261 > 262 {text.length}/3000 263 </span> 264 <div className="flex items-center gap-2"> 265 {onCancel && ( 266 <button 267 type="button" 268 className="text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 px-3 py-1.5" 269 onClick={onCancel} 270 disabled={loading} 271 > 272 Cancel 273 </button> 274 )} 275 <button 276 type="submit" 277 className="bg-primary-600 hover:bg-primary-700 text-white font-medium px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 text-sm" 278 disabled={ 279 loading || (!text.trim() && !highlightedText && !quoteText.trim()) 280 } 281 > 282 {loading ? "..." : "Post"} 283 </button> 284 </div> 285 </div> 286 287 {error && ( 288 <div className="text-red-500 dark:text-red-400 text-sm text-center bg-red-50 dark:bg-red-900/20 py-2 rounded-lg"> 289 {error} 290 </div> 291 )} 292 </form> 293 ); 294}