Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

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 + }