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