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