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

bug fixes and improvements

+643 -284
+68 -31
backend/internal/api/og.go
··· 948 height := 630 949 padding := 100 950 951 - bgPrimary := color.RGBA{10, 10, 13, 255} 952 - accent := color.RGBA{149, 122, 134, 255} 953 - textPrimary := color.RGBA{234, 234, 238, 255} 954 - textSecondary := color.RGBA{168, 164, 171, 255} 955 - border := color.RGBA{42, 40, 46, 255} 956 957 img := image.NewRGBA(image.Rect(0, 0, width, height)) 958 959 - draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 960 - draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 961 962 avatarSize := 64 963 avatarX := padding ··· 967 if avatarImg != nil { 968 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 969 } else { 970 - drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 971 } 972 drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 973 ··· 1015 numLines := min(len(lines), maxQuoteLines) 1016 barHeight := numLines * quoteLineHeight 1017 1018 - draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 1019 1020 for i := 0; i < numLines; i++ { 1021 line := lines[i] ··· 1027 yPos += barHeight + 40 1028 } 1029 1030 - draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1031 yPos += 40 1032 drawText(img, source, padding, yPos+32, textSecondary, 24, false) 1033 1034 return img 1035 } ··· 1155 height := 630 1156 padding := 120 1157 1158 - bgPrimary := color.RGBA{10, 10, 13, 255} 1159 - accent := color.RGBA{149, 122, 134, 255} 1160 - textPrimary := color.RGBA{234, 234, 238, 255} 1161 - textSecondary := color.RGBA{168, 164, 171, 255} 1162 - textTertiary := color.RGBA{107, 103, 112, 255} 1163 - border := color.RGBA{42, 40, 46, 255} 1164 1165 img := image.NewRGBA(image.Rect(0, 0, width, height)) 1166 - 1167 - draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 1168 - draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 1169 1170 iconY := 120 1171 var iconWidth int ··· 1202 } 1203 1204 yPos = 480 1205 - draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1206 1207 avatarSize := 64 1208 avatarX := padding ··· 1212 if avatarImg != nil { 1213 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1214 } else { 1215 - drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 1216 } 1217 1218 handleX := avatarX + avatarSize + 24 1219 drawText(img, author, handleX, avatarY+42, textTertiary, 28, false) 1220 1221 return img 1222 } 1223 ··· 1257 height := 630 1258 padding := 100 1259 1260 - bgPrimary := color.RGBA{10, 10, 13, 255} 1261 - accent := color.RGBA{149, 122, 134, 255} 1262 - textPrimary := color.RGBA{234, 234, 238, 255} 1263 - textSecondary := color.RGBA{168, 164, 171, 255} 1264 - border := color.RGBA{42, 40, 46, 255} 1265 1266 img := image.NewRGBA(image.Rect(0, 0, width, height)) 1267 1268 - draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 1269 - draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 1270 1271 avatarSize := 64 1272 avatarX := padding ··· 1276 if avatarImg != nil { 1277 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1278 } else { 1279 - drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 1280 } 1281 drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 1282 ··· 1302 numLines := min(len(lines), maxLines) 1303 barHeight := numLines * lineHeight 1304 1305 - draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 1306 1307 for i := 0; i < numLines; i++ { 1308 line := lines[i] ··· 1314 yPos += barHeight + 40 1315 } 1316 1317 - draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1318 yPos += 40 1319 1320 if pageTitle != "" { ··· 1326 1327 if source != "" { 1328 drawText(img, source, padding, yPos+80, textSecondary, 24, false) 1329 } 1330 1331 return img
··· 948 height := 630 949 padding := 100 950 951 + bgColor := color.RGBA{9, 9, 11, 255} 952 + primaryColor := color.RGBA{59, 130, 246, 255} 953 + primaryLight := color.RGBA{96, 165, 250, 255} 954 + textPrimary := color.RGBA{250, 250, 250, 255} 955 + textSecondary := color.RGBA{161, 161, 170, 255} 956 + borderColor := color.RGBA{63, 63, 70, 255} 957 + cardBg := color.RGBA{24, 24, 27, 255} 958 959 img := image.NewRGBA(image.Rect(0, 0, width, height)) 960 961 + draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) 962 + draw.Draw(img, image.Rect(0, 0, width, 6), &image.Uniform{primaryColor}, image.Point{}, draw.Src) 963 + draw.Draw(img, image.Rect(60, 50, width-60, height-50), &image.Uniform{cardBg}, image.Point{}, draw.Src) 964 + draw.Draw(img, image.Rect(60, 50, width-60, 51), &image.Uniform{borderColor}, image.Point{}, draw.Src) 965 + draw.Draw(img, image.Rect(60, height-51, width-60, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 966 + draw.Draw(img, image.Rect(60, 50, 61, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 967 + draw.Draw(img, image.Rect(width-61, 50, width-60, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 968 969 avatarSize := 64 970 avatarX := padding ··· 974 if avatarImg != nil { 975 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 976 } else { 977 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, primaryColor) 978 } 979 drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 980 ··· 1022 numLines := min(len(lines), maxQuoteLines) 1023 barHeight := numLines * quoteLineHeight 1024 1025 + draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{primaryLight}, image.Point{}, draw.Src) 1026 1027 for i := 0; i < numLines; i++ { 1028 line := lines[i] ··· 1034 yPos += barHeight + 40 1035 } 1036 1037 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1038 yPos += 40 1039 drawText(img, source, padding, yPos+32, textSecondary, 24, false) 1040 + 1041 + if logoImage != nil { 1042 + logoSize := 36 1043 + drawScaledImage(img, logoImage, width-padding-logoSize, height-80, logoSize, logoSize) 1044 + drawText(img, "margin.at", width-padding-logoSize-120, height-52, textSecondary, 22, true) 1045 + } 1046 1047 return img 1048 } ··· 1168 height := 630 1169 padding := 120 1170 1171 + bgColor := color.RGBA{9, 9, 11, 255} 1172 + primaryColor := color.RGBA{59, 130, 246, 255} 1173 + textPrimary := color.RGBA{250, 250, 250, 255} 1174 + textSecondary := color.RGBA{161, 161, 170, 255} 1175 + textTertiary := color.RGBA{113, 113, 122, 255} 1176 + borderColor := color.RGBA{63, 63, 70, 255} 1177 + cardBg := color.RGBA{24, 24, 27, 255} 1178 1179 img := image.NewRGBA(image.Rect(0, 0, width, height)) 1180 + draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) 1181 + draw.Draw(img, image.Rect(0, 0, width, 6), &image.Uniform{primaryColor}, image.Point{}, draw.Src) 1182 + draw.Draw(img, image.Rect(60, 50, width-60, height-50), &image.Uniform{cardBg}, image.Point{}, draw.Src) 1183 + draw.Draw(img, image.Rect(60, 50, width-60, 51), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1184 + draw.Draw(img, image.Rect(60, height-51, width-60, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1185 + draw.Draw(img, image.Rect(60, 50, 61, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1186 + draw.Draw(img, image.Rect(width-61, 50, width-60, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1187 1188 iconY := 120 1189 var iconWidth int ··· 1220 } 1221 1222 yPos = 480 1223 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1224 1225 avatarSize := 64 1226 avatarX := padding ··· 1230 if avatarImg != nil { 1231 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1232 } else { 1233 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, primaryColor) 1234 } 1235 1236 handleX := avatarX + avatarSize + 24 1237 drawText(img, author, handleX, avatarY+42, textTertiary, 28, false) 1238 1239 + if logoImage != nil { 1240 + logoSize := 36 1241 + drawScaledImage(img, logoImage, width-padding-logoSize, height-80, logoSize, logoSize) 1242 + drawText(img, "margin.at", width-padding-logoSize-120, height-52, textSecondary, 22, true) 1243 + } 1244 + 1245 return img 1246 } 1247 ··· 1281 height := 630 1282 padding := 100 1283 1284 + bgColor := color.RGBA{9, 9, 11, 255} 1285 + primaryColor := color.RGBA{59, 130, 246, 255} 1286 + primaryLight := color.RGBA{96, 165, 250, 255} 1287 + textPrimary := color.RGBA{250, 250, 250, 255} 1288 + textSecondary := color.RGBA{161, 161, 170, 255} 1289 + borderColor := color.RGBA{63, 63, 70, 255} 1290 + cardBg := color.RGBA{24, 24, 27, 255} 1291 1292 img := image.NewRGBA(image.Rect(0, 0, width, height)) 1293 1294 + draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) 1295 + draw.Draw(img, image.Rect(0, 0, width, 6), &image.Uniform{primaryColor}, image.Point{}, draw.Src) 1296 + draw.Draw(img, image.Rect(60, 50, width-60, height-50), &image.Uniform{cardBg}, image.Point{}, draw.Src) 1297 + draw.Draw(img, image.Rect(60, 50, width-60, 51), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1298 + draw.Draw(img, image.Rect(60, height-51, width-60, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1299 + draw.Draw(img, image.Rect(60, 50, 61, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1300 + draw.Draw(img, image.Rect(width-61, 50, width-60, height-50), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1301 1302 avatarSize := 64 1303 avatarX := padding ··· 1307 if avatarImg != nil { 1308 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1309 } else { 1310 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, primaryColor) 1311 } 1312 drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 1313 ··· 1333 numLines := min(len(lines), maxLines) 1334 barHeight := numLines * lineHeight 1335 1336 + draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{primaryLight}, image.Point{}, draw.Src) 1337 1338 for i := 0; i < numLines; i++ { 1339 line := lines[i] ··· 1345 yPos += barHeight + 40 1346 } 1347 1348 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{borderColor}, image.Point{}, draw.Src) 1349 yPos += 40 1350 1351 if pageTitle != "" { ··· 1357 1358 if source != "" { 1359 drawText(img, source, padding, yPos+80, textSecondary, 24, false) 1360 + } 1361 + 1362 + if logoImage != nil { 1363 + logoSize := 36 1364 + drawScaledImage(img, logoImage, width-padding-logoSize, height-80, logoSize, logoSize) 1365 + drawText(img, "margin.at", width-padding-logoSize-120, height-52, textSecondary, 22, true) 1366 } 1367 1368 return img
web/public/og.png

This is a binary file and will not be displayed.

+1 -2
web/src/App.tsx
··· 21 } from "./routes/wrappers"; 22 import About from "./views/About"; 23 import AdminModeration from "./views/core/AdminModeration"; 24 - import NotFound from "./views/NotFound"; 25 26 export default function App() { 27 React.useEffect(() => { ··· 220 } 221 /> 222 223 - <Route path="*" element={<NotFound />} /> 224 </Routes> 225 </BrowserRouter> 226 );
··· 21 } from "./routes/wrappers"; 22 import About from "./views/About"; 23 import AdminModeration from "./views/core/AdminModeration"; 24 25 export default function App() { 26 React.useEffect(() => { ··· 219 } 220 /> 221 222 + <Route path="*" element={<Navigate to="/home" replace />} /> 223 </Routes> 224 </BrowserRouter> 225 );
+163 -9
web/src/components/navigation/RightSidebar.tsx
··· 1 - import React, { useEffect, useState } from "react"; 2 import { useNavigate } from "react-router-dom"; 3 import { Search } from "lucide-react"; 4 - import { getTrendingTags, type Tag } from "../../api/client"; 5 6 export default function RightSidebar() { 7 const navigate = useNavigate(); ··· 15 return "other"; 16 }); 17 const [searchQuery, setSearchQuery] = useState(""); 18 19 - const handleSearch = (e: React.KeyboardEvent) => { 20 - if (e.key === "Enter" && searchQuery.trim()) { 21 - navigate(`/url/${encodeURIComponent(searchQuery.trim())}`); 22 } 23 - }; 24 25 useEffect(() => { 26 getTrendingTags(10).then(setTags); ··· 44 /> 45 </div> 46 <input 47 type="text" 48 value={searchQuery} 49 - onChange={(e) => setSearchQuery(e.target.value)} 50 - onKeyDown={handleSearch} 51 - placeholder="Search..." 52 className="w-full bg-surface-100 dark:bg-surface-800/80 rounded-lg pl-9 pr-4 py-2 text-sm text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:bg-white dark:focus:bg-surface-800 transition-all border border-surface-200/60 dark:border-surface-700/60" 53 /> 54 </div> 55 56 <div className="rounded-xl p-4 bg-gradient-to-br from-primary-50 to-primary-100/50 dark:from-primary-950/30 dark:to-primary-900/10 border border-primary-200/40 dark:border-primary-800/30">
··· 1 + import React, { useCallback, useEffect, useRef, useState } from "react"; 2 import { useNavigate } from "react-router-dom"; 3 import { Search } from "lucide-react"; 4 + import { 5 + getTrendingTags, 6 + searchActors, 7 + type ActorSearchItem, 8 + type Tag, 9 + } from "../../api/client"; 10 + import { Avatar } from "../ui"; 11 + 12 + function looksLikeUrl(query: string): boolean { 13 + const q = query.trim().toLowerCase(); 14 + return ( 15 + q.startsWith("http://") || 16 + q.startsWith("https://") || 17 + /\.(com|org|net|io|dev|me|co|app|xyz|edu|gov)\b/.test(q) 18 + ); 19 + } 20 21 export default function RightSidebar() { 22 const navigate = useNavigate(); ··· 30 return "other"; 31 }); 32 const [searchQuery, setSearchQuery] = useState(""); 33 + const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); 34 + const [showSuggestions, setShowSuggestions] = useState(false); 35 + const [selectedIndex, setSelectedIndex] = useState(-1); 36 37 + const inputRef = useRef<HTMLInputElement>(null); 38 + const suggestionsRef = useRef<HTMLDivElement>(null); 39 + const isSelectionRef = useRef(false); 40 + const latestQueryRef = useRef(searchQuery); 41 + 42 + useEffect(() => { 43 + latestQueryRef.current = searchQuery; 44 + 45 + if (searchQuery.length < 3 || looksLikeUrl(searchQuery)) { 46 + return; 47 + } 48 + 49 + if (isSelectionRef.current) { 50 + isSelectionRef.current = false; 51 + return; 52 } 53 + 54 + const capturedQuery = searchQuery; 55 + const timer = setTimeout(async () => { 56 + try { 57 + const data = await searchActors(capturedQuery); 58 + if (capturedQuery !== latestQueryRef.current) return; 59 + setSuggestions(data.actors || []); 60 + setShowSuggestions((data.actors || []).length > 0); 61 + setSelectedIndex(-1); 62 + } catch (e) { 63 + console.error("Search failed:", e); 64 + } 65 + }, 300); 66 + 67 + return () => clearTimeout(timer); 68 + }, [searchQuery]); 69 + 70 + useEffect(() => { 71 + const handleClickOutside = (e: MouseEvent) => { 72 + if ( 73 + suggestionsRef.current && 74 + !suggestionsRef.current.contains(e.target as Node) && 75 + inputRef.current && 76 + !inputRef.current.contains(e.target as Node) 77 + ) { 78 + setShowSuggestions(false); 79 + } 80 + }; 81 + document.addEventListener("mousedown", handleClickOutside); 82 + return () => document.removeEventListener("mousedown", handleClickOutside); 83 + }, []); 84 + 85 + const selectSuggestion = useCallback( 86 + (actor: ActorSearchItem) => { 87 + isSelectionRef.current = true; 88 + setSearchQuery(""); 89 + setSuggestions([]); 90 + setShowSuggestions(false); 91 + navigate(`/profile/${encodeURIComponent(actor.handle)}`); 92 + }, 93 + [navigate], 94 + ); 95 + 96 + const handleKeyDown = useCallback( 97 + (e: React.KeyboardEvent) => { 98 + if (showSuggestions && suggestions.length > 0) { 99 + if (e.key === "ArrowDown") { 100 + e.preventDefault(); 101 + setSelectedIndex((prev) => 102 + Math.min(prev + 1, suggestions.length - 1), 103 + ); 104 + return; 105 + } else if (e.key === "ArrowUp") { 106 + e.preventDefault(); 107 + setSelectedIndex((prev) => Math.max(prev - 1, -1)); 108 + return; 109 + } else if (e.key === "Enter" && selectedIndex >= 0) { 110 + e.preventDefault(); 111 + selectSuggestion(suggestions[selectedIndex]); 112 + return; 113 + } else if (e.key === "Escape") { 114 + setShowSuggestions(false); 115 + return; 116 + } 117 + } 118 + 119 + if (e.key === "Enter" && searchQuery.trim()) { 120 + const q = searchQuery.trim(); 121 + if (looksLikeUrl(q)) { 122 + navigate(`/url/${encodeURIComponent(q)}`); 123 + } else { 124 + navigate(`/profile/${encodeURIComponent(q)}`); 125 + } 126 + setSearchQuery(""); 127 + setSuggestions([]); 128 + setShowSuggestions(false); 129 + } 130 + }, 131 + [ 132 + showSuggestions, 133 + suggestions, 134 + selectedIndex, 135 + searchQuery, 136 + navigate, 137 + selectSuggestion, 138 + ], 139 + ); 140 141 useEffect(() => { 142 getTrendingTags(10).then(setTags); ··· 160 /> 161 </div> 162 <input 163 + ref={inputRef} 164 type="text" 165 value={searchQuery} 166 + onChange={(e) => { 167 + setSearchQuery(e.target.value); 168 + if (e.target.value.length < 3) { 169 + setSuggestions([]); 170 + setShowSuggestions(false); 171 + } 172 + }} 173 + onKeyDown={handleKeyDown} 174 + onFocus={() => 175 + searchQuery.length >= 3 && 176 + suggestions.length > 0 && 177 + setShowSuggestions(true) 178 + } 179 + placeholder="Search users or URLs..." 180 className="w-full bg-surface-100 dark:bg-surface-800/80 rounded-lg pl-9 pr-4 py-2 text-sm text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:bg-white dark:focus:bg-surface-800 transition-all border border-surface-200/60 dark:border-surface-700/60" 181 /> 182 + 183 + {showSuggestions && suggestions.length > 0 && ( 184 + <div 185 + ref={suggestionsRef} 186 + className="absolute top-[calc(100%+6px)] left-0 right-0 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[280px] overflow-y-auto" 187 + > 188 + {suggestions.map((actor, index) => ( 189 + <button 190 + key={actor.did} 191 + type="button" 192 + className={`w-full flex items-center gap-3 px-3.5 py-2.5 border-b border-surface-100 dark:border-surface-800 last:border-0 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors text-left ${index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : ""}`} 193 + onClick={() => selectSuggestion(actor)} 194 + > 195 + <Avatar src={actor.avatar} size="sm" /> 196 + <div className="min-w-0 flex-1"> 197 + <div className="font-semibold text-surface-900 dark:text-white truncate text-sm leading-tight"> 198 + {actor.displayName || actor.handle} 199 + </div> 200 + <div className="text-surface-500 dark:text-surface-400 text-xs truncate"> 201 + @{actor.handle} 202 + </div> 203 + </div> 204 + </button> 205 + ))} 206 + </div> 207 + )} 208 </div> 209 210 <div className="rounded-xl p-4 bg-gradient-to-br from-primary-50 to-primary-100/50 dark:from-primary-950/30 dark:to-primary-900/10 border border-primary-200/40 dark:border-primary-800/30">
+13 -1
web/src/layouts/BaseLayout.astro
··· 4 interface Props { 5 title?: string; 6 description?: string; 7 } 8 9 - const { title = 'Margin', description = 'Annotate the web' } = Astro.props; 10 --- 11 12 <!DOCTYPE html> ··· 20 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 21 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 22 <meta name="generator" content={Astro.generator} /> 23 <title>{title}</title> 24 </head> 25 <body class="bg-surface-50 dark:bg-surface-950 min-h-screen text-surface-900 dark:text-white">
··· 4 interface Props { 5 title?: string; 6 description?: string; 7 + image?: string; 8 } 9 10 + const { title = 'Margin', description = 'Annotate the web', image = 'https://margin.at/og.png' } = Astro.props; 11 --- 12 13 <!DOCTYPE html> ··· 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 23 <meta name="generator" content={Astro.generator} /> 24 + <meta property="og:type" content="website" /> 25 + <meta property="og:url" content={Astro.url} /> 26 + <meta property="og:title" content={title} /> 27 + <meta property="og:description" content={description} /> 28 + <meta property="og:image" content={image} /> 29 + <meta property="og:site_name" content="Margin" /> 30 + <meta name="twitter:card" content="summary_large_image" /> 31 + <meta name="twitter:title" content={title} /> 32 + <meta name="twitter:description" content={description} /> 33 + <meta name="twitter:image" content={image} /> 34 + 35 <title>{title}</title> 36 </head> 37 <body class="bg-surface-50 dark:bg-surface-950 min-h-screen text-surface-900 dark:text-white">
-35
web/src/views/NotFound.tsx
··· 1 - import React from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { Home, AlertCircle } from "lucide-react"; 4 - import { useStore } from "@nanostores/react"; 5 - import { $theme } from "../store/theme"; 6 - 7 - export default function NotFound() { 8 - useStore($theme); 9 - 10 - return ( 11 - <div className="min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4"> 12 - <div className="w-full max-w-md bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none text-center"> 13 - <div className="w-16 h-16 bg-surface-50 dark:bg-surface-800 rounded-2xl flex items-center justify-center mx-auto mb-6 text-surface-400 dark:text-surface-500"> 14 - <AlertCircle size={32} /> 15 - </div> 16 - 17 - <h1 className="text-3xl font-bold font-display text-surface-900 dark:text-white mb-3 tracking-tight"> 18 - Page not found 19 - </h1> 20 - 21 - <p className="text-surface-500 dark:text-surface-400 text-base mb-8 leading-relaxed max-w-xs mx-auto"> 22 - The page you are looking for doesn't exist or has been moved. 23 - </p> 24 - 25 - <Link 26 - to="/home" 27 - className="inline-flex items-center justify-center gap-2 px-6 py-3.5 w-full bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-xl font-bold text-sm transition-transform active:scale-[0.98] hover:bg-surface-800 dark:hover:bg-surface-50 shadow-lg shadow-surface-900/10 dark:shadow-none" 28 - > 29 - <Home size={18} /> 30 - Back to Home 31 - </Link> 32 - </div> 33 - </div> 34 - ); 35 - }
···
+10 -4
web/src/views/collections/CollectionDetail.tsx
··· 146 <span> 147 by{" "} 148 <Link 149 - to={`/profile/${collection.creator.did}`} 150 className="hover:text-primary-600 dark:hover:text-primary-400 hover:underline transition-colors" 151 > 152 - {collection.creator.displayName || collection.creator.handle} 153 </Link> 154 </span> 155 </div> ··· 157 <div className="flex items-center gap-1"> 158 <ShareMenu 159 uri={collection.uri} 160 - handle={collection.creator.handle} 161 type="Collection" 162 text={collection.name} 163 /> ··· 187 isOpen={isEditModalOpen} 188 onClose={() => setIsEditModalOpen(false)} 189 collection={collection} 190 - onUpdate={(updated) => setCollection(updated)} 191 /> 192 193 <div className="space-y-2">
··· 146 <span> 147 by{" "} 148 <Link 149 + to={`/profile/${collection.creator?.did}`} 150 className="hover:text-primary-600 dark:hover:text-primary-400 hover:underline transition-colors" 151 > 152 + {collection.creator?.displayName || 153 + collection.creator?.handle} 154 </Link> 155 </span> 156 </div> ··· 158 <div className="flex items-center gap-1"> 159 <ShareMenu 160 uri={collection.uri} 161 + handle={collection.creator?.handle} 162 type="Collection" 163 text={collection.name} 164 /> ··· 188 isOpen={isEditModalOpen} 189 onClose={() => setIsEditModalOpen(false)} 190 collection={collection} 191 + onUpdate={(updated) => 192 + setCollection({ 193 + ...updated, 194 + creator: updated.creator || collection.creator, 195 + }) 196 + } 197 /> 198 199 <div className="space-y-2">
+73 -23
web/src/views/content/UrlPage.tsx
··· 12 User, 13 Users, 14 } from "lucide-react"; 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"; ··· 29 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 30 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 31 const [loading, setLoading] = useState(true); 32 const [error, setError] = useState<string | null>(null); 33 const [activeTab, setActiveTab] = useState< 34 "all" | "annotations" | "highlights" ··· 36 const [copied, setCopied] = useState(false); 37 const user = useStore($user); 38 39 useEffect(() => { 40 async function fetchData() { 41 if (!targetUrl) { ··· 47 setLoading(true); 48 setError(null); 49 50 - const data = await getByTarget(targetUrl); 51 - setAnnotations(data.annotations || []); 52 - setHighlights(data.highlights || []); 53 } catch (err) { 54 setError(err instanceof Error ? err.message : "Failed to load data"); 55 } finally { ··· 59 fetchData(); 60 }, [targetUrl]); 61 62 const handleCopyLink = useCallback(async () => { 63 try { 64 await navigator.clipboard.writeText(window.location.href); ··· 210 {!loading && totalItems > 0 && ( 211 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700 flex items-center gap-4 text-sm text-surface-500 dark:text-surface-400"> 212 <span className="flex items-center gap-1.5"> 213 - <PenTool size={14} /> 214 - {annotations.length} annotation 215 - {annotations.length !== 1 ? "s" : ""} 216 - </span> 217 - <span className="flex items-center gap-1.5"> 218 - <Highlighter size={14} /> 219 - {highlights.length} highlight 220 - {highlights.length !== 1 ? "s" : ""} 221 - </span> 222 - <span className="flex items-center gap-1.5"> 223 <Users size={14} /> 224 {authorCount} contributor{authorCount !== 1 ? "s" : ""} 225 </span> ··· 259 <div className="mb-6"> 260 <Tabs 261 tabs={[ 262 - { id: "all", label: `All (${totalItems})` }, 263 - { 264 - id: "annotations", 265 - label: `Annotations (${annotations.length})`, 266 - }, 267 - { 268 - id: "highlights", 269 - label: `Highlights (${highlights.length})`, 270 - }, 271 ]} 272 activeTab={activeTab} 273 onChange={(id: string) => ··· 296 <Card key={item.uri} item={item} /> 297 ))} 298 </div> 299 </div> 300 )} 301 </div>
··· 12 User, 13 Users, 14 } from "lucide-react"; 15 + import React, { useCallback, useEffect, useRef, useState } from "react"; 16 import { useNavigate, useParams } from "react-router-dom"; 17 import { getByTarget } from "../../api/client"; 18 import Card from "../../components/common/Card"; ··· 29 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 30 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 31 const [loading, setLoading] = useState(true); 32 + const [loadingMore, setLoadingMore] = useState(false); 33 + const [loadMoreError, setLoadMoreError] = useState<string | null>(null); 34 + const [hasMore, setHasMore] = useState(false); 35 + const [offset, setOffset] = useState(0); 36 const [error, setError] = useState<string | null>(null); 37 const [activeTab, setActiveTab] = useState< 38 "all" | "annotations" | "highlights" ··· 40 const [copied, setCopied] = useState(false); 41 const user = useStore($user); 42 43 + const LIMIT = 50; 44 + const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 45 + 46 + useEffect(() => { 47 + return () => { 48 + if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 49 + }; 50 + }, []); 51 + 52 useEffect(() => { 53 async function fetchData() { 54 if (!targetUrl) { ··· 60 setLoading(true); 61 setError(null); 62 63 + const data = await getByTarget(targetUrl, LIMIT, 0); 64 + const fetchedAnnotations = data.annotations || []; 65 + const fetchedHighlights = data.highlights || []; 66 + setAnnotations(fetchedAnnotations); 67 + setHighlights(fetchedHighlights); 68 + const totalFetched = 69 + fetchedAnnotations.length + fetchedHighlights.length; 70 + setHasMore(totalFetched >= LIMIT); 71 + setOffset(totalFetched); 72 } catch (err) { 73 setError(err instanceof Error ? err.message : "Failed to load data"); 74 } finally { ··· 78 fetchData(); 79 }, [targetUrl]); 80 81 + const loadMore = useCallback(async () => { 82 + setLoadingMore(true); 83 + setLoadMoreError(null); 84 + try { 85 + const data = await getByTarget(targetUrl, LIMIT, offset); 86 + const fetchedAnnotations = data.annotations || []; 87 + const fetchedHighlights = data.highlights || []; 88 + setAnnotations((prev) => [...prev, ...fetchedAnnotations]); 89 + setHighlights((prev) => [...prev, ...fetchedHighlights]); 90 + const totalFetched = fetchedAnnotations.length + fetchedHighlights.length; 91 + setHasMore(totalFetched >= LIMIT); 92 + setOffset((prev) => prev + totalFetched); 93 + } catch (err) { 94 + console.error("Failed to load more:", err); 95 + const msg = err instanceof Error ? err.message : "Something went wrong"; 96 + setLoadMoreError(msg); 97 + if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 98 + loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000); 99 + } finally { 100 + setLoadingMore(false); 101 + } 102 + }, [targetUrl, offset]); 103 + 104 const handleCopyLink = useCallback(async () => { 105 try { 106 await navigator.clipboard.writeText(window.location.href); ··· 252 {!loading && totalItems > 0 && ( 253 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700 flex items-center gap-4 text-sm text-surface-500 dark:text-surface-400"> 254 <span className="flex items-center gap-1.5"> 255 <Users size={14} /> 256 {authorCount} contributor{authorCount !== 1 ? "s" : ""} 257 </span> ··· 291 <div className="mb-6"> 292 <Tabs 293 tabs={[ 294 + { id: "all", label: "All" }, 295 + { id: "annotations", label: "Annotations" }, 296 + { id: "highlights", label: "Highlights" }, 297 ]} 298 activeTab={activeTab} 299 onChange={(id: string) => ··· 322 <Card key={item.uri} item={item} /> 323 ))} 324 </div> 325 + 326 + {hasMore && ( 327 + <div className="flex flex-col items-center gap-2 py-6"> 328 + {loadMoreError && ( 329 + <p className="text-sm text-red-500 dark:text-red-400"> 330 + Failed to load more: {loadMoreError} 331 + </p> 332 + )} 333 + <button 334 + onClick={loadMore} 335 + disabled={loadingMore} 336 + className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 337 + > 338 + {loadingMore ? ( 339 + <> 340 + <Loader2 size={16} className="animate-spin" /> 341 + Loading... 342 + </> 343 + ) : ( 344 + "Load more" 345 + )} 346 + </button> 347 + </div> 348 + )} 349 </div> 350 )} 351 </div>
+176 -169
web/src/views/content/UserUrlPage.tsx
··· 1 - import { clsx } from "clsx"; 2 import { 3 AlertTriangle, 4 ExternalLink, 5 Highlighter, 6 PenTool, 7 Search, 8 } from "lucide-react"; 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"; 14 15 export default function UserUrlPage() { ··· 22 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 23 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 24 const [loading, setLoading] = useState(true); 25 const [error, setError] = useState<string | null>(null); 26 const [activeTab, setActiveTab] = useState< 27 "all" | "annotations" | "highlights" 28 >("all"); 29 30 useEffect(() => { 31 async function fetchData() { ··· 50 } 51 52 const decodedUrl = decodeURIComponent(targetUrl); 53 54 - const data = await getUserTargetItems(did, decodedUrl); 55 - setAnnotations(data.annotations || []); 56 - setHighlights(data.highlights || []); 57 } catch (err) { 58 setError(err instanceof Error ? err.message : "Unknown error"); 59 } finally { ··· 63 fetchData(); 64 }, [handle, targetUrl]); 65 66 const displayName = profile?.displayName || profile?.handle || handle; 67 const displayHandle = 68 profile?.handle || (handle?.startsWith("did:") ? null : handle); 69 - const avatarUrl = getAvatarUrl(profile?.did, profile?.avatar); 70 - 71 - const getInitial = () => { 72 - return (displayName || displayHandle || "??") 73 - ?.substring(0, 2) 74 - .toUpperCase(); 75 - }; 76 77 const totalItems = annotations.length + highlights.length; 78 - const bskyProfileUrl = displayHandle 79 - ? `https://bsky.app/profile/${displayHandle}` 80 - : `https://bsky.app/profile/${handle}`; 81 82 - const renderResults = () => { 83 - if (activeTab === "annotations" && annotations.length === 0) { 84 - return ( 85 - <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 86 - <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 87 - <PenTool size={24} /> 88 - </div> 89 - <h3 className="text-lg font-medium text-surface-600"> 90 - No annotations 91 - </h3> 92 - </div> 93 - ); 94 - } 95 96 - if (activeTab === "highlights" && highlights.length === 0) { 97 - return ( 98 - <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 99 - <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 100 - <Highlighter size={24} /> 101 - </div> 102 - <h3 className="text-lg font-medium text-surface-600"> 103 - No highlights 104 - </h3> 105 - </div> 106 - ); 107 - } 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 - 124 - return ( 125 - <div className="space-y-6"> 126 - {items.map((item) => ( 127 - <Card key={item.uri} item={item} /> 128 - ))} 129 - </div> 130 - ); 131 - }; 132 133 if (!targetUrl) { 134 return ( 135 - <div className="max-w-2xl mx-auto py-20 text-center"> 136 - <div className="w-16 h-16 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 137 - <Search size={32} /> 138 - </div> 139 - <h3 className="text-xl font-bold text-surface-900 mb-2"> 140 - No URL specified 141 - </h3> 142 - <p className="text-surface-500"> 143 - Please provide a URL to view annotations. 144 - </p> 145 - </div> 146 ); 147 } 148 149 return ( 150 - <div className="max-w-3xl mx-auto pb-20"> 151 - <header className="flex items-center gap-6 mb-8 p-6 bg-white dark:bg-surface-800 rounded-2xl border border-surface-200 dark:border-surface-700 shadow-sm"> 152 - <a 153 - href={bskyProfileUrl} 154 - target="_blank" 155 - rel="noopener noreferrer" 156 - className="shrink-0 hover:opacity-80 transition-opacity" 157 - > 158 - {avatarUrl ? ( 159 - <img 160 - src={avatarUrl} 161 - alt={displayName} 162 - className="w-20 h-20 rounded-full object-cover border-4 border-surface-50 dark:border-surface-700" 163 /> 164 - ) : ( 165 - <div className="w-20 h-20 rounded-full bg-surface-100 dark:bg-surface-700 flex items-center justify-center text-2xl font-bold text-surface-500 dark:text-surface-400 border-4 border-surface-50 dark:border-surface-700"> 166 - {getInitial()} 167 - </div> 168 - )} 169 - </a> 170 - <div className="flex-1"> 171 - <h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-1"> 172 - {displayName} 173 - </h1> 174 - {displayHandle && ( 175 <a 176 - href={bskyProfileUrl} 177 target="_blank" 178 rel="noopener noreferrer" 179 - className="text-surface-500 dark:text-surface-400 hover:text-primary-600 transition-colors bg-surface-50 dark:bg-surface-700 hover:bg-primary-50 dark:hover:bg-primary-900/30 px-2 py-1 rounded-md text-sm inline-flex items-center gap-1" 180 > 181 - @{displayHandle} <ExternalLink size={12} /> 182 </a> 183 - )} 184 </div> 185 - </header> 186 - 187 - <div className="mb-8 p-4 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl flex flex-col sm:flex-row sm:items-center gap-4"> 188 - <span className="text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wide"> 189 - Annotations on: 190 - </span> 191 - <a 192 - href={decodeURIComponent(targetUrl)} 193 - target="_blank" 194 - rel="noopener noreferrer" 195 - className="text-primary-600 hover:text-primary-700 hover:underline font-medium truncate flex-1 block" 196 - > 197 - {decodeURIComponent(targetUrl)} 198 - </a> 199 </div> 200 201 {loading && ( 202 - <div className="flex justify-center py-12"> 203 - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> 204 </div> 205 )} 206 207 {error && ( 208 - <div className="mb-8 bg-red-50 text-red-600 p-4 rounded-xl flex items-start gap-3 border border-red-100"> 209 <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 210 <p>{error}</p> 211 </div> 212 )} 213 214 {!loading && !error && totalItems === 0 && ( 215 - <div className="text-center py-16 bg-surface-50 dark:bg-surface-800 rounded-2xl border border-dashed border-surface-200 dark:border-surface-700"> 216 - <div className="w-12 h-12 bg-surface-100 dark:bg-surface-700 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 217 - <PenTool size={24} /> 218 - </div> 219 - <h3 className="text-lg font-bold text-surface-900 dark:text-white mb-1"> 220 - No items found 221 - </h3> 222 - <p className="text-surface-500 dark:text-surface-400"> 223 - {displayName} hasn&apos;t annotated this page yet. 224 - </p> 225 - </div> 226 )} 227 228 {!loading && !error && totalItems > 0 && ( 229 - <div className="animate-fade-in"> 230 - <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6"> 231 - <h2 className="text-xl font-bold text-surface-900 dark:text-white"> 232 - {totalItems} item{totalItems !== 1 ? "s" : ""} 233 - </h2> 234 - <div className="flex bg-surface-100 dark:bg-surface-800 p-1 rounded-xl self-start md:self-auto"> 235 - <button 236 - className={clsx( 237 - "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 238 - activeTab === "all" 239 - ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 240 - : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200", 241 - )} 242 - onClick={() => setActiveTab("all")} 243 - > 244 - All ({totalItems}) 245 - </button> 246 <button 247 - className={clsx( 248 - "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 249 - activeTab === "annotations" 250 - ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 251 - : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200", 252 - )} 253 - onClick={() => setActiveTab("annotations")} 254 > 255 - Annotations ({annotations.length}) 256 - </button> 257 - <button 258 - className={clsx( 259 - "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 260 - activeTab === "highlights" 261 - ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 262 - : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200", 263 )} 264 - onClick={() => setActiveTab("highlights")} 265 - > 266 - Highlights ({highlights.length}) 267 </button> 268 </div> 269 - </div> 270 - <div className="space-y-6">{renderResults()}</div> 271 </div> 272 )} 273 </div>
··· 1 import { 2 AlertTriangle, 3 ExternalLink, 4 Highlighter, 5 + Loader2, 6 PenTool, 7 Search, 8 } from "lucide-react"; 9 + import React, { useCallback, useEffect, useState } from "react"; 10 import { useParams } from "react-router-dom"; 11 + import { getUserTargetItems } from "../../api/client"; 12 import Card from "../../components/common/Card"; 13 + import Avatar from "../../components/ui/Avatar"; 14 + import { EmptyState, Tabs } from "../../components/ui"; 15 import type { AnnotationItem, UserProfile } from "../../types"; 16 17 export default function UserUrlPage() { ··· 24 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 25 const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 26 const [loading, setLoading] = useState(true); 27 + const [loadingMore, setLoadingMore] = useState(false); 28 + const [loadMoreError, setLoadMoreError] = useState<string | null>(null); 29 + const [hasMore, setHasMore] = useState(false); 30 + const [offset, setOffset] = useState(0); 31 const [error, setError] = useState<string | null>(null); 32 const [activeTab, setActiveTab] = useState< 33 "all" | "annotations" | "highlights" 34 >("all"); 35 + 36 + const LIMIT = 50; 37 + const [resolvedDid, setResolvedDid] = useState<string | null>(null); 38 39 useEffect(() => { 40 async function fetchData() { ··· 59 } 60 61 const decodedUrl = decodeURIComponent(targetUrl); 62 + setResolvedDid(did); 63 64 + const data = await getUserTargetItems(did, decodedUrl, LIMIT, 0); 65 + const fetchedAnnotations = data.annotations || []; 66 + const fetchedHighlights = data.highlights || []; 67 + setAnnotations(fetchedAnnotations); 68 + setHighlights(fetchedHighlights); 69 + const totalFetched = 70 + fetchedAnnotations.length + fetchedHighlights.length; 71 + setHasMore(totalFetched >= LIMIT); 72 + setOffset(totalFetched); 73 } catch (err) { 74 setError(err instanceof Error ? err.message : "Unknown error"); 75 } finally { ··· 79 fetchData(); 80 }, [handle, targetUrl]); 81 82 + const loadMore = useCallback(async () => { 83 + if (!resolvedDid) return; 84 + setLoadingMore(true); 85 + setLoadMoreError(null); 86 + try { 87 + const decodedUrl = decodeURIComponent(targetUrl); 88 + const data = await getUserTargetItems( 89 + resolvedDid, 90 + decodedUrl, 91 + LIMIT, 92 + offset, 93 + ); 94 + const fetchedAnnotations = data.annotations || []; 95 + const fetchedHighlights = data.highlights || []; 96 + setAnnotations((prev) => [...prev, ...fetchedAnnotations]); 97 + setHighlights((prev) => [...prev, ...fetchedHighlights]); 98 + const totalFetched = fetchedAnnotations.length + fetchedHighlights.length; 99 + setHasMore(totalFetched >= LIMIT); 100 + setOffset((prev) => prev + totalFetched); 101 + } catch (err) { 102 + console.error("Failed to load more:", err); 103 + const msg = err instanceof Error ? err.message : "Something went wrong"; 104 + setLoadMoreError(msg); 105 + setTimeout(() => setLoadMoreError(null), 5000); 106 + } finally { 107 + setLoadingMore(false); 108 + } 109 + }, [resolvedDid, targetUrl, offset]); 110 + 111 const displayName = profile?.displayName || profile?.handle || handle; 112 const displayHandle = 113 profile?.handle || (handle?.startsWith("did:") ? null : handle); 114 115 const totalItems = annotations.length + highlights.length; 116 + const decodedTargetUrl = decodeURIComponent(targetUrl); 117 118 + const items = [ 119 + ...(activeTab === "all" || activeTab === "annotations" ? annotations : []), 120 + ...(activeTab === "all" || activeTab === "highlights" ? highlights : []), 121 + ]; 122 123 + if (activeTab === "all") { 124 + items.sort((a, b) => { 125 + const dateA = new Date(a.createdAt).getTime(); 126 + const dateB = new Date(b.createdAt).getTime(); 127 + return dateB - dateA; 128 + }); 129 + } 130 131 if (!targetUrl) { 132 return ( 133 + <EmptyState 134 + icon={<Search size={48} />} 135 + title="No URL specified" 136 + message="Please provide a URL to view annotations." 137 + /> 138 ); 139 } 140 141 return ( 142 + <div className="max-w-2xl mx-auto pb-20 animate-fade-in"> 143 + <div className="card p-5 mb-4"> 144 + <div className="flex items-start gap-4"> 145 + <a 146 + href={`/profile/${displayHandle || handle}`} 147 + className="shrink-0 hover:opacity-80 transition-opacity" 148 + > 149 + <Avatar 150 + did={profile?.did} 151 + avatar={profile?.avatar} 152 + size="lg" 153 + className="ring-4 ring-surface-100 dark:ring-surface-800" 154 /> 155 + </a> 156 + <div className="flex-1 min-w-0"> 157 + <a 158 + href={`/profile/${displayHandle || handle}`} 159 + className="hover:underline" 160 + > 161 + <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate"> 162 + {displayName} 163 + </h1> 164 + </a> 165 + {displayHandle && ( 166 + <p className="text-surface-500 dark:text-surface-400"> 167 + @{displayHandle} 168 + </p> 169 + )} 170 + </div> 171 + </div> 172 + 173 + <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700"> 174 + <div className="flex items-center gap-2 text-sm"> 175 + <span className="text-surface-400 dark:text-surface-500 font-medium shrink-0"> 176 + on 177 + </span> 178 <a 179 + href={decodedTargetUrl} 180 target="_blank" 181 rel="noopener noreferrer" 182 + className="text-primary-600 dark:text-primary-400 hover:underline truncate flex items-center gap-1" 183 > 184 + <span className="truncate">{decodedTargetUrl}</span> 185 + <ExternalLink size={12} className="shrink-0" /> 186 </a> 187 + </div> 188 </div> 189 </div> 190 191 {loading && ( 192 + <div className="flex flex-col items-center justify-center py-20"> 193 + <Loader2 194 + className="animate-spin text-primary-600 dark:text-primary-400 mb-4" 195 + size={32} 196 + /> 197 + <p className="text-surface-500 dark:text-surface-400"> 198 + Loading annotations... 199 + </p> 200 </div> 201 )} 202 203 {error && ( 204 + <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"> 205 <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 206 <p>{error}</p> 207 </div> 208 )} 209 210 {!loading && !error && totalItems === 0 && ( 211 + <EmptyState 212 + icon={<PenTool size={32} />} 213 + title="No items found" 214 + message={`${displayName} hasn't annotated this page yet.`} 215 + /> 216 )} 217 218 {!loading && !error && totalItems > 0 && ( 219 + <div> 220 + <div className="mb-6"> 221 + <Tabs 222 + tabs={[ 223 + { id: "all", label: "All" }, 224 + { id: "annotations", label: "Annotations" }, 225 + { id: "highlights", label: "Highlights" }, 226 + ]} 227 + activeTab={activeTab} 228 + onChange={(id: string) => 229 + setActiveTab(id as "all" | "annotations" | "highlights") 230 + } 231 + /> 232 + </div> 233 + 234 + <div className="space-y-4"> 235 + {activeTab === "annotations" && annotations.length === 0 && ( 236 + <EmptyState 237 + icon={<PenTool size={32} />} 238 + title="No annotations" 239 + message={`${displayName} hasn't annotated this page yet.`} 240 + /> 241 + )} 242 + {activeTab === "highlights" && highlights.length === 0 && ( 243 + <EmptyState 244 + icon={<Highlighter size={32} />} 245 + title="No highlights" 246 + message={`${displayName} hasn't highlighted this page yet.`} 247 + /> 248 + )} 249 + 250 + {items.map((item) => ( 251 + <Card key={item.uri} item={item} /> 252 + ))} 253 + </div> 254 + 255 + {hasMore && ( 256 + <div className="flex flex-col items-center gap-2 py-6"> 257 + {loadMoreError && ( 258 + <p className="text-sm text-red-500 dark:text-red-400"> 259 + Failed to load more: {loadMoreError} 260 + </p> 261 + )} 262 <button 263 + onClick={loadMore} 264 + disabled={loadingMore} 265 + className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 266 > 267 + {loadingMore ? ( 268 + <> 269 + <Loader2 size={16} className="animate-spin" /> 270 + Loading... 271 + </> 272 + ) : ( 273 + "Load more" 274 )} 275 </button> 276 </div> 277 + )} 278 </div> 279 )} 280 </div>
+1 -1
web/src/views/core/Settings.tsx
··· 211 </p> 212 </div> 213 <Switch 214 - checked={$preferences.get().disableExternalLinkWarning} 215 onCheckedChange={setDisableExternalLinkWarning} 216 /> 217 </div>
··· 211 </p> 212 </div> 213 <Switch 214 + checked={preferences.disableExternalLinkWarning} 215 onCheckedChange={setDisableExternalLinkWarning} 216 /> 217 </div>
+138 -9
web/src/views/profile/Profile.tsx
··· 1 - import React, { useEffect, useState } from "react"; 2 import { 3 getProfile, 4 getFeed, ··· 63 64 type Tab = "all" | "annotations" | "highlights" | "bookmarks" | "collections"; 65 66 export default function Profile({ did }: ProfileProps) { 67 const [profile, setProfile] = useState<UserProfile | null>(null); 68 const [loading, setLoading] = useState(true); ··· 80 const [showEdit, setShowEdit] = useState(false); 81 const [externalLink, setExternalLink] = useState<string | null>(null); 82 const [showReportModal, setShowReportModal] = useState(false); 83 const [modRelation, setModRelation] = useState<ModerationRelationship>({ 84 blocking: false, 85 muting: false, ··· 182 }, []); 183 184 useEffect(() => { 185 const loadTabContent = async () => { 186 const isHandle = !did.startsWith("did:"); 187 const resolvedDid = isHandle ? profile?.did : did; ··· 189 if (!resolvedDid) return; 190 191 setDataLoading(true); 192 try { 193 if (activeTab === "all") { 194 const res = await getFeed({ 195 creator: resolvedDid, 196 - limit: 50, 197 }); 198 - setAll(res.items || []); 199 } else if (activeTab === "annotations") { 200 const res = await getFeed({ 201 creator: resolvedDid, 202 motivation: "commenting", 203 - limit: 50, 204 }); 205 - setAnnotations(res.items || []); 206 } else if (activeTab === "highlights") { 207 const res = await getFeed({ 208 creator: resolvedDid, 209 motivation: "highlighting", 210 - limit: 50, 211 }); 212 - setHighlights(res.items || []); 213 } else if (activeTab === "bookmarks") { 214 const res = await getFeed({ 215 creator: resolvedDid, 216 motivation: "bookmarking", 217 - limit: 50, 218 }); 219 - setBookmarks(res.items || []); 220 } else if (activeTab === "collections") { 221 const res = await getCollections(resolvedDid); 222 setCollections(res); ··· 229 }; 230 loadTabContent(); 231 }, [profile?.did, did, activeTab]); 232 233 if (loading) { 234 return ( ··· 674 : `No ${activeTab}` 675 } 676 /> 677 )} 678 </div> 679
··· 1 + import React, { useCallback, useEffect, useRef, useState } from "react"; 2 import { 3 getProfile, 4 getFeed, ··· 63 64 type Tab = "all" | "annotations" | "highlights" | "bookmarks" | "collections"; 65 66 + const LIMIT = 50; 67 + 68 export default function Profile({ did }: ProfileProps) { 69 const [profile, setProfile] = useState<UserProfile | null>(null); 70 const [loading, setLoading] = useState(true); ··· 82 const [showEdit, setShowEdit] = useState(false); 83 const [externalLink, setExternalLink] = useState<string | null>(null); 84 const [showReportModal, setShowReportModal] = useState(false); 85 + const [loadingMore, setLoadingMore] = useState(false); 86 + const [loadMoreError, setLoadMoreError] = useState<string | null>(null); 87 + const [pagination, setPagination] = useState< 88 + Record<string, { hasMore: boolean; offset: number }> 89 + >({ 90 + all: { hasMore: false, offset: 0 }, 91 + annotations: { hasMore: false, offset: 0 }, 92 + highlights: { hasMore: false, offset: 0 }, 93 + bookmarks: { hasMore: false, offset: 0 }, 94 + }); 95 + const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 96 const [modRelation, setModRelation] = useState<ModerationRelationship>({ 97 blocking: false, 98 muting: false, ··· 195 }, []); 196 197 useEffect(() => { 198 + return () => { 199 + if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 200 + }; 201 + }, []); 202 + 203 + useEffect(() => { 204 const loadTabContent = async () => { 205 const isHandle = !did.startsWith("did:"); 206 const resolvedDid = isHandle ? profile?.did : did; ··· 208 if (!resolvedDid) return; 209 210 setDataLoading(true); 211 + setPagination((prev) => ({ 212 + ...prev, 213 + [activeTab]: { hasMore: false, offset: 0 }, 214 + })); 215 try { 216 if (activeTab === "all") { 217 const res = await getFeed({ 218 creator: resolvedDid, 219 + limit: LIMIT, 220 }); 221 + const items = res.items || []; 222 + setAll(items); 223 + setPagination((prev) => ({ 224 + ...prev, 225 + all: { 226 + hasMore: res.hasMore ?? items.length >= LIMIT, 227 + offset: res.fetchedCount ?? items.length, 228 + }, 229 + })); 230 } else if (activeTab === "annotations") { 231 const res = await getFeed({ 232 creator: resolvedDid, 233 motivation: "commenting", 234 + limit: LIMIT, 235 }); 236 + const items = res.items || []; 237 + setAnnotations(items); 238 + setPagination((prev) => ({ 239 + ...prev, 240 + annotations: { 241 + hasMore: res.hasMore ?? items.length >= LIMIT, 242 + offset: res.fetchedCount ?? items.length, 243 + }, 244 + })); 245 } else if (activeTab === "highlights") { 246 const res = await getFeed({ 247 creator: resolvedDid, 248 motivation: "highlighting", 249 + limit: LIMIT, 250 }); 251 + const items = res.items || []; 252 + setHighlights(items); 253 + setPagination((prev) => ({ 254 + ...prev, 255 + highlights: { 256 + hasMore: res.hasMore ?? items.length >= LIMIT, 257 + offset: res.fetchedCount ?? items.length, 258 + }, 259 + })); 260 } else if (activeTab === "bookmarks") { 261 const res = await getFeed({ 262 creator: resolvedDid, 263 motivation: "bookmarking", 264 + limit: LIMIT, 265 }); 266 + const items = res.items || []; 267 + setBookmarks(items); 268 + setPagination((prev) => ({ 269 + ...prev, 270 + bookmarks: { 271 + hasMore: res.hasMore ?? items.length >= LIMIT, 272 + offset: res.fetchedCount ?? items.length, 273 + }, 274 + })); 275 } else if (activeTab === "collections") { 276 const res = await getCollections(resolvedDid); 277 setCollections(res); ··· 284 }; 285 loadTabContent(); 286 }, [profile?.did, did, activeTab]); 287 + 288 + const loadMore = useCallback(async () => { 289 + const isHandle = !did.startsWith("did:"); 290 + const resolvedDid = isHandle ? profile?.did : did; 291 + if (!resolvedDid) return; 292 + 293 + const tabPagination = pagination[activeTab]; 294 + if (!tabPagination) return; 295 + 296 + const capturedTab = activeTab; 297 + setLoadingMore(true); 298 + setLoadMoreError(null); 299 + try { 300 + const motivationMap: Record<string, string | undefined> = { 301 + all: undefined, 302 + annotations: "commenting", 303 + highlights: "highlighting", 304 + bookmarks: "bookmarking", 305 + }; 306 + const res = await getFeed({ 307 + creator: resolvedDid, 308 + motivation: motivationMap[capturedTab], 309 + limit: LIMIT, 310 + offset: tabPagination.offset, 311 + }); 312 + const fetched = res.items || []; 313 + const setters: Record< 314 + string, 315 + React.Dispatch<React.SetStateAction<AnnotationItem[]>> 316 + > = { 317 + annotations: setAnnotations, 318 + highlights: setHighlights, 319 + bookmarks: setBookmarks, 320 + }; 321 + const setter = setters[capturedTab] ?? setAll; 322 + setter((prev) => [...prev, ...fetched]); 323 + setPagination((prev) => ({ 324 + ...prev, 325 + [capturedTab]: { 326 + hasMore: res.hasMore ?? fetched.length >= LIMIT, 327 + offset: 328 + prev[capturedTab].offset + (res.fetchedCount ?? fetched.length), 329 + }, 330 + })); 331 + } catch (e) { 332 + console.error(e); 333 + const msg = e instanceof Error ? e.message : "Something went wrong"; 334 + setLoadMoreError(msg); 335 + if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); 336 + loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000); 337 + } finally { 338 + setLoadingMore(false); 339 + } 340 + }, [did, profile?.did, activeTab, pagination]); 341 342 if (loading) { 343 return ( ··· 783 : `No ${activeTab}` 784 } 785 /> 786 + )} 787 + 788 + {activeTab !== "collections" && pagination[activeTab]?.hasMore && ( 789 + <div className="flex flex-col items-center gap-2 py-6"> 790 + {loadMoreError && ( 791 + <p className="text-sm text-red-500 dark:text-red-400"> 792 + Failed to load more: {loadMoreError} 793 + </p> 794 + )} 795 + <Button 796 + variant="secondary" 797 + size="sm" 798 + onClick={loadMore} 799 + loading={loadingMore} 800 + disabled={loadingMore} 801 + className="rounded-xl" 802 + > 803 + Load more 804 + </Button> 805 + </div> 806 )} 807 </div> 808