import { useState, useEffect } from 'react'; import { sendMessage } from '@/utils/messaging'; import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage'; import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; import CollectionIcon from '@/components/CollectionIcon'; import TagInput from '@/components/TagInput'; import { Settings, ExternalLink, Bookmark as BookmarkIcon, Highlighter, X, Sun, Moon, Monitor, Check, Globe, ChevronRight, Sparkles, FolderPlus, Folder, PenTool, Eye, Send, } from 'lucide-react'; type Tab = 'page' | 'bookmarks' | 'highlights' | 'collections'; type PageFilter = 'all' | 'annotations' | 'highlights'; export function App() { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('page'); const [pageFilter, setPageFilter] = useState('all'); const [annotations, setAnnotations] = useState([]); const [pageHighlights, setPageHighlights] = useState([]); const [bookmarks, setBookmarks] = useState([]); const [highlights, setHighlights] = useState([]); const [collections, setCollections] = useState([]); const [loadingAnnotations, setLoadingAnnotations] = useState(false); const [loadingBookmarks, setLoadingBookmarks] = useState(false); const [loadingHighlights, setLoadingHighlights] = useState(false); const [loadingCollections, setLoadingCollections] = useState(false); const [collectionModalItem, setCollectionModalItem] = useState(null); const [addingToCollection, setAddingToCollection] = useState(null); const [containingCollections, setContainingCollections] = useState>(new Set()); const [currentUrl, setCurrentUrl] = useState(''); const [currentTitle, setCurrentTitle] = useState(''); const [text, setText] = useState(''); const [posting, setPosting] = useState(false); const [bookmarking, setBookmarking] = useState(false); const [bookmarked, setBookmarked] = useState(false); const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); const [showSettings, setShowSettings] = useState(false); const [apiUrl, setApiUrl] = useState('https://margin.at'); const [overlayEnabled, setOverlayEnabled] = useState(true); const [tags, setTags] = useState([]); const [tagSuggestions, setTagSuggestions] = useState([]); useEffect(() => { checkSession(); loadCurrentTab(); loadTheme(); loadSettings(); }, []); useEffect(() => { if (session?.authenticated && session.did) { Promise.all([ sendMessage('getUserTags', { did: session.did }).catch(() => [] as string[]), sendMessage('getTrendingTags', undefined).catch(() => [] as string[]), ]).then(([userTags, trendingTags]) => { const seen = new Set(userTags); const merged = [...userTags]; for (const t of trendingTags) { if (!seen.has(t)) { merged.push(t); seen.add(t); } } setTagSuggestions(merged); }); } }, [session]); useEffect(() => { if (session?.authenticated && currentUrl) { if (activeTab === 'page') loadAnnotations(); else if (activeTab === 'bookmarks') loadBookmarks(); else if (activeTab === 'highlights') loadHighlights(); else if (activeTab === 'collections') loadCollections(); } }, [activeTab, session, currentUrl]); async function loadSettings() { const url = await apiUrlItem.getValue(); const overlay = await overlayEnabledItem.getValue(); setApiUrl(url); setOverlayEnabled(overlay); } async function saveSettings() { const cleanUrl = apiUrl.replace(/\/$/, ''); await apiUrlItem.setValue(cleanUrl); await overlayEnabledItem.setValue(overlayEnabled); const tabs = await browser.tabs.query({}); for (const tab of tabs) { if (tab.id) { try { await browser.tabs.sendMessage(tab.id, { type: 'UPDATE_OVERLAY_VISIBILITY', show: overlayEnabled, }); } catch { /* ignore */ } } } setShowSettings(false); checkSession(); } async function loadTheme() { const t = await themeItem.getValue(); setTheme(t); applyTheme(t); themeItem.watch((newTheme) => { setTheme(newTheme); applyTheme(newTheme); }); } function applyTheme(t: string) { document.body.classList.remove('light', 'dark'); if (t === 'system') { if (window.matchMedia('(prefers-color-scheme: light)').matches) { document.body.classList.add('light'); } } else { document.body.classList.add(t); } } async function handleThemeChange(newTheme: 'light' | 'dark' | 'system') { await themeItem.setValue(newTheme); setTheme(newTheme); applyTheme(newTheme); } async function checkSession() { try { const result = await sendMessage('checkSession', undefined); setSession(result); } catch (error) { console.error('Session check error:', error); setSession({ authenticated: false }); } finally { setLoading(false); } } async function loadCurrentTab() { const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); if (tab?.url) { if (isPdfUrl(tab.url) && tab.id) { await sendMessage('activateOnPdf', { tabId: tab.id, url: tab.url }); window.close(); return; } const resolved = extractOriginalUrl(tab.url); setCurrentUrl(resolved); setCurrentTitle(tab.title || ''); } } function extractOriginalUrl(url: string): string { if (url.includes('/pdfjs/web/viewer.html')) { try { const fileParam = new URL(url).searchParams.get('file'); if (fileParam) return fileParam; } catch { /* ignore */ } } return url; } function isPdfUrl(url: string): boolean { if (!url.startsWith('http://') && !url.startsWith('https://')) return false; try { const { pathname } = new URL(url); return /\.pdf$/i.test(pathname); } catch { return false; } } async function loadAnnotations() { if (!currentUrl) return; setLoadingAnnotations(true); try { let result = await sendMessage('getCachedAnnotations', { url: currentUrl }); if (!result) { result = await sendMessage('getAnnotations', { url: currentUrl }); } const all = result || []; const annots = all.filter( (item: any) => item.type !== 'Bookmark' && item.type !== 'Highlight' ); const hlights = all.filter((item: any) => item.type === 'Highlight'); setAnnotations(annots); setPageHighlights(hlights); const isBookmarked = all.some( (item: any) => item.type === 'Bookmark' && item.creator?.did === session?.did ); setBookmarked(isBookmarked); } catch (error) { console.error('Load annotations error:', error); } finally { setLoadingAnnotations(false); } } async function loadBookmarks() { if (!session?.did) return; setLoadingBookmarks(true); try { const result = await sendMessage('getUserBookmarks', { did: session.did }); setBookmarks(result || []); } catch (error) { console.error('Load bookmarks error:', error); } finally { setLoadingBookmarks(false); } } async function loadHighlights() { if (!session?.did) return; setLoadingHighlights(true); try { const result = await sendMessage('getUserHighlights', { did: session.did }); setHighlights(result || []); } catch (error) { console.error('Load highlights error:', error); } finally { setLoadingHighlights(false); } } async function loadCollections() { if (!session?.did) return; setLoadingCollections(true); try { const result = await sendMessage('getUserCollections', { did: session.did }); setCollections(result || []); } catch (error) { console.error('Load collections error:', error); } finally { setLoadingCollections(false); } } async function openCollectionModal(itemUri: string) { setCollectionModalItem(itemUri); setContainingCollections(new Set()); if (collections.length === 0) { await loadCollections(); } try { const itemCollectionUris = await sendMessage('getItemCollections', { annotationUri: itemUri, }); setContainingCollections(new Set(itemCollectionUris)); } catch (error) { console.error('Failed to get item collections:', error); } } async function handleAddToCollection(collectionUri: string) { if (!collectionModalItem) return; if (containingCollections.has(collectionUri)) { setCollectionModalItem(null); return; } setAddingToCollection(collectionUri); try { const result = await sendMessage('addToCollection', { collectionUri, annotationUri: collectionModalItem, }); if (result.success) { setContainingCollections((prev) => new Set([...prev, collectionUri])); } else { alert('Failed to add to collection'); } } catch (error) { console.error('Add to collection error:', error); alert('Error adding to collection'); } finally { setAddingToCollection(null); } } async function handlePost() { if (!text.trim()) return; setPosting(true); try { const result = await sendMessage('createAnnotation', { url: currentUrl, text: text.trim(), title: currentTitle, tags: tags.length > 0 ? tags : undefined, }); if (result.success) { setText(''); setTags([]); loadAnnotations(); } else { alert('Failed to post annotation'); } } catch (error) { console.error('Post error:', error); alert('Error posting annotation'); } finally { setPosting(false); } } async function handleBookmark() { setBookmarking(true); try { const result = await sendMessage('createBookmark', { url: currentUrl, title: currentTitle, }); if (result.success) { setBookmarked(true); } else { alert('Failed to bookmark page'); } } catch (error) { console.error('Bookmark error:', error); alert('Error bookmarking page'); } finally { setBookmarking(false); } } function formatDate(dateString?: string) { if (!dateString) return ''; try { return new Date(dateString).toLocaleDateString(); } catch { return dateString; } } if (loading) { return (
); } if (!session?.authenticated) { return (
{showSettings && (
Settings
setApiUrl(e.target.value)} 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" placeholder="https://margin.at" />
{(['light', 'dark', 'system'] as const).map((t) => ( ))}
)}
Margin

Welcome to Margin

Annotate, highlight, and bookmark the web with your AT Protocol identity.

); } return (
{showSettings && (
Settings
setApiUrl(e.target.value)} 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" placeholder="https://margin.at" />

For development or self-hosted instances

Page Overlay

Show highlights and annotations on pages

{(['light', 'dark', 'system'] as const).map((t) => ( ))}
)}
Margin Margin
{(['page', 'bookmarks', 'highlights', 'collections'] as Tab[]).map((tab) => { const icons: Record = { page: , bookmarks: , highlights: , collections: , }; const labels: Record = { page: 'Page', bookmarks: 'Bookmarks', highlights: 'Highlights', collections: 'Collections', }; return ( ); })}
{activeTab === 'page' && (
{currentUrl ? ( { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove( 'hidden' ); }} /> ) : null}
{currentTitle || 'Untitled'}
{currentUrl ? new URL(currentUrl).hostname : ''}