Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement seeing and sharing your notes on an specific page

+507 -54
+38
backend/internal/api/handler.go
··· 66 66 r.Get("/users/{did}/annotations", h.GetUserAnnotations) 67 67 r.Get("/users/{did}/highlights", h.GetUserHighlights) 68 68 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 69 + r.Get("/users/{did}/targets", h.GetUserTargetItems) 69 70 70 71 r.Get("/replies", h.GetReplies) 71 72 r.Get("/likes", h.GetLikeCount) ··· 662 663 "creator": did, 663 664 "items": enriched, 664 665 "totalItems": len(enriched), 666 + }) 667 + } 668 + 669 + func (h *Handler) GetUserTargetItems(w http.ResponseWriter, r *http.Request) { 670 + did := chi.URLParam(r, "did") 671 + if decoded, err := url.QueryUnescape(did); err == nil { 672 + did = decoded 673 + } 674 + 675 + source := r.URL.Query().Get("source") 676 + if source == "" { 677 + source = r.URL.Query().Get("url") 678 + } 679 + if source == "" { 680 + http.Error(w, "source or url parameter required", http.StatusBadRequest) 681 + return 682 + } 683 + 684 + limit := parseIntParam(r, "limit", 50) 685 + offset := parseIntParam(r, "offset", 0) 686 + 687 + urlHash := db.HashURL(source) 688 + 689 + annotations, _ := h.db.GetAnnotationsByAuthorAndTargetHash(did, urlHash, limit, offset) 690 + highlights, _ := h.db.GetHighlightsByAuthorAndTargetHash(did, urlHash, limit, offset) 691 + 692 + enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 693 + enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 694 + 695 + w.Header().Set("Content-Type", "application/json") 696 + json.NewEncoder(w).Encode(map[string]interface{}{ 697 + "@context": "http://www.w3.org/ns/anno.jsonld", 698 + "creator": did, 699 + "source": source, 700 + "sourceHash": urlHash, 701 + "annotations": enrichedAnnotations, 702 + "highlights": enrichedHighlights, 665 703 }) 666 704 } 667 705
+16
backend/internal/db/queries_annotations.go
··· 146 146 return scanAnnotations(rows) 147 147 } 148 148 149 + func (db *DB) GetAnnotationsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Annotation, error) { 150 + rows, err := db.Query(db.Rebind(` 151 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 152 + FROM annotations 153 + WHERE author_did = ? AND target_hash = ? 154 + ORDER BY created_at DESC 155 + LIMIT ? OFFSET ? 156 + `), authorDID, targetHash, limit, offset) 157 + if err != nil { 158 + return nil, err 159 + } 160 + defer rows.Close() 161 + 162 + return scanAnnotations(rows) 163 + } 164 + 149 165 func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { 150 166 if len(uris) == 0 { 151 167 return []Annotation{}, nil
+24
backend/internal/db/queries_highlights.go
··· 153 153 return highlights, nil 154 154 } 155 155 156 + func (db *DB) GetHighlightsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Highlight, error) { 157 + rows, err := db.Query(db.Rebind(` 158 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 159 + FROM highlights 160 + WHERE author_did = ? AND target_hash = ? 161 + ORDER BY created_at DESC 162 + LIMIT ? OFFSET ? 163 + `), authorDID, targetHash, limit, offset) 164 + if err != nil { 165 + return nil, err 166 + } 167 + defer rows.Close() 168 + 169 + var highlights []Highlight 170 + for rows.Next() { 171 + var h Highlight 172 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 173 + return nil, err 174 + } 175 + highlights = append(highlights, h) 176 + } 177 + return highlights, nil 178 + } 179 + 156 180 func (db *DB) DeleteHighlight(uri string) error { 157 181 _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) 158 182 return err
+2
web/src/App.jsx
··· 6 6 import MobileNav from "./components/MobileNav"; 7 7 import Feed from "./pages/Feed"; 8 8 import Url from "./pages/Url"; 9 + import UserUrl from "./pages/UserUrl"; 9 10 import Profile from "./pages/Profile"; 10 11 import Login from "./pages/Login"; 11 12 import New from "./pages/New"; ··· 64 65 path="/:handle/bookmark/:rkey" 65 66 element={<AnnotationDetail />} 66 67 /> 68 + <Route path="/:handle/url/*" element={<UserUrl />} /> 67 69 <Route path="/collection/*" element={<CollectionDetail />} /> 68 70 <Route path="/privacy" element={<Privacy />} /> 69 71 <Route path="/terms" element={<Terms />} />
+6
web/src/api/client.js
··· 75 75 ); 76 76 } 77 77 78 + export async function getUserTargetItems(did, url, limit = 50, offset = 0) { 79 + return request( 80 + `${API_BASE}/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 81 + ); 82 + } 83 + 78 84 export async function getHighlights(creatorDid, limit = 50, offset = 0) { 79 85 return request( 80 86 `${API_BASE}/highlights?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`,
+53 -53
web/src/context/ThemeContext.jsx
··· 1 1 import { createContext, useContext, useEffect, useState } from "react"; 2 2 3 3 const ThemeContext = createContext({ 4 - theme: "system", 5 - setTheme: () => null, 4 + theme: "system", 5 + setTheme: () => null, 6 6 }); 7 7 8 8 export function ThemeProvider({ children }) { 9 - const [theme, setTheme] = useState(() => { 10 - return localStorage.getItem("theme") || "system"; 11 - }); 9 + const [theme, setTheme] = useState(() => { 10 + return localStorage.getItem("theme") || "system"; 11 + }); 12 12 13 - useEffect(() => { 14 - localStorage.setItem("theme", theme); 13 + useEffect(() => { 14 + localStorage.setItem("theme", theme); 15 15 16 - const root = window.document.documentElement; 17 - root.classList.remove("light", "dark"); 16 + const root = window.document.documentElement; 17 + root.classList.remove("light", "dark"); 18 18 19 - delete root.dataset.theme; 19 + delete root.dataset.theme; 20 20 21 - if (theme === "system") { 22 - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 23 - .matches 24 - ? "dark" 25 - : "light"; 21 + if (theme === "system") { 22 + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 23 + .matches 24 + ? "dark" 25 + : "light"; 26 26 27 - if (systemTheme === "light") { 28 - root.dataset.theme = "light"; 29 - } else { 30 - root.dataset.theme = "dark"; 31 - } 32 - return; 33 - } 27 + if (systemTheme === "light") { 28 + root.dataset.theme = "light"; 29 + } else { 30 + root.dataset.theme = "dark"; 31 + } 32 + return; 33 + } 34 34 35 - if (theme === "light") { 36 - root.dataset.theme = "light"; 37 - } 38 - }, [theme]); 35 + if (theme === "light") { 36 + root.dataset.theme = "light"; 37 + } 38 + }, [theme]); 39 39 40 - useEffect(() => { 41 - if (theme !== "system") return; 40 + useEffect(() => { 41 + if (theme !== "system") return; 42 42 43 - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 44 - const handleChange = () => { 45 - const root = window.document.documentElement; 46 - if (mediaQuery.matches) { 47 - delete root.dataset.theme; 48 - } else { 49 - root.dataset.theme = "light"; 50 - } 51 - }; 43 + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 44 + const handleChange = () => { 45 + const root = window.document.documentElement; 46 + if (mediaQuery.matches) { 47 + delete root.dataset.theme; 48 + } else { 49 + root.dataset.theme = "light"; 50 + } 51 + }; 52 52 53 - mediaQuery.addEventListener("change", handleChange); 54 - return () => mediaQuery.removeEventListener("change", handleChange); 55 - }, [theme]); 53 + mediaQuery.addEventListener("change", handleChange); 54 + return () => mediaQuery.removeEventListener("change", handleChange); 55 + }, [theme]); 56 56 57 - const value = { 58 - theme, 59 - setTheme: (newTheme) => { 60 - setTheme(newTheme); 61 - }, 62 - }; 57 + const value = { 58 + theme, 59 + setTheme: (newTheme) => { 60 + setTheme(newTheme); 61 + }, 62 + }; 63 63 64 - return ( 65 - <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> 66 - ); 64 + return ( 65 + <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> 66 + ); 67 67 } 68 68 69 69 // eslint-disable-next-line react-refresh/only-export-components 70 70 export function useTheme() { 71 - const context = useContext(ThemeContext); 72 - if (context === undefined) 73 - throw new Error("useTheme must be used within a ThemeProvider"); 74 - return context; 71 + const context = useContext(ThemeContext); 72 + if (context === undefined) 73 + throw new Error("useTheme must be used within a ThemeProvider"); 74 + return context; 75 75 }
+67
web/src/css/feed.css
··· 139 139 font-size: 1.5rem; 140 140 } 141 141 } 142 + 143 + .user-url-page { 144 + max-width: 800px; 145 + } 146 + 147 + .url-target-info { 148 + display: flex; 149 + flex-direction: column; 150 + gap: 4px; 151 + padding: 16px; 152 + background: var(--bg-secondary); 153 + border: 1px solid var(--border); 154 + border-radius: var(--radius-md); 155 + margin-bottom: 24px; 156 + } 157 + 158 + .url-target-label { 159 + font-size: 0.875rem; 160 + color: var(--text-secondary); 161 + } 162 + 163 + .url-target-link { 164 + color: var(--accent); 165 + font-size: 0.95rem; 166 + word-break: break-all; 167 + text-decoration: none; 168 + } 169 + 170 + .url-target-link:hover { 171 + text-decoration: underline; 172 + } 173 + 174 + .share-notes-banner { 175 + display: flex; 176 + align-items: center; 177 + justify-content: space-between; 178 + gap: 16px; 179 + padding: 12px 16px; 180 + background: var(--accent-subtle); 181 + border: 1px solid var(--accent); 182 + border-radius: var(--radius-md); 183 + margin-bottom: 16px; 184 + } 185 + 186 + .share-notes-info { 187 + display: flex; 188 + align-items: center; 189 + gap: 8px; 190 + color: var(--text-primary); 191 + font-size: 0.9rem; 192 + } 193 + 194 + .share-notes-actions { 195 + display: flex; 196 + gap: 8px; 197 + } 198 + 199 + @media (max-width: 640px) { 200 + .share-notes-banner { 201 + flex-direction: column; 202 + align-items: stretch; 203 + } 204 + 205 + .share-notes-actions { 206 + justify-content: flex-end; 207 + } 208 + }
+1 -1
web/src/css/login.css
··· 334 334 width: 48px; 335 335 height: 48px; 336 336 } 337 - } 337 + }
+65
web/src/pages/Url.jsx
··· 1 1 import { useState } from "react"; 2 + import { Link } from "react-router-dom"; 2 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 4 import { getByTarget } from "../api/client"; 5 + import { useAuth } from "../context/AuthContext"; 4 6 import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 7 + import { Copy, Check, ExternalLink } from "lucide-react"; 5 8 6 9 export default function Url() { 10 + const { user } = useAuth(); 7 11 const [url, setUrl] = useState(""); 8 12 const [annotations, setAnnotations] = useState([]); 9 13 const [highlights, setHighlights] = useState([]); ··· 11 15 const [searched, setSearched] = useState(false); 12 16 const [error, setError] = useState(null); 13 17 const [activeTab, setActiveTab] = useState("all"); 18 + const [copied, setCopied] = useState(false); 14 19 15 20 const handleSearch = async (e) => { 16 21 e.preventDefault(); ··· 27 32 setError(err.message); 28 33 } finally { 29 34 setLoading(false); 35 + } 36 + }; 37 + 38 + const myAnnotations = user 39 + ? annotations.filter((a) => (a.creator?.did || a.author?.did) === user.did) 40 + : []; 41 + const myHighlights = user 42 + ? highlights.filter((h) => (h.creator?.did || h.author?.did) === user.did) 43 + : []; 44 + const myItemsCount = myAnnotations.length + myHighlights.length; 45 + 46 + const getShareUrl = () => { 47 + if (!user?.handle || !url) return null; 48 + return `${window.location.origin}/${user.handle}/url/${url}`; 49 + }; 50 + 51 + const handleCopyShareLink = async () => { 52 + const shareUrl = getShareUrl(); 53 + if (!shareUrl) return; 54 + try { 55 + await navigator.clipboard.writeText(shareUrl); 56 + setCopied(true); 57 + setTimeout(() => setCopied(false), 2000); 58 + } catch { 59 + prompt("Copy this link:", shareUrl); 30 60 } 31 61 }; 32 62 ··· 128 158 </button> 129 159 </div> 130 160 </div> 161 + 162 + {user && myItemsCount > 0 && ( 163 + <div className="share-notes-banner"> 164 + <div className="share-notes-info"> 165 + <ExternalLink size={16} /> 166 + <span> 167 + You have {myItemsCount} note{myItemsCount !== 1 ? "s" : ""} on 168 + this page 169 + </span> 170 + </div> 171 + <div className="share-notes-actions"> 172 + <Link 173 + to={`/${user.handle}/url/${encodeURIComponent(url)}`} 174 + className="btn btn-ghost btn-sm" 175 + > 176 + View 177 + </Link> 178 + <button 179 + onClick={handleCopyShareLink} 180 + className="btn btn-primary btn-sm" 181 + > 182 + {copied ? ( 183 + <> 184 + <Check size={14} /> Copied! 185 + </> 186 + ) : ( 187 + <> 188 + <Copy size={14} /> Copy Share Link 189 + </> 190 + )} 191 + </button> 192 + </div> 193 + </div> 194 + )} 195 + 131 196 <div className="feed">{renderResults()}</div> 132 197 </> 133 198 )}
+235
web/src/pages/UserUrl.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useParams } from "react-router-dom"; 3 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 + import { getUserTargetItems } from "../api/client"; 5 + import { 6 + PenIcon, 7 + HighlightIcon, 8 + SearchIcon, 9 + BlueskyIcon, 10 + } from "../components/Icons"; 11 + 12 + export default function UserUrl() { 13 + const { handle, "*": urlPath } = useParams(); 14 + const targetUrl = urlPath || ""; 15 + 16 + const [profile, setProfile] = useState(null); 17 + const [annotations, setAnnotations] = useState([]); 18 + const [highlights, setHighlights] = useState([]); 19 + const [loading, setLoading] = useState(true); 20 + const [error, setError] = useState(null); 21 + const [activeTab, setActiveTab] = useState("all"); 22 + 23 + useEffect(() => { 24 + async function fetchData() { 25 + if (!targetUrl) { 26 + setLoading(false); 27 + return; 28 + } 29 + 30 + try { 31 + setLoading(true); 32 + setError(null); 33 + 34 + const profileRes = await fetch( 35 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 36 + ); 37 + let did = handle; 38 + if (profileRes.ok) { 39 + const profileData = await profileRes.json(); 40 + setProfile(profileData); 41 + did = profileData.did; 42 + } 43 + 44 + const data = await getUserTargetItems(did, targetUrl); 45 + setAnnotations(data.annotations || []); 46 + setHighlights(data.highlights || []); 47 + } catch (err) { 48 + setError(err.message); 49 + } finally { 50 + setLoading(false); 51 + } 52 + } 53 + fetchData(); 54 + }, [handle, targetUrl]); 55 + 56 + const displayName = profile?.displayName || profile?.handle || handle; 57 + const displayHandle = 58 + profile?.handle || (handle?.startsWith("did:") ? null : handle); 59 + const avatarUrl = profile?.avatar; 60 + 61 + const getInitial = () => { 62 + return (displayName || displayHandle || "??") 63 + ?.substring(0, 2) 64 + .toUpperCase(); 65 + }; 66 + 67 + const totalItems = annotations.length + highlights.length; 68 + const bskyProfileUrl = displayHandle 69 + ? `https://bsky.app/profile/${displayHandle}` 70 + : `https://bsky.app/profile/${handle}`; 71 + 72 + const renderResults = () => { 73 + if (activeTab === "annotations" && annotations.length === 0) { 74 + return ( 75 + <div className="empty-state"> 76 + <div className="empty-state-icon"> 77 + <PenIcon size={32} /> 78 + </div> 79 + <h3 className="empty-state-title">No annotations</h3> 80 + </div> 81 + ); 82 + } 83 + 84 + if (activeTab === "highlights" && highlights.length === 0) { 85 + return ( 86 + <div className="empty-state"> 87 + <div className="empty-state-icon"> 88 + <HighlightIcon size={32} /> 89 + </div> 90 + <h3 className="empty-state-title">No highlights</h3> 91 + </div> 92 + ); 93 + } 94 + 95 + return ( 96 + <> 97 + {(activeTab === "all" || activeTab === "annotations") && 98 + annotations.map((a) => <AnnotationCard key={a.uri} annotation={a} />)} 99 + {(activeTab === "all" || activeTab === "highlights") && 100 + highlights.map((h) => <HighlightCard key={h.uri} highlight={h} />)} 101 + </> 102 + ); 103 + }; 104 + 105 + if (!targetUrl) { 106 + return ( 107 + <div className="user-url-page"> 108 + <div className="empty-state"> 109 + <div className="empty-state-icon"> 110 + <SearchIcon size={32} /> 111 + </div> 112 + <h3 className="empty-state-title">No URL specified</h3> 113 + <p className="empty-state-text"> 114 + Please provide a URL to view annotations. 115 + </p> 116 + </div> 117 + </div> 118 + ); 119 + } 120 + 121 + return ( 122 + <div className="user-url-page"> 123 + <header className="profile-header"> 124 + <a 125 + href={bskyProfileUrl} 126 + target="_blank" 127 + rel="noopener noreferrer" 128 + className="profile-avatar-link" 129 + > 130 + <div className="profile-avatar"> 131 + {avatarUrl ? ( 132 + <img src={avatarUrl} alt={displayName} /> 133 + ) : ( 134 + <span>{getInitial()}</span> 135 + )} 136 + </div> 137 + </a> 138 + <div className="profile-info"> 139 + <h1 className="profile-name">{displayName}</h1> 140 + {displayHandle && ( 141 + <a 142 + href={bskyProfileUrl} 143 + target="_blank" 144 + rel="noopener noreferrer" 145 + className="profile-bluesky-link" 146 + > 147 + <BlueskyIcon size={16} />@{displayHandle} 148 + </a> 149 + )} 150 + </div> 151 + </header> 152 + 153 + <div className="url-target-info"> 154 + <span className="url-target-label">Annotations on:</span> 155 + <a 156 + href={targetUrl} 157 + target="_blank" 158 + rel="noopener noreferrer" 159 + className="url-target-link" 160 + > 161 + {targetUrl} 162 + </a> 163 + </div> 164 + 165 + {loading && ( 166 + <div className="feed"> 167 + {[1, 2, 3].map((i) => ( 168 + <div key={i} className="card"> 169 + <div 170 + className="skeleton skeleton-text" 171 + style={{ width: "40%" }} 172 + /> 173 + <div className="skeleton skeleton-text" /> 174 + <div 175 + className="skeleton skeleton-text" 176 + style={{ width: "60%" }} 177 + /> 178 + </div> 179 + ))} 180 + </div> 181 + )} 182 + 183 + {error && ( 184 + <div className="empty-state"> 185 + <div className="empty-state-icon">⚠️</div> 186 + <h3 className="empty-state-title">Error</h3> 187 + <p className="empty-state-text">{error}</p> 188 + </div> 189 + )} 190 + 191 + {!loading && !error && totalItems === 0 && ( 192 + <div className="empty-state"> 193 + <div className="empty-state-icon"> 194 + <SearchIcon size={32} /> 195 + </div> 196 + <h3 className="empty-state-title">No items found</h3> 197 + <p className="empty-state-text"> 198 + {displayName} hasn&apos;t annotated this page yet. 199 + </p> 200 + </div> 201 + )} 202 + 203 + {!loading && !error && totalItems > 0 && ( 204 + <> 205 + <div className="url-results-header"> 206 + <h2 className="feed-title"> 207 + {totalItems} item{totalItems !== 1 ? "s" : ""} 208 + </h2> 209 + <div className="feed-filters"> 210 + <button 211 + className={`filter-tab ${activeTab === "all" ? "active" : ""}`} 212 + onClick={() => setActiveTab("all")} 213 + > 214 + All ({totalItems}) 215 + </button> 216 + <button 217 + className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`} 218 + onClick={() => setActiveTab("annotations")} 219 + > 220 + Annotations ({annotations.length}) 221 + </button> 222 + <button 223 + className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`} 224 + onClick={() => setActiveTab("highlights")} 225 + > 226 + Highlights ({highlights.length}) 227 + </button> 228 + </div> 229 + </div> 230 + <div className="feed">{renderResults()}</div> 231 + </> 232 + )} 233 + </div> 234 + ); 235 + }