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

Merge pull request #38 from hacdias/refactor/rename-notes-to-annotations

Some consistency improvements and a nicer button

authored by

Scan and committed by
GitHub
ac8157ff 2f3f1051

+87 -402
+4 -27
web/src/App.tsx
··· 1 1 import React from "react"; 2 - import { 3 - BrowserRouter, 4 - Routes, 5 - Route, 6 - Navigate, 7 - useSearchParams, 8 - } from "react-router-dom"; 2 + import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 9 3 import { initAuth } from "./store/auth"; 10 4 import { loadPreferences } from "./store/preferences"; 11 5 ··· 23 17 CollectionDetailWrapper, 24 18 AnnotationDetailWrapper, 25 19 UserUrlWrapper, 26 - SiteWrapper, 20 + UrlWrapper, 27 21 } from "./routes/wrappers"; 28 22 import About from "./views/About"; 29 23 import AdminModeration from "./views/core/AdminModeration"; 30 24 import NotFound from "./views/NotFound"; 31 - 32 - function UrlRedirect() { 33 - const [searchParams] = useSearchParams(); 34 - const q = searchParams.get("q"); 35 - if (q) { 36 - return <Navigate to={`/site/${encodeURIComponent(q)}`} replace />; 37 - } 38 - return <Navigate to="/site" replace />; 39 - } 40 25 41 26 export default function App() { 42 27 React.useEffect(() => { ··· 145 130 /> 146 131 147 132 <Route 148 - path="/url" 149 - element={ 150 - <AppLayout> 151 - <UrlRedirect /> 152 - </AppLayout> 153 - } 154 - /> 155 - <Route 156 133 path="/new" 157 134 element={ 158 135 <AppLayout> ··· 209 186 } 210 187 /> 211 188 <Route 212 - path="/site/*" 189 + path="/url/*" 213 190 element={ 214 191 <AppLayout> 215 - <SiteWrapper /> 192 + <UrlWrapper /> 216 193 </AppLayout> 217 194 } 218 195 />
+2 -2
web/src/components/navigation/MobileNav.tsx
··· 205 205 </Link> 206 206 207 207 <Link 208 - to="/site" 208 + to="/url" 209 209 className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 210 - isActive("/site") 210 + isActive("/url") 211 211 ? "text-primary-600" 212 212 : "text-surface-500 hover:text-surface-700" 213 213 }`}
+1 -1
web/src/components/navigation/RightSidebar.tsx
··· 18 18 19 19 const handleSearch = (e: React.KeyboardEvent) => { 20 20 if (e.key === "Enter" && searchQuery.trim()) { 21 - navigate(`/site/${encodeURIComponent(searchQuery.trim())}`); 21 + navigate(`/url/${encodeURIComponent(searchQuery.trim())}`); 22 22 } 23 23 }; 24 24
+6 -6
web/src/routes/wrappers.tsx
··· 1 + import { useStore } from "@nanostores/react"; 1 2 import React from "react"; 2 3 import { Navigate, useParams } from "react-router-dom"; 3 - import { useStore } from "@nanostores/react"; 4 4 import { $user } from "../store/auth"; 5 - import Profile from "../views/profile/Profile"; 6 5 import CollectionDetail from "../views/collections/CollectionDetail"; 7 6 import AnnotationDetail from "../views/content/AnnotationDetail"; 8 - import UserUrlPage from "../views/content/UserUrl"; 9 - import SitePage from "../views/content/SitePage"; 7 + import UrlPage from "../views/content/UrlPage"; 8 + import UserUrlPage from "../views/content/UserUrlPage"; 9 + import Profile from "../views/profile/Profile"; 10 10 11 11 export function ProfileWrapper() { 12 12 const { did } = useParams(); ··· 33 33 return <UserUrlPage />; 34 34 } 35 35 36 - export function SiteWrapper() { 37 - return <SitePage />; 36 + export function UrlWrapper() { 37 + return <UrlPage />; 38 38 }
+42 -37
web/src/views/content/SitePage.tsx web/src/views/content/UrlPage.tsx
··· 1 - import React, { useState, useEffect, useCallback } from "react"; 2 - import { useParams, useNavigate } from "react-router-dom"; 3 1 import { useStore } from "@nanostores/react"; 4 - import { $user } from "../../store/auth"; 5 - import { getByTarget } from "../../api/client"; 6 - import type { AnnotationItem } from "../../types"; 7 - import Card from "../../components/common/Card"; 8 2 import { 9 - PenTool, 10 - Highlighter, 11 - Search, 12 3 AlertTriangle, 13 - Globe, 14 - Copy, 15 4 Check, 5 + Copy, 16 6 ExternalLink, 7 + Globe, 8 + Highlighter, 17 9 Loader2, 18 - Users, 10 + PenTool, 11 + Search, 19 12 User, 13 + Users, 20 14 } from "lucide-react"; 21 - 22 - import { Tabs, EmptyState, Input, Button } from "../../components/ui"; 15 + import React, { useCallback, useEffect, useState } from "react"; 16 + import { useNavigate, useParams } from "react-router-dom"; 17 + import { getByTarget } from "../../api/client"; 18 + import Card from "../../components/common/Card"; 19 + import { Button, EmptyState, Input, Tabs } from "../../components/ui"; 20 + import { $user } from "../../store/auth"; 21 + import type { AnnotationItem } from "../../types"; 23 22 24 - export default function SitePage() { 23 + export default function UrlPage() { 25 24 const params = useParams(); 26 25 const navigate = useNavigate(); 27 26 const urlPath = params["*"]; ··· 35 34 "all" | "annotations" | "highlights" 36 35 >("all"); 37 36 const [copied, setCopied] = useState(false); 38 - const [userLinkCopied, setUserLinkCopied] = useState(false); 39 37 const user = useStore($user); 40 38 41 39 useEffect(() => { ··· 71 69 } 72 70 }, []); 73 71 74 - const handleCopyUserLink = useCallback(async () => { 72 + const handleNavigateMyAnnotations = useCallback(async () => { 75 73 if (!user?.handle || !targetUrl) return; 76 - try { 77 - const url = `${window.location.origin}/${user.handle}/url/${encodeURIComponent(targetUrl)}`; 78 - await navigator.clipboard.writeText(url); 79 - setUserLinkCopied(true); 80 - setTimeout(() => setUserLinkCopied(false), 2000); 81 - } catch (err) { 82 - console.error("Failed to copy user link:", err); 83 - } 84 - }, [user?.handle, targetUrl]); 74 + navigate(`/${user.handle}/url/${encodeURIComponent(targetUrl)}`); 75 + }, [user?.handle, targetUrl, navigate]); 85 76 86 77 const totalItems = annotations.length + highlights.length; 87 78 ··· 120 111 /> 121 112 </div> 122 113 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-3"> 123 - Site Annotations 114 + URL Annotations 124 115 </h1> 125 116 <p className="text-surface-500 dark:text-surface-400 max-w-md mx-auto mb-8"> 126 117 Enter a URL to see all public annotations and highlights from the ··· 134 125 const q = (formData.get("q") as string)?.trim(); 135 126 if (q) { 136 127 const encoded = encodeURIComponent(q); 137 - navigate(`/site/${encoded}`); 128 + navigate(`/url/${encoded}`); 138 129 } 139 130 }} 140 131 className="max-w-md mx-auto flex gap-2" ··· 152 143 </div> 153 144 </div> 154 145 ); 146 + } 147 + 148 + const items = [ 149 + ...(activeTab === "all" || activeTab === "annotations" ? annotations : []), 150 + ...(activeTab === "all" || activeTab === "highlights" ? highlights : []), 151 + ]; 152 + 153 + if (activeTab === "all") { 154 + items.sort((a, b) => { 155 + const dateA = new Date(a.createdAt).getTime(); 156 + const dateB = new Date(b.createdAt).getTime(); 157 + return dateB - dateA; 158 + }); 155 159 } 156 160 157 161 return ( ··· 185 189 <div className="flex items-center gap-2 shrink-0"> 186 190 {user && ( 187 191 <button 188 - onClick={handleCopyUserLink} 192 + onClick={handleNavigateMyAnnotations} 189 193 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors" 190 - title="Copy link to your annotations on this page" 194 + title="See your annotations for this page" 191 195 > 192 - {userLinkCopied ? <Check size={14} /> : <User size={14} />} 193 - {userLinkCopied ? "Copied!" : "My Link"} 196 + <User size={14} /> My Annotations 194 197 </button> 195 198 )} 196 199 <button ··· 257 260 <Tabs 258 261 tabs={[ 259 262 { id: "all", label: `All (${totalItems})` }, 260 - { id: "annotations", label: `Notes (${annotations.length})` }, 263 + { 264 + id: "annotations", 265 + label: `Annotations (${annotations.length})`, 266 + }, 261 267 { 262 268 id: "highlights", 263 269 label: `Highlights (${highlights.length})`, ··· 286 292 /> 287 293 )} 288 294 289 - {(activeTab === "all" || activeTab === "annotations") && 290 - annotations.map((a) => <Card key={a.uri} item={a} />)} 291 - {(activeTab === "all" || activeTab === "highlights") && 292 - highlights.map((h) => <Card key={h.uri} item={h} />)} 295 + {items.map((item) => ( 296 + <Card key={item.uri} item={item} /> 297 + ))} 293 298 </div> 294 299 </div> 295 300 )}
-311
web/src/views/content/Url.tsx
··· 1 - import React, { useState, useEffect, useCallback } from "react"; 2 - import { useNavigate, useSearchParams } from "react-router-dom"; 3 - import { useStore } from "@nanostores/react"; 4 - import { $user } from "../../store/auth"; 5 - import { getByTarget, searchActors } from "../../api/client"; 6 - import type { AnnotationItem } from "../../types"; 7 - import Card from "../../components/common/Card"; 8 - import { 9 - Search, 10 - PenTool, 11 - Highlighter, 12 - Loader2, 13 - AlertTriangle, 14 - Copy, 15 - Check, 16 - Clock, 17 - Globe, 18 - } from "lucide-react"; 19 - 20 - import { EmptyState, Tabs, Input, Button } from "../../components/ui"; 21 - 22 - export default function UrlPage() { 23 - const user = useStore($user); 24 - const navigate = useNavigate(); 25 - const [searchParams] = useSearchParams(); 26 - const query = searchParams.get("q"); 27 - 28 - const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 29 - const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 30 - const [loading, setLoading] = useState(false); 31 - const [error, setError] = useState<string | null>(null); 32 - const [activeTab, setActiveTab] = useState< 33 - "all" | "annotations" | "highlights" 34 - >("all"); 35 - const [copied, setCopied] = useState(false); 36 - const [recentSearches, setRecentSearches] = useState<string[]>([]); 37 - const [searched, setSearched] = useState(false); 38 - 39 - useEffect(() => { 40 - const stored = localStorage.getItem("margin-recent-searches"); 41 - if (stored) { 42 - try { 43 - setRecentSearches(JSON.parse(stored).slice(0, 5)); 44 - } catch (e) { 45 - console.warn("Failed to parse recent searches", e); 46 - } 47 - } 48 - }, []); 49 - 50 - const saveRecentSearch = useCallback((q: string) => { 51 - setRecentSearches((prev) => { 52 - const updated = [q, ...prev.filter((s) => s !== q)].slice(0, 5); 53 - localStorage.setItem("margin-recent-searches", JSON.stringify(updated)); 54 - return updated; 55 - }); 56 - }, []); 57 - 58 - useEffect(() => { 59 - const performSearch = async (urlOrHandle: string) => { 60 - if (!urlOrHandle.trim()) return; 61 - 62 - setLoading(true); 63 - setError(null); 64 - setSearched(true); 65 - setAnnotations([]); 66 - setHighlights([]); 67 - 68 - const isProtocol = 69 - urlOrHandle.startsWith("http://") || urlOrHandle.startsWith("https://"); 70 - 71 - if (isProtocol) { 72 - try { 73 - const data = await getByTarget(urlOrHandle); 74 - setAnnotations(data.annotations || []); 75 - setHighlights(data.highlights || []); 76 - saveRecentSearch(urlOrHandle); 77 - } catch (err) { 78 - setError(err instanceof Error ? err.message : "Search failed"); 79 - } finally { 80 - setLoading(false); 81 - } 82 - } else { 83 - try { 84 - const actorRes = await searchActors(urlOrHandle); 85 - if (actorRes?.actors?.length > 0) { 86 - const match = actorRes.actors[0]; 87 - navigate(`/profile/${encodeURIComponent(match.handle)}`, { 88 - replace: true, 89 - }); 90 - return; 91 - } else { 92 - setError( 93 - "User not found. To search for a URL, please include 'http://' or 'https://'.", 94 - ); 95 - setLoading(false); 96 - } 97 - } catch { 98 - setError("Failed to search user."); 99 - setLoading(false); 100 - } 101 - } 102 - }; 103 - 104 - if (query) { 105 - performSearch(query); 106 - } else { 107 - setSearched(false); 108 - setAnnotations([]); 109 - setHighlights([]); 110 - setLoading(false); 111 - } 112 - }, [query, navigate, saveRecentSearch]); 113 - 114 - const myAnnotations = user 115 - ? annotations.filter((a) => (a.author?.did || a.creator?.did) === user.did) 116 - : []; 117 - const myHighlights = user 118 - ? highlights.filter((h) => (h.author?.did || h.creator?.did) === user.did) 119 - : []; 120 - const myItemsCount = myAnnotations.length + myHighlights.length; 121 - 122 - const getShareUrl = () => { 123 - if (!user?.handle || !query) return null; 124 - return `${window.location.origin}/${user.handle}/url/${encodeURIComponent(query)}`; 125 - }; 126 - 127 - const handleCopyShareLink = async () => { 128 - const shareUrl = getShareUrl(); 129 - if (!shareUrl) return; 130 - try { 131 - await navigator.clipboard.writeText(shareUrl); 132 - setCopied(true); 133 - setTimeout(() => setCopied(false), 2000); 134 - } catch (err) { 135 - console.error("Failed to copy link:", err); 136 - } 137 - }; 138 - 139 - const totalItems = annotations.length + highlights.length; 140 - 141 - const renderResults = () => { 142 - if (activeTab === "annotations" && annotations.length === 0) { 143 - return ( 144 - <EmptyState 145 - icon={<PenTool size={32} />} 146 - title="No annotations" 147 - message="There are no annotations for this URL yet." 148 - /> 149 - ); 150 - } 151 - 152 - if (activeTab === "highlights" && highlights.length === 0) { 153 - return ( 154 - <EmptyState 155 - icon={<Highlighter size={32} />} 156 - title="No highlights" 157 - message="There are no highlights for this URL yet." 158 - /> 159 - ); 160 - } 161 - 162 - return ( 163 - <div className="space-y-4"> 164 - {(activeTab === "all" || activeTab === "annotations") && 165 - annotations.map((a) => <Card key={a.uri} item={a} />)} 166 - {(activeTab === "all" || activeTab === "highlights") && 167 - highlights.map((h) => <Card key={h.uri} item={h} />)} 168 - </div> 169 - ); 170 - }; 171 - 172 - const handleRecentClick = (q: string) => { 173 - navigate(`/url?q=${encodeURIComponent(q)}`); 174 - }; 175 - 176 - return ( 177 - <div className="max-w-2xl mx-auto pb-20 animate-fade-in"> 178 - {!query && ( 179 - <div className="text-center py-10"> 180 - <div className="w-16 h-16 bg-primary-50 dark:bg-primary-900/20 rounded-2xl flex items-center justify-center mx-auto mb-6 rotate-3"> 181 - <Search 182 - size={32} 183 - className="text-primary-600 dark:text-primary-400" 184 - /> 185 - </div> 186 - <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-3"> 187 - Explore 188 - </h1> 189 - <p className="text-surface-500 dark:text-surface-400 max-w-md mx-auto mb-8"> 190 - Search for any URL in the sidebar to see specific annotations and 191 - highlights. 192 - </p> 193 - 194 - <form 195 - onSubmit={(e) => { 196 - e.preventDefault(); 197 - const formData = new FormData(e.currentTarget); 198 - const q = formData.get("q") as string; 199 - if (q?.trim()) { 200 - navigate(`/url?q=${encodeURIComponent(q.trim())}`); 201 - } 202 - }} 203 - className="max-w-md mx-auto mb-8 flex gap-2" 204 - > 205 - <div className="flex-1"> 206 - <Input 207 - name="q" 208 - placeholder="https://example.com" 209 - className="w-full bg-surface-50 dark:bg-surface-800" 210 - autoFocus 211 - /> 212 - </div> 213 - <Button type="submit">Search</Button> 214 - </form> 215 - 216 - {recentSearches.length > 0 && ( 217 - <div className="text-left max-w-lg mx-auto bg-surface-50 dark:bg-surface-800/50 rounded-2xl p-6 border border-surface-100 dark:border-surface-800"> 218 - <h3 className="text-sm font-bold text-surface-900 dark:text-white mb-4 flex items-center gap-2"> 219 - <Clock size={16} className="text-primary-500" /> 220 - Recent Searches 221 - </h3> 222 - <div className="flex flex-wrap gap-2"> 223 - {recentSearches.map((q, i) => ( 224 - <button 225 - key={i} 226 - onClick={() => handleRecentClick(q)} 227 - className="px-3 py-1.5 bg-white dark:bg-surface-700 hover:bg-surface-50 dark:hover:bg-surface-600 rounded-lg text-sm text-surface-700 dark:text-surface-200 transition-colors shadow-sm ring-1 ring-black/5 dark:ring-white/5 flex items-center gap-2" 228 - > 229 - <Globe size={12} className="opacity-50" /> 230 - <span className="truncate max-w-[200px]"> 231 - {q.replace(/^https?:\/\//, "")} 232 - </span> 233 - </button> 234 - ))} 235 - </div> 236 - </div> 237 - )} 238 - </div> 239 - )} 240 - 241 - {loading && ( 242 - <div className="flex flex-col items-center justify-center py-20"> 243 - <Loader2 244 - className="animate-spin text-primary-600 dark:text-primary-400 mb-4" 245 - size={32} 246 - /> 247 - <p className="text-surface-500 dark:text-surface-400">Searching...</p> 248 - </div> 249 - )} 250 - 251 - {error && ( 252 - <div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-xl flex items-start gap-3 border border-red-100 dark:border-red-900/30 mb-6"> 253 - <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 254 - <p>{error}</p> 255 - </div> 256 - )} 257 - 258 - {searched && !loading && !error && totalItems === 0 && ( 259 - <EmptyState 260 - icon={<Search size={48} />} 261 - title="No results found" 262 - message="We couldn't find any annotations for this URL. Be the first to add one!" 263 - /> 264 - )} 265 - 266 - {searched && !loading && !error && totalItems > 0 && ( 267 - <div> 268 - <div className="flex items-center justify-between gap-4 mb-6"> 269 - <div> 270 - <h1 className="text-2xl font-bold text-surface-900 dark:text-white truncate max-w-md"> 271 - {query?.replace(/^https?:\/\//, "")} 272 - </h1> 273 - <p className="text-surface-500 dark:text-surface-400 text-sm"> 274 - {totalItems} result{totalItems !== 1 ? "s" : ""} found 275 - </p> 276 - </div> 277 - 278 - {user && myItemsCount > 0 && ( 279 - <button 280 - onClick={handleCopyShareLink} 281 - className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white text-sm font-medium rounded-lg transition-colors" 282 - > 283 - {copied ? <Check size={14} /> : <Copy size={14} />} 284 - {copied ? "Copied" : "Share your thoughts on this URL"} 285 - </button> 286 - )} 287 - </div> 288 - 289 - <div className="mb-6"> 290 - <Tabs 291 - tabs={[ 292 - { id: "all", label: `All (${totalItems})` }, 293 - { id: "annotations", label: `Notes (${annotations.length})` }, 294 - { 295 - id: "highlights", 296 - label: `Highlights (${highlights.length})`, 297 - }, 298 - ]} 299 - activeTab={activeTab} 300 - onChange={(id: string) => 301 - setActiveTab(id as "all" | "annotations" | "highlights") 302 - } 303 - /> 304 - </div> 305 - 306 - {renderResults()} 307 - </div> 308 - )} 309 - </div> 310 - ); 311 - }
+27 -14
web/src/views/content/UserUrl.tsx web/src/views/content/UserUrlPage.tsx
··· 1 - import React, { useState, useEffect } from "react"; 2 - import { useParams } from "react-router-dom"; 3 - import { getUserTargetItems } from "../../api/client"; 4 - import type { AnnotationItem, UserProfile } from "../../types"; 5 - import Card from "../../components/common/Card"; 1 + import { clsx } from "clsx"; 6 2 import { 7 - PenTool, 3 + AlertTriangle, 4 + ExternalLink, 8 5 Highlighter, 6 + PenTool, 9 7 Search, 10 - AlertTriangle, 11 - ExternalLink, 12 8 } from "lucide-react"; 13 - import { clsx } from "clsx"; 14 - import { getAvatarUrl } from "../../api/client"; 9 + import React, { useEffect, useState } from "react"; 10 + import { useParams } from "react-router-dom"; 11 + import { getAvatarUrl, getUserTargetItems } from "../../api/client"; 12 + import Card from "../../components/common/Card"; 13 + import type { AnnotationItem, UserProfile } from "../../types"; 15 14 16 15 export default function UserUrlPage() { 17 16 const params = useParams(); ··· 107 106 ); 108 107 } 109 108 109 + const items = [ 110 + ...(activeTab === "all" || activeTab === "annotations" 111 + ? annotations 112 + : []), 113 + ...(activeTab === "all" || activeTab === "highlights" ? highlights : []), 114 + ]; 115 + 116 + if (activeTab === "all") { 117 + items.sort((a, b) => { 118 + const dateA = new Date(a.createdAt).getTime(); 119 + const dateB = new Date(b.createdAt).getTime(); 120 + return dateB - dateA; 121 + }); 122 + } 123 + 110 124 return ( 111 125 <div className="space-y-6"> 112 - {(activeTab === "all" || activeTab === "annotations") && 113 - annotations.map((a) => <Card key={a.uri} item={a} />)} 114 - {(activeTab === "all" || activeTab === "highlights") && 115 - highlights.map((h) => <Card key={h.uri} item={h} />)} 126 + {items.map((item) => ( 127 + <Card key={item.uri} item={item} /> 128 + ))} 116 129 </div> 117 130 ); 118 131 };
+5 -4
web/src/views/core/HighlightImporter.tsx
··· 1 - import React, { useState, useRef } from "react"; 2 1 import { 3 - Upload, 4 - Loader2, 2 + AlertCircle, 5 3 CheckCircle2, 6 - AlertCircle, 7 4 Download, 5 + Loader2, 6 + Upload, 8 7 } from "lucide-react"; 8 + import type React from "react"; 9 + import { useRef, useState } from "react"; 9 10 import { createHighlight } from "../../api/client"; 10 11 import type { Selector } from "../../types"; 11 12