Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}