Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect } from 'react';
2import { sendMessage } from '@/utils/messaging';
3import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage';
4import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types';
5import CollectionIcon from '@/components/CollectionIcon';
6import TagInput from '@/components/TagInput';
7import {
8 Settings,
9 ExternalLink,
10 Bookmark as BookmarkIcon,
11 Highlighter,
12 X,
13 Sun,
14 Moon,
15 Monitor,
16 Check,
17 Globe,
18 ChevronRight,
19 Sparkles,
20 FolderPlus,
21 Folder,
22 PenTool,
23 Eye,
24 Send,
25} from 'lucide-react';
26
27type Tab = 'page' | 'bookmarks' | 'highlights' | 'collections';
28type PageFilter = 'all' | 'annotations' | 'highlights';
29
30export function App() {
31 const [session, setSession] = useState<MarginSession | null>(null);
32 const [loading, setLoading] = useState(true);
33 const [activeTab, setActiveTab] = useState<Tab>('page');
34 const [pageFilter, setPageFilter] = useState<PageFilter>('all');
35 const [annotations, setAnnotations] = useState<Annotation[]>([]);
36 const [pageHighlights, setPageHighlights] = useState<Annotation[]>([]);
37 const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
38 const [highlights, setHighlights] = useState<Highlight[]>([]);
39 const [collections, setCollections] = useState<Collection[]>([]);
40 const [loadingAnnotations, setLoadingAnnotations] = useState(false);
41 const [loadingBookmarks, setLoadingBookmarks] = useState(false);
42 const [loadingHighlights, setLoadingHighlights] = useState(false);
43 const [loadingCollections, setLoadingCollections] = useState(false);
44 const [collectionModalItem, setCollectionModalItem] = useState<string | null>(null);
45 const [addingToCollection, setAddingToCollection] = useState<string | null>(null);
46 const [containingCollections, setContainingCollections] = useState<Set<string>>(new Set());
47 const [currentUrl, setCurrentUrl] = useState('');
48 const [currentTitle, setCurrentTitle] = useState('');
49 const [text, setText] = useState('');
50 const [posting, setPosting] = useState(false);
51 const [bookmarking, setBookmarking] = useState(false);
52 const [bookmarked, setBookmarked] = useState(false);
53 const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
54 const [showSettings, setShowSettings] = useState(false);
55 const [apiUrl, setApiUrl] = useState('https://margin.at');
56 const [overlayEnabled, setOverlayEnabled] = useState(true);
57 const [tags, setTags] = useState<string[]>([]);
58 const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
59
60 useEffect(() => {
61 checkSession();
62 loadCurrentTab();
63 loadTheme();
64 loadSettings();
65 }, []);
66
67 useEffect(() => {
68 if (session?.authenticated && session.did) {
69 Promise.all([
70 sendMessage('getUserTags', { did: session.did }).catch(() => [] as string[]),
71 sendMessage('getTrendingTags', undefined).catch(() => [] as string[]),
72 ]).then(([userTags, trendingTags]) => {
73 const seen = new Set(userTags);
74 const merged = [...userTags];
75 for (const t of trendingTags) {
76 if (!seen.has(t)) {
77 merged.push(t);
78 seen.add(t);
79 }
80 }
81 setTagSuggestions(merged);
82 });
83 }
84 }, [session]);
85
86 useEffect(() => {
87 if (session?.authenticated && currentUrl) {
88 if (activeTab === 'page') loadAnnotations();
89 else if (activeTab === 'bookmarks') loadBookmarks();
90 else if (activeTab === 'highlights') loadHighlights();
91 else if (activeTab === 'collections') loadCollections();
92 }
93 }, [activeTab, session, currentUrl]);
94
95 async function loadSettings() {
96 const url = await apiUrlItem.getValue();
97 const overlay = await overlayEnabledItem.getValue();
98 setApiUrl(url);
99 setOverlayEnabled(overlay);
100 }
101
102 async function saveSettings() {
103 const cleanUrl = apiUrl.replace(/\/$/, '');
104 await apiUrlItem.setValue(cleanUrl);
105 await overlayEnabledItem.setValue(overlayEnabled);
106
107 const tabs = await browser.tabs.query({});
108 for (const tab of tabs) {
109 if (tab.id) {
110 try {
111 await browser.tabs.sendMessage(tab.id, {
112 type: 'UPDATE_OVERLAY_VISIBILITY',
113 show: overlayEnabled,
114 });
115 } catch {
116 /* ignore */
117 }
118 }
119 }
120
121 setShowSettings(false);
122 checkSession();
123 }
124
125 async function loadTheme() {
126 const t = await themeItem.getValue();
127 setTheme(t);
128 applyTheme(t);
129
130 themeItem.watch((newTheme) => {
131 setTheme(newTheme);
132 applyTheme(newTheme);
133 });
134 }
135
136 function applyTheme(t: string) {
137 document.body.classList.remove('light', 'dark');
138 if (t === 'system') {
139 if (window.matchMedia('(prefers-color-scheme: light)').matches) {
140 document.body.classList.add('light');
141 }
142 } else {
143 document.body.classList.add(t);
144 }
145 }
146
147 async function handleThemeChange(newTheme: 'light' | 'dark' | 'system') {
148 await themeItem.setValue(newTheme);
149 setTheme(newTheme);
150 applyTheme(newTheme);
151 }
152
153 async function checkSession() {
154 try {
155 const result = await sendMessage('checkSession', undefined);
156 setSession(result);
157 } catch (error) {
158 console.error('Session check error:', error);
159 setSession({ authenticated: false });
160 } finally {
161 setLoading(false);
162 }
163 }
164
165 async function loadCurrentTab() {
166 const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
167 if (tab?.url) {
168 if (isPdfUrl(tab.url) && tab.id) {
169 await sendMessage('activateOnPdf', { tabId: tab.id, url: tab.url });
170 window.close();
171 return;
172 }
173
174 const resolved = extractOriginalUrl(tab.url);
175 setCurrentUrl(resolved);
176 setCurrentTitle(tab.title || '');
177 }
178 }
179
180 function extractOriginalUrl(url: string): string {
181 if (url.includes('/pdfjs/web/viewer.html')) {
182 try {
183 const fileParam = new URL(url).searchParams.get('file');
184 if (fileParam) return fileParam;
185 } catch {
186 /* ignore */
187 }
188 }
189 return url;
190 }
191
192 function isPdfUrl(url: string): boolean {
193 if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
194 try {
195 const { pathname } = new URL(url);
196 return /\.pdf$/i.test(pathname);
197 } catch {
198 return false;
199 }
200 }
201
202 async function loadAnnotations() {
203 if (!currentUrl) return;
204 setLoadingAnnotations(true);
205 try {
206 let result = await sendMessage('getCachedAnnotations', { url: currentUrl });
207
208 if (!result) {
209 result = await sendMessage('getAnnotations', { url: currentUrl });
210 }
211
212 const all = result || [];
213 const annots = all.filter(
214 (item: any) => item.type !== 'Bookmark' && item.type !== 'Highlight'
215 );
216 const hlights = all.filter((item: any) => item.type === 'Highlight');
217 setAnnotations(annots);
218 setPageHighlights(hlights);
219
220 const isBookmarked = all.some(
221 (item: any) => item.type === 'Bookmark' && item.creator?.did === session?.did
222 );
223 setBookmarked(isBookmarked);
224 } catch (error) {
225 console.error('Load annotations error:', error);
226 } finally {
227 setLoadingAnnotations(false);
228 }
229 }
230
231 async function loadBookmarks() {
232 if (!session?.did) return;
233 setLoadingBookmarks(true);
234 try {
235 const result = await sendMessage('getUserBookmarks', { did: session.did });
236 setBookmarks(result || []);
237 } catch (error) {
238 console.error('Load bookmarks error:', error);
239 } finally {
240 setLoadingBookmarks(false);
241 }
242 }
243
244 async function loadHighlights() {
245 if (!session?.did) return;
246 setLoadingHighlights(true);
247 try {
248 const result = await sendMessage('getUserHighlights', { did: session.did });
249 setHighlights(result || []);
250 } catch (error) {
251 console.error('Load highlights error:', error);
252 } finally {
253 setLoadingHighlights(false);
254 }
255 }
256
257 async function loadCollections() {
258 if (!session?.did) return;
259 setLoadingCollections(true);
260 try {
261 const result = await sendMessage('getUserCollections', { did: session.did });
262 setCollections(result || []);
263 } catch (error) {
264 console.error('Load collections error:', error);
265 } finally {
266 setLoadingCollections(false);
267 }
268 }
269
270 async function openCollectionModal(itemUri: string) {
271 setCollectionModalItem(itemUri);
272 setContainingCollections(new Set());
273
274 if (collections.length === 0) {
275 await loadCollections();
276 }
277
278 try {
279 const itemCollectionUris = await sendMessage('getItemCollections', {
280 annotationUri: itemUri,
281 });
282 setContainingCollections(new Set(itemCollectionUris));
283 } catch (error) {
284 console.error('Failed to get item collections:', error);
285 }
286 }
287
288 async function handleAddToCollection(collectionUri: string) {
289 if (!collectionModalItem) return;
290
291 if (containingCollections.has(collectionUri)) {
292 setCollectionModalItem(null);
293 return;
294 }
295
296 setAddingToCollection(collectionUri);
297 try {
298 const result = await sendMessage('addToCollection', {
299 collectionUri,
300 annotationUri: collectionModalItem,
301 });
302 if (result.success) {
303 setContainingCollections((prev) => new Set([...prev, collectionUri]));
304 } else {
305 alert('Failed to add to collection');
306 }
307 } catch (error) {
308 console.error('Add to collection error:', error);
309 alert('Error adding to collection');
310 } finally {
311 setAddingToCollection(null);
312 }
313 }
314
315 async function handlePost() {
316 if (!text.trim()) return;
317 setPosting(true);
318 try {
319 const result = await sendMessage('createAnnotation', {
320 url: currentUrl,
321 text: text.trim(),
322 title: currentTitle,
323 tags: tags.length > 0 ? tags : undefined,
324 });
325 if (result.success) {
326 setText('');
327 setTags([]);
328 loadAnnotations();
329 } else {
330 alert('Failed to post annotation');
331 }
332 } catch (error) {
333 console.error('Post error:', error);
334 alert('Error posting annotation');
335 } finally {
336 setPosting(false);
337 }
338 }
339
340 async function handleBookmark() {
341 setBookmarking(true);
342 try {
343 const result = await sendMessage('createBookmark', {
344 url: currentUrl,
345 title: currentTitle,
346 });
347 if (result.success) {
348 setBookmarked(true);
349 } else {
350 alert('Failed to bookmark page');
351 }
352 } catch (error) {
353 console.error('Bookmark error:', error);
354 alert('Error bookmarking page');
355 } finally {
356 setBookmarking(false);
357 }
358 }
359
360 function formatDate(dateString?: string) {
361 if (!dateString) return '';
362 try {
363 return new Date(dateString).toLocaleDateString();
364 } catch {
365 return dateString;
366 }
367 }
368
369 if (loading) {
370 return (
371 <div className="flex items-center justify-center h-screen">
372 <div className="w-5 h-5 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
373 </div>
374 );
375 }
376
377 if (!session?.authenticated) {
378 return (
379 <div className="flex flex-col h-screen">
380 {showSettings && (
381 <div className="absolute inset-0 bg-[var(--bg-primary)] z-10 flex flex-col">
382 <header className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
383 <span className="text-sm font-semibold">Settings</span>
384 <button
385 onClick={() => setShowSettings(false)}
386 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-colors"
387 >
388 <X size={16} />
389 </button>
390 </header>
391
392 <div className="flex-1 overflow-y-auto p-4 space-y-5">
393 <div>
394 <label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
395 API URL
396 </label>
397 <input
398 type="text"
399 value={apiUrl}
400 onChange={(e) => setApiUrl(e.target.value)}
401 className="w-full px-3 py-2 bg-[var(--bg-card)] border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-subtle)] transition-all"
402 placeholder="https://margin.at"
403 />
404 </div>
405
406 <div>
407 <label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
408 Theme
409 </label>
410 <div className="flex gap-1.5">
411 {(['light', 'dark', 'system'] as const).map((t) => (
412 <button
413 key={t}
414 onClick={() => handleThemeChange(t)}
415 className={`flex-1 py-2 px-3 text-xs font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5 ${
416 theme === t
417 ? 'bg-[var(--accent)] text-white'
418 : 'bg-[var(--bg-card)] border border-[var(--border)] hover:bg-[var(--bg-hover)]'
419 }`}
420 >
421 {t === 'light' ? (
422 <Sun size={12} />
423 ) : t === 'dark' ? (
424 <Moon size={12} />
425 ) : (
426 <Monitor size={12} />
427 )}
428 {t.charAt(0).toUpperCase() + t.slice(1)}
429 </button>
430 ))}
431 </div>
432 </div>
433 </div>
434
435 <div className="p-4 border-t border-[var(--border)]">
436 <button
437 onClick={saveSettings}
438 className="w-full py-2.5 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:bg-[var(--accent-hover)] transition-colors"
439 >
440 Save Settings
441 </button>
442 </div>
443 </div>
444 )}
445
446 <div className="flex flex-col items-center justify-center flex-1 p-8 text-center">
447 <div className="w-14 h-14 rounded-2xl bg-[var(--accent-subtle)] flex items-center justify-center mb-5">
448 <img src="/icons/logo.svg" alt="Margin" className="w-8 h-8" />
449 </div>
450 <h2 className="font-display text-xl font-bold tracking-tight mb-2">Welcome to Margin</h2>
451 <p className="text-[var(--text-secondary)] text-sm leading-relaxed mb-8 max-w-[280px]">
452 Annotate, highlight, and bookmark the web with your AT Protocol identity.
453 </p>
454 <button
455 onClick={() => browser.tabs.create({ url: `${apiUrl}/login` })}
456 className="w-full max-w-[240px] px-6 py-2.5 bg-[var(--accent)] text-white rounded-xl text-sm font-semibold hover:bg-[var(--accent-hover)] transition-colors"
457 >
458 Sign In
459 </button>
460 <button
461 onClick={() => setShowSettings(true)}
462 className="mt-4 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
463 >
464 <Settings size={12} /> Settings
465 </button>
466 </div>
467 </div>
468 );
469 }
470
471 return (
472 <div className="flex flex-col h-screen">
473 {showSettings && (
474 <div className="absolute inset-0 bg-[var(--bg-primary)] z-10 flex flex-col">
475 <header className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
476 <span className="text-sm font-semibold">Settings</span>
477 <button
478 onClick={() => setShowSettings(false)}
479 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-colors"
480 >
481 <X size={16} />
482 </button>
483 </header>
484
485 <div className="flex-1 overflow-y-auto p-4 space-y-5">
486 <div>
487 <label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
488 API URL
489 </label>
490 <input
491 type="text"
492 value={apiUrl}
493 onChange={(e) => setApiUrl(e.target.value)}
494 className="w-full px-3 py-2 bg-[var(--bg-card)] border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-subtle)] transition-all"
495 placeholder="https://margin.at"
496 />
497 <p className="text-[11px] text-[var(--text-tertiary)] mt-1">
498 For development or self-hosted instances
499 </p>
500 </div>
501
502 <div className="flex items-center justify-between p-3 bg-[var(--bg-card)] border border-[var(--border)] rounded-lg">
503 <div>
504 <div className="text-sm font-medium">Page Overlay</div>
505 <p className="text-[11px] text-[var(--text-tertiary)] mt-0.5">
506 Show highlights and annotations on pages
507 </p>
508 </div>
509 <button
510 onClick={() => setOverlayEnabled(!overlayEnabled)}
511 className={`relative w-10 h-[22px] rounded-full transition-colors ${
512 overlayEnabled
513 ? 'bg-[var(--accent)]'
514 : 'bg-[var(--bg-hover)] border border-[var(--border)]'
515 }`}
516 >
517 <div
518 className={`absolute top-[3px] w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
519 overlayEnabled ? 'left-[22px]' : 'left-[3px]'
520 }`}
521 />
522 </button>
523 </div>
524
525 <div>
526 <label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
527 Theme
528 </label>
529 <div className="flex gap-1.5">
530 {(['light', 'dark', 'system'] as const).map((t) => (
531 <button
532 key={t}
533 onClick={() => handleThemeChange(t)}
534 className={`flex-1 py-2 px-3 text-xs font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5 ${
535 theme === t
536 ? 'bg-[var(--accent)] text-white'
537 : 'bg-[var(--bg-card)] border border-[var(--border)] hover:bg-[var(--bg-hover)]'
538 }`}
539 >
540 {t === 'light' ? (
541 <Sun size={12} />
542 ) : t === 'dark' ? (
543 <Moon size={12} />
544 ) : (
545 <Monitor size={12} />
546 )}
547 {t.charAt(0).toUpperCase() + t.slice(1)}
548 </button>
549 ))}
550 </div>
551 </div>
552 </div>
553
554 <div className="p-4 border-t border-[var(--border)]">
555 <button
556 onClick={saveSettings}
557 className="w-full py-2.5 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:bg-[var(--accent-hover)] transition-colors"
558 >
559 Save
560 </button>
561 </div>
562 </div>
563 )}
564
565 <header className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--border)]">
566 <div className="flex items-center gap-2">
567 <img src="/icons/logo.svg" alt="Margin" className="w-5 h-5" />
568 <span className="font-display font-bold text-sm tracking-tight">Margin</span>
569 </div>
570 <div className="flex items-center gap-1.5">
571 <button
572 onClick={() => browser.tabs.create({ url: apiUrl })}
573 className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--accent)] px-2 py-1 rounded-md hover:bg-[var(--bg-hover)] transition-colors"
574 >
575 @{session.handle}
576 </button>
577 <button
578 onClick={() => setShowSettings(true)}
579 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-colors"
580 title="Settings"
581 >
582 <Settings size={15} />
583 </button>
584 </div>
585 </header>
586
587 <div className="flex border-b border-[var(--border)] px-2 gap-0.5">
588 {(['page', 'bookmarks', 'highlights', 'collections'] as Tab[]).map((tab) => {
589 const icons: Record<Tab, JSX.Element> = {
590 page: <Globe size={13} />,
591 bookmarks: <BookmarkIcon size={13} />,
592 highlights: <Highlighter size={13} />,
593 collections: <Folder size={13} />,
594 };
595 const labels: Record<Tab, string> = {
596 page: 'Page',
597 bookmarks: 'Bookmarks',
598 highlights: 'Highlights',
599 collections: 'Collections',
600 };
601 return (
602 <button
603 key={tab}
604 onClick={() => setActiveTab(tab)}
605 className={`flex-1 py-2.5 text-[11px] font-medium flex items-center justify-center gap-1 border-b-2 transition-all ${
606 activeTab === tab
607 ? 'border-[var(--accent)] text-[var(--accent)]'
608 : 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
609 }`}
610 >
611 {icons[tab]}
612 {labels[tab]}
613 </button>
614 );
615 })}
616 </div>
617
618 <div className="flex-1 overflow-y-auto">
619 {activeTab === 'page' && (
620 <div>
621 <div className="p-4 border-b border-[var(--border)]">
622 <div className="flex items-center gap-3 p-3 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl">
623 <div className="w-9 h-9 rounded-lg bg-[var(--bg-hover)] flex items-center justify-center flex-shrink-0 overflow-hidden">
624 {currentUrl ? (
625 <img
626 src={`https://www.google.com/s2/favicons?domain=${new URL(currentUrl).hostname}&sz=64`}
627 alt=""
628 className="w-5 h-5"
629 onError={(e) => {
630 (e.target as HTMLImageElement).style.display = 'none';
631 (e.target as HTMLImageElement).nextElementSibling?.classList.remove(
632 'hidden'
633 );
634 }}
635 />
636 ) : null}
637 <Globe
638 size={16}
639 className={`text-[var(--text-tertiary)] ${currentUrl ? 'hidden' : ''}`}
640 />
641 </div>
642 <div className="flex-1 min-w-0">
643 <div className="text-sm font-medium truncate">{currentTitle || 'Untitled'}</div>
644 <div className="text-[11px] text-[var(--text-tertiary)] truncate">
645 {currentUrl ? new URL(currentUrl).hostname : ''}
646 </div>
647 </div>
648 <div className="flex items-center gap-1 flex-shrink-0">
649 <button
650 onClick={() => {
651 if (currentUrl) {
652 const shareUrl = `${apiUrl}/url/${encodeURIComponent(currentUrl)}`;
653 browser.tabs.create({ url: shareUrl });
654 }
655 }}
656 className="p-1.5 rounded-md text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--bg-hover)] transition-colors"
657 title="View on Margin"
658 >
659 <Eye size={15} />
660 </button>
661 <button
662 onClick={handleBookmark}
663 disabled={bookmarking || bookmarked}
664 className={`p-1.5 rounded-md transition-colors ${
665 bookmarked
666 ? 'text-emerald-400'
667 : 'text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--bg-hover)]'
668 }`}
669 title={bookmarked ? 'Bookmarked' : 'Bookmark page'}
670 >
671 {bookmarked ? <Check size={15} /> : <BookmarkIcon size={15} />}
672 </button>
673 </div>
674 </div>
675 </div>
676
677 <div className="p-4 border-b border-[var(--border)]">
678 <textarea
679 value={text}
680 onChange={(e) => setText(e.target.value)}
681 placeholder="Share your thoughts on this page..."
682 className="w-full px-3 py-2.5 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl text-sm resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-subtle)] min-h-[80px] transition-all"
683 />
684 <div className="mt-2">
685 <TagInput tags={tags} onChange={setTags} suggestions={tagSuggestions} />
686 </div>
687 <div className="flex items-center justify-between mt-2">
688 <span className="text-[11px] text-[var(--text-tertiary)]">
689 {text.length > 0 ? `${text.length} chars` : ''}
690 </span>
691 <button
692 onClick={handlePost}
693 disabled={posting || !text.trim()}
694 className="px-4 py-1.5 bg-[var(--accent)] text-white text-xs rounded-lg font-medium hover:bg-[var(--accent-hover)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
695 >
696 {posting ? 'Posting...' : 'Post'}
697 </button>
698 </div>
699 </div>
700
701 <div>
702 <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
703 <div className="flex items-center gap-2">
704 <span className="text-xs font-semibold text-[var(--text-secondary)]">
705 Activity
706 </span>
707 <span className="text-xs font-semibold bg-[var(--accent-subtle)] text-[var(--accent)] px-2 py-0.5 rounded-full">
708 {annotations.length + pageHighlights.length}
709 </span>
710 </div>
711 <div className="flex items-center bg-[var(--bg-card)] border border-[var(--border)] rounded-lg p-0.5">
712 {(
713 [
714 { key: 'all', label: 'All', icon: undefined },
715 {
716 key: 'annotations',
717 label: `${annotations.length}`,
718 icon: <PenTool size={10} />,
719 },
720 {
721 key: 'highlights',
722 label: `${pageHighlights.length}`,
723 icon: <Highlighter size={10} />,
724 },
725 ] as const
726 ).map(({ key, label, icon }) => (
727 <button
728 key={key}
729 onClick={() => setPageFilter(key as PageFilter)}
730 className={`px-2 py-1 text-[10px] font-medium rounded-md transition-all flex items-center gap-1 ${
731 pageFilter === key
732 ? 'bg-[var(--accent)] text-white shadow-sm'
733 : 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
734 }`}
735 >
736 {icon}
737 {label}
738 </button>
739 ))}
740 </div>
741 </div>
742
743 {loadingAnnotations ? (
744 <div className="flex items-center justify-center py-12">
745 <div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--accent)] border-t-transparent" />
746 </div>
747 ) : annotations.length + pageHighlights.length === 0 ? (
748 <div className="flex flex-col items-center justify-center py-12 text-[var(--text-tertiary)]">
749 <div className="w-12 h-12 rounded-xl bg-[var(--accent-subtle)] flex items-center justify-center mb-4">
750 <Sparkles size={22} className="text-[var(--accent)] opacity-60" />
751 </div>
752 <p className="text-sm font-medium mb-1">No activity yet</p>
753 <p className="text-xs text-[var(--text-tertiary)]">
754 Be the first to annotate or highlight this page
755 </p>
756 </div>
757 ) : (
758 <div className="divide-y divide-[var(--border)]">
759 {(pageFilter === 'all' || pageFilter === 'annotations') &&
760 annotations.map((item) => (
761 <AnnotationCard
762 key={item.uri || item.id}
763 item={item}
764 sessionDid={session?.did}
765 formatDate={formatDate}
766 onAddToCollection={() => openCollectionModal(item.uri || item.id || '')}
767 onConverted={loadAnnotations}
768 />
769 ))}
770 {(pageFilter === 'all' || pageFilter === 'highlights') &&
771 pageHighlights.map((item) => (
772 <AnnotationCard
773 key={item.uri || item.id}
774 item={item}
775 sessionDid={session?.did}
776 formatDate={formatDate}
777 onAddToCollection={() => openCollectionModal(item.uri || item.id || '')}
778 onConverted={loadAnnotations}
779 />
780 ))}
781 {((pageFilter === 'annotations' && annotations.length === 0) ||
782 (pageFilter === 'highlights' && pageHighlights.length === 0)) && (
783 <div className="flex flex-col items-center justify-center py-10 text-[var(--text-tertiary)]">
784 <p className="text-xs">
785 No {pageFilter === 'annotations' ? 'annotations' : 'highlights'} on this
786 page
787 </p>
788 </div>
789 )}
790 </div>
791 )}
792 </div>
793 </div>
794 )}
795
796 {activeTab === 'bookmarks' && (
797 <div className="p-4">
798 {loadingBookmarks ? (
799 <div className="flex items-center justify-center py-16">
800 <div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--accent)] border-t-transparent" />
801 </div>
802 ) : bookmarks.length === 0 ? (
803 <div className="flex flex-col items-center justify-center py-16 text-[var(--text-tertiary)]">
804 <div className="w-12 h-12 rounded-xl bg-[var(--accent-subtle)] flex items-center justify-center mb-4">
805 <BookmarkIcon size={22} className="text-[var(--accent)] opacity-60" />
806 </div>
807 <p className="text-sm font-medium mb-1">No bookmarks yet</p>
808 <p className="text-xs text-[var(--text-tertiary)]">Save pages to read later</p>
809 </div>
810 ) : (
811 <div className="space-y-2">
812 {bookmarks.map((item) => (
813 <div
814 key={item.uri || item.id}
815 className="flex items-center gap-3 p-3 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl hover:bg-[var(--bg-hover)] hover:border-[var(--border-strong)] transition-all group"
816 >
817 <div className="w-9 h-9 rounded-lg bg-[var(--accent-subtle)] flex items-center justify-center flex-shrink-0">
818 <BookmarkIcon size={16} className="text-[var(--accent)]" />
819 </div>
820 <a
821 href={item.source}
822 target="_blank"
823 rel="noopener noreferrer"
824 className="flex-1 min-w-0"
825 >
826 <div className="text-sm font-medium truncate group-hover:text-[var(--accent)] transition-colors">
827 {item.title || 'Untitled'}
828 </div>
829 <div className="text-xs text-[var(--text-tertiary)] truncate">
830 {item.source ? new URL(item.source).hostname : ''}
831 </div>
832 </a>
833 <button
834 onClick={() => openCollectionModal(item.uri || item.id || '')}
835 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--accent-subtle)] rounded-lg transition-all"
836 title="Add to collection"
837 >
838 <FolderPlus size={14} />
839 </button>
840 </div>
841 ))}
842 </div>
843 )}
844 </div>
845 )}
846
847 {activeTab === 'highlights' && (
848 <div className="p-4">
849 {loadingHighlights ? (
850 <div className="flex items-center justify-center py-16">
851 <div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--accent)] border-t-transparent" />
852 </div>
853 ) : highlights.length === 0 ? (
854 <div className="flex flex-col items-center justify-center py-16 text-[var(--text-tertiary)]">
855 <div className="w-12 h-12 rounded-xl bg-[var(--accent-subtle)] flex items-center justify-center mb-4">
856 <Highlighter size={22} className="text-[var(--accent)] opacity-60" />
857 </div>
858 <p className="text-sm font-medium mb-1">No highlights yet</p>
859 <p className="text-xs text-[var(--text-tertiary)]">
860 Select text on any page to highlight
861 </p>
862 </div>
863 ) : (
864 <div className="space-y-3">
865 {highlights.map((item) => (
866 <HighlightCard
867 key={item.uri || item.id}
868 item={item}
869 onAddToCollection={() => openCollectionModal(item.uri || item.id || '')}
870 onConverted={loadHighlights}
871 />
872 ))}
873 </div>
874 )}
875 </div>
876 )}
877
878 {activeTab === 'collections' && (
879 <div className="p-4">
880 {loadingCollections ? (
881 <div className="flex items-center justify-center py-16">
882 <div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--accent)] border-t-transparent" />
883 </div>
884 ) : collections.length === 0 ? (
885 <div className="flex flex-col items-center justify-center py-16 text-[var(--text-tertiary)]">
886 <div className="w-12 h-12 rounded-xl bg-[var(--accent-subtle)] flex items-center justify-center mb-4">
887 <Folder size={22} className="text-[var(--accent)] opacity-60" />
888 </div>
889 <p className="text-sm font-medium mb-1">No collections yet</p>
890 <p className="text-xs text-[var(--text-tertiary)]">
891 Organize your annotations into collections
892 </p>
893 </div>
894 ) : (
895 <div className="space-y-2">
896 {collections.map((item) => (
897 <button
898 key={item.uri || item.id}
899 onClick={() =>
900 browser.tabs.create({
901 url: `${apiUrl}/collection/${encodeURIComponent(item.uri || item.id || '')}`,
902 })
903 }
904 className="w-full text-left p-4 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl hover:bg-[var(--bg-hover)] hover:border-[var(--border-strong)] transition-all group flex items-center gap-3"
905 >
906 <div className="w-10 h-10 rounded-lg bg-[var(--accent)]/15 flex items-center justify-center flex-shrink-0 text-[var(--accent)] text-lg">
907 <CollectionIcon icon={item.icon} size={18} />
908 </div>
909 <div className="flex-1 min-w-0">
910 <div className="text-sm font-medium group-hover:text-[var(--accent)] transition-colors">
911 {item.name}
912 </div>
913 {item.description && (
914 <div className="text-xs text-[var(--text-tertiary)] truncate">
915 {item.description}
916 </div>
917 )}
918 </div>
919 <ChevronRight
920 size={16}
921 className="text-[var(--text-tertiary)] group-hover:text-[var(--accent)] transition-colors"
922 />
923 </button>
924 ))}
925 </div>
926 )}
927 </div>
928 )}
929 </div>
930
931 {collectionModalItem && (
932 <div
933 className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 animate-fadeIn"
934 onClick={() => setCollectionModalItem(null)}
935 >
936 <div
937 className="bg-[var(--bg-primary)] rounded-2xl w-[90%] max-w-[340px] max-h-[80vh] overflow-hidden shadow-2xl animate-scaleIn"
938 onClick={(e) => e.stopPropagation()}
939 >
940 <div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
941 <h3 className="text-sm font-bold">Add to Collection</h3>
942 <button
943 onClick={() => setCollectionModalItem(null)}
944 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-all"
945 >
946 <X size={16} />
947 </button>
948 </div>
949 <div className="p-4 max-h-[300px] overflow-y-auto">
950 {loadingCollections ? (
951 <div className="flex items-center justify-center py-8">
952 <div className="animate-spin rounded-full h-6 w-6 border-2 border-[var(--accent)] border-t-transparent" />
953 </div>
954 ) : collections.length === 0 ? (
955 <div className="text-center py-8 text-[var(--text-tertiary)]">
956 <Folder size={32} className="mx-auto mb-2 opacity-40" />
957 <p className="text-sm">No collections yet</p>
958 <p className="text-xs mt-1">Create collections on margin.at</p>
959 </div>
960 ) : (
961 <div className="space-y-2">
962 {collections.map((col) => {
963 const colUri = col.uri || col.id || '';
964 const isInCollection = containingCollections.has(colUri);
965 const isAdding = addingToCollection === colUri;
966 return (
967 <button
968 key={colUri}
969 onClick={() => !isInCollection && handleAddToCollection(colUri)}
970 disabled={isAdding || isInCollection}
971 className={`w-full text-left p-3 border rounded-xl transition-all flex items-center gap-3 ${
972 isInCollection
973 ? 'bg-emerald-400/10 border-emerald-400/30 cursor-default'
974 : 'bg-[var(--bg-card)] border-[var(--border)] hover:bg-[var(--bg-hover)] hover:border-[var(--accent)]'
975 }`}
976 >
977 <div
978 className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 text-base ${
979 isInCollection
980 ? 'bg-emerald-400/15 text-emerald-400'
981 : 'bg-[var(--accent)]/15 text-[var(--accent)]'
982 }`}
983 >
984 <CollectionIcon icon={col.icon} size={16} />
985 </div>
986 <div className="flex-1 min-w-0">
987 <div className="text-sm font-medium">{col.name}</div>
988 </div>
989 {isAdding ? (
990 <div className="animate-spin rounded-full h-4 w-4 border-2 border-[var(--accent)] border-t-transparent" />
991 ) : isInCollection ? (
992 <Check size={16} className="text-emerald-400" />
993 ) : (
994 <FolderPlus size={16} className="text-[var(--text-tertiary)]" />
995 )}
996 </button>
997 );
998 })}
999 </div>
1000 )}
1001 </div>
1002 </div>
1003 </div>
1004 )}
1005
1006 <footer className="flex items-center justify-center px-4 py-2 border-t border-[var(--border)]">
1007 <button
1008 onClick={() => browser.tabs.create({ url: apiUrl })}
1009 className="text-xs text-[var(--text-tertiary)] hover:text-[var(--accent)] flex items-center gap-1.5 py-1.5 px-3 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
1010 >
1011 Open Margin <ExternalLink size={11} />
1012 </button>
1013 </footer>
1014 </div>
1015 );
1016}
1017
1018function AnnotationCard({
1019 item,
1020 sessionDid,
1021 formatDate,
1022 onAddToCollection,
1023 onConverted,
1024}: {
1025 item: Annotation;
1026 sessionDid?: string;
1027 formatDate: (d?: string) => string;
1028 onAddToCollection?: () => void;
1029 onConverted?: () => void;
1030}) {
1031 const [noteInput, setNoteInput] = useState('');
1032 const [showNoteInput, setShowNoteInput] = useState(false);
1033 const [converting, setConverting] = useState(false);
1034
1035 const author = item.author || item.creator || {};
1036 const handle = author.handle || 'User';
1037 const text = item.body?.value || item.text || '';
1038 const selector = item.target?.selector;
1039 const quote = selector?.exact || '';
1040 const isHighlight = (item as any).type === 'Highlight';
1041 const isOwned = sessionDid && author.did === sessionDid;
1042 const highlightColor = item.color || (isHighlight ? '#fbbf24' : 'var(--accent)');
1043
1044 async function handleConvert() {
1045 if (!noteInput.trim()) return;
1046 setConverting(true);
1047 try {
1048 const result = await sendMessage('convertHighlightToAnnotation', {
1049 highlightUri: item.uri || item.id || '',
1050 url: item.target?.source || '',
1051 text: noteInput.trim(),
1052 selector: item.target?.selector,
1053 });
1054 if (result.success) {
1055 setShowNoteInput(false);
1056 setNoteInput('');
1057 onConverted?.();
1058 } else {
1059 console.error('Convert failed:', result.error);
1060 }
1061 } catch (error) {
1062 console.error('Convert error:', error);
1063 } finally {
1064 setConverting(false);
1065 }
1066 }
1067
1068 return (
1069 <div className="px-4 py-4 hover:bg-[var(--bg-hover)] transition-colors">
1070 <div className="flex items-start gap-3">
1071 <div className="w-9 h-9 rounded-full bg-gradient-to-br from-[var(--accent)] to-[var(--accent-hover)] flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden shadow-sm">
1072 {author.avatar ? (
1073 <img src={author.avatar} alt={handle} className="w-full h-full object-cover" />
1074 ) : (
1075 handle[0]?.toUpperCase() || 'U'
1076 )}
1077 </div>
1078 <div className="flex-1 min-w-0">
1079 <div className="flex items-center gap-2 mb-1.5">
1080 <span className="text-sm font-semibold hover:text-[var(--accent)] cursor-pointer transition-colors">
1081 @{handle}
1082 </span>
1083 <span className="text-[11px] text-[var(--text-tertiary)]">
1084 {formatDate(item.created || item.createdAt)}
1085 </span>
1086 {isHighlight && (
1087 <span
1088 className="text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1"
1089 style={{
1090 backgroundColor: `${highlightColor}20`,
1091 color: highlightColor,
1092 }}
1093 >
1094 <Highlighter size={10} /> Highlight
1095 </span>
1096 )}
1097 <div className="ml-auto flex items-center gap-0.5">
1098 {isHighlight && isOwned && !showNoteInput && (
1099 <button
1100 onClick={(e) => {
1101 e.stopPropagation();
1102 setShowNoteInput(true);
1103 }}
1104 className="p-1 text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--accent-subtle)] rounded transition-all"
1105 title="Add note (convert to annotation)"
1106 >
1107 <PenTool size={13} />
1108 </button>
1109 )}
1110 {onAddToCollection && (
1111 <button
1112 onClick={(e) => {
1113 e.stopPropagation();
1114 onAddToCollection();
1115 }}
1116 className="p-1 text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--accent-subtle)] rounded transition-all"
1117 title="Add to collection"
1118 >
1119 <FolderPlus size={13} />
1120 </button>
1121 )}
1122 </div>
1123 </div>
1124
1125 {quote && (
1126 <div
1127 className="text-sm text-[var(--text-secondary)] border-l-2 pl-3 mb-2.5 py-1.5 rounded-r italic cursor-pointer hover:opacity-80 transition-all"
1128 style={{
1129 borderColor: highlightColor,
1130 backgroundColor: `${highlightColor}12`,
1131 }}
1132 onClick={async (e) => {
1133 e.stopPropagation();
1134 const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
1135 if (tab?.id) {
1136 browser.tabs.sendMessage(tab.id, { type: 'SCROLL_TO_TEXT', text: quote });
1137 window.close();
1138 }
1139 }}
1140 title="Jump to text on page"
1141 >
1142 "{quote.length > 200 ? quote.slice(0, 200) + '...' : quote}"
1143 </div>
1144 )}
1145
1146 {text && (
1147 <div className="text-[13px] leading-relaxed text-[var(--text-primary)]">{text}</div>
1148 )}
1149
1150 {showNoteInput && (
1151 <div className="mt-2.5 flex gap-2 items-end animate-fadeIn">
1152 <textarea
1153 value={noteInput}
1154 onChange={(e) => setNoteInput(e.target.value)}
1155 placeholder="Add your note..."
1156 autoFocus
1157 onKeyDown={(e) => {
1158 if (e.key === 'Enter' && !e.shiftKey) {
1159 e.preventDefault();
1160 handleConvert();
1161 }
1162 if (e.key === 'Escape') {
1163 setShowNoteInput(false);
1164 setNoteInput('');
1165 }
1166 }}
1167 className="flex-1 p-2.5 bg-[var(--bg-card)] border border-[var(--border)] rounded-lg text-xs resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent-subtle)] min-h-[60px]"
1168 />
1169 <div className="flex flex-col gap-1.5">
1170 <button
1171 onClick={handleConvert}
1172 disabled={converting || !noteInput.trim()}
1173 className="p-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed transition-all"
1174 title="Convert to annotation"
1175 >
1176 {converting ? (
1177 <div className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-white border-t-transparent" />
1178 ) : (
1179 <Send size={14} />
1180 )}
1181 </button>
1182 <button
1183 onClick={() => {
1184 setShowNoteInput(false);
1185 setNoteInput('');
1186 }}
1187 className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-all"
1188 title="Cancel"
1189 >
1190 <X size={14} />
1191 </button>
1192 </div>
1193 </div>
1194 )}
1195 </div>
1196 </div>
1197 </div>
1198 );
1199}
1200
1201function HighlightCard({
1202 item,
1203 onAddToCollection,
1204 onConverted,
1205}: {
1206 item: Highlight;
1207 onAddToCollection?: () => void;
1208 onConverted?: () => void;
1209}) {
1210 const [noteInput, setNoteInput] = useState('');
1211 const [showNoteInput, setShowNoteInput] = useState(false);
1212 const [converting, setConverting] = useState(false);
1213
1214 const hlItem = item as any;
1215 const selector = hlItem.target?.selector || hlItem.selector;
1216 const source = hlItem.target?.source || hlItem.source || hlItem.url || '';
1217 const quote = selector?.exact || '';
1218 const color = hlItem.color || '#fbbf24';
1219
1220 async function handleConvert() {
1221 if (!noteInput.trim()) return;
1222 setConverting(true);
1223 try {
1224 const result = await sendMessage('convertHighlightToAnnotation', {
1225 highlightUri: hlItem.uri || hlItem.id || '',
1226 url: source,
1227 text: noteInput.trim(),
1228 selector: selector,
1229 });
1230 if (result.success) {
1231 setShowNoteInput(false);
1232 setNoteInput('');
1233 onConverted?.();
1234 } else {
1235 console.error('Convert failed:', result.error);
1236 }
1237 } catch (error) {
1238 console.error('Convert error:', error);
1239 } finally {
1240 setConverting(false);
1241 }
1242 }
1243
1244 return (
1245 <div className="p-4 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl hover:bg-[var(--bg-hover)] hover:border-[var(--border-strong)] transition-all group">
1246 {quote && (
1247 <div
1248 className="text-sm leading-relaxed border-l-3 pl-3 mb-3 py-1"
1249 style={{
1250 borderColor: color,
1251 background: `linear-gradient(90deg, ${color}15, transparent)`,
1252 }}
1253 >
1254 "{quote.length > 120 ? quote.slice(0, 120) + '...' : quote}"
1255 </div>
1256 )}
1257
1258 <div className="flex items-center justify-between">
1259 <div
1260 className="flex items-center gap-2 text-xs text-[var(--text-tertiary)] flex-1 cursor-pointer hover:text-[var(--accent)]"
1261 onClick={() => {
1262 if (source) browser.tabs.create({ url: source });
1263 }}
1264 >
1265 <Globe size={12} />
1266 {source ? new URL(source).hostname : ''}
1267 <ChevronRight
1268 size={14}
1269 className="ml-auto text-[var(--text-tertiary)] group-hover:text-[var(--accent)] transition-colors"
1270 />
1271 </div>
1272 <div className="flex items-center gap-0.5 ml-2">
1273 {!showNoteInput && (
1274 <button
1275 onClick={(e) => {
1276 e.stopPropagation();
1277 setShowNoteInput(true);
1278 }}
1279 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--accent-subtle)] rounded-lg transition-all"
1280 title="Add note (convert to annotation)"
1281 >
1282 <PenTool size={13} />
1283 </button>
1284 )}
1285 {onAddToCollection && (
1286 <button
1287 onClick={(e) => {
1288 e.stopPropagation();
1289 onAddToCollection();
1290 }}
1291 className="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--accent)] hover:bg-[var(--accent-subtle)] rounded-lg transition-all"
1292 title="Add to collection"
1293 >
1294 <FolderPlus size={14} />
1295 </button>
1296 )}
1297 </div>
1298 </div>
1299
1300 {showNoteInput && (
1301 <div className="mt-3 flex gap-2 items-end animate-fadeIn">
1302 <textarea
1303 value={noteInput}
1304 onChange={(e) => setNoteInput(e.target.value)}
1305 placeholder="Add your note..."
1306 autoFocus
1307 onKeyDown={(e) => {
1308 if (e.key === 'Enter' && !e.shiftKey) {
1309 e.preventDefault();
1310 handleConvert();
1311 }
1312 if (e.key === 'Escape') {
1313 setShowNoteInput(false);
1314 setNoteInput('');
1315 }
1316 }}
1317 className="flex-1 p-2.5 bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg text-xs resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent-subtle)] min-h-[60px]"
1318 />
1319 <div className="flex flex-col gap-1.5">
1320 <button
1321 onClick={handleConvert}
1322 disabled={converting || !noteInput.trim()}
1323 className="p-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed transition-all"
1324 title="Convert to annotation"
1325 >
1326 {converting ? (
1327 <div className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-white border-t-transparent" />
1328 ) : (
1329 <Send size={14} />
1330 )}
1331 </button>
1332 <button
1333 onClick={() => {
1334 setShowNoteInput(false);
1335 setNoteInput('');
1336 }}
1337 className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] rounded-lg transition-all"
1338 title="Cancel"
1339 >
1340 <X size={14} />
1341 </button>
1342 </div>
1343 </div>
1344 )}
1345 </div>
1346 );
1347}
1348
1349export default App;