(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}