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

bug fixes + better ui/ux

+245 -58
+45 -2
web/src/api/client.ts
··· 139 139 name: string; 140 140 icon?: string; 141 141 }; 142 + context?: { 143 + uri: string; 144 + name: string; 145 + icon?: string; 146 + }[]; 142 147 created?: string; 143 148 createdAt?: string; 144 149 target?: string | { source?: string; title?: string; selector?: Selector }; ··· 171 176 icon: raw.collection.icon, 172 177 } 173 178 : undefined, 179 + context: raw.context 180 + ? raw.context.map((c: any) => ({ 181 + uri: c.uri, 182 + name: c.name, 183 + icon: c.icon, 184 + })) 185 + : undefined, 174 186 addedBy: raw.creator || raw.author, 175 - createdAt: raw.created || raw.createdAt || new Date().toISOString(), 187 + createdAt: 188 + normalizedInner.createdAt || 189 + raw.created || 190 + raw.createdAt || 191 + new Date().toISOString(), 176 192 collectionItemUri: raw.id || raw.uri, 177 193 }; 178 194 } ··· 248 264 }); 249 265 if (!res.ok) throw new Error("Failed to fetch feed"); 250 266 const data = await res.json(); 267 + const normalizedItems = (data.items || []).map(normalizeItem); 268 + 269 + const groupedItems: AnnotationItem[] = []; 270 + if (normalizedItems.length > 0) { 271 + groupedItems.push(normalizedItems[0]); 272 + 273 + for (let i = 1; i < normalizedItems.length; i++) { 274 + const prev = groupedItems[groupedItems.length - 1]; 275 + const curr = normalizedItems[i]; 276 + 277 + if (prev.collection && curr.collection) { 278 + if ( 279 + prev.uri === curr.uri && 280 + prev.addedBy?.did === curr.addedBy?.did 281 + ) { 282 + if (!prev.context) { 283 + prev.context = [prev.collection]; 284 + } 285 + prev.context.push(curr.collection); 286 + groupedItems[groupedItems.length - 1] = prev; 287 + continue; 288 + } 289 + } 290 + groupedItems.push(curr); 291 + } 292 + } 293 + 251 294 return { 252 295 cursor: data.cursor, 253 - items: (data.items || []).map(normalizeItem), 296 + items: groupedItems, 254 297 }; 255 298 } catch (e) { 256 299 console.error(e);
+57 -30
web/src/components/common/Card.tsx
··· 274 274 275 275 return ( 276 276 <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative"> 277 - {item.collection && ( 278 - <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2"> 277 + {(item.collection || (item.context && item.context.length > 0)) && ( 278 + <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> 279 279 {item.addedBy && item.addedBy.did !== item.author?.did ? ( 280 280 <> 281 281 <ProfileHoverCard did={item.addedBy.did}> ··· 298 298 ) : ( 299 299 <span>Added to</span> 300 300 )} 301 - <Link 302 - to={`/${item.addedBy?.handle || ""}/collection/${(item.collection.uri || "").split("/").pop()}`} 303 - className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 304 - > 305 - <CollectionIcon icon={item.collection.icon} size={14} /> 306 - <span className="font-medium">{item.collection.name}</span> 307 - </Link> 301 + 302 + {item.context && item.context.length > 0 ? ( 303 + item.context.map((col, index) => ( 304 + <React.Fragment key={col.uri}> 305 + {index > 0 && index < item.context!.length - 1 && ( 306 + <span className="text-surface-300 dark:text-surface-600"> 307 + , 308 + </span> 309 + )} 310 + {index > 0 && index === item.context!.length - 1 && ( 311 + <span>and</span> 312 + )} 313 + <Link 314 + to={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} 315 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 316 + > 317 + <CollectionIcon icon={col.icon} size={14} /> 318 + <span className="font-medium">{col.name}</span> 319 + </Link> 320 + </React.Fragment> 321 + )) 322 + ) : ( 323 + <Link 324 + to={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`} 325 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 326 + > 327 + <CollectionIcon icon={item.collection!.icon} size={14} /> 328 + <span className="font-medium">{item.collection!.name}</span> 329 + </Link> 330 + )} 308 331 </div> 309 332 )} 310 333 ··· 426 449 </button> 427 450 )} 428 451 {isBookmark && ( 429 - <a 430 - href={pageUrl || "#"} 431 - target={pageUrl ? "_blank" : undefined} 432 - rel="noopener noreferrer" 433 - onClick={(e) => pageUrl && handleExternalClick(e, pageUrl)} 434 - className="block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden" 452 + <div 453 + onClick={(e) => { 454 + e.preventDefault(); 455 + if (pageUrl) handleExternalClick(e, pageUrl); 456 + }} 457 + role="button" 458 + tabIndex={0} 459 + className="flex items-stretch bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden cursor-pointer" 435 460 > 436 461 {displayImage && !imgError && ( 437 - <div className="h-32 w-full overflow-hidden bg-surface-200 dark:bg-surface-700 border-b border-surface-200 dark:border-surface-700"> 438 - <img 439 - src={displayImage} 440 - alt="" 441 - onError={() => setImgError(true)} 442 - className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" 443 - /> 462 + <div className="w-[140px] sm:w-[180px] shrink-0 border-r border-surface-200 dark:border-surface-700 bg-surface-200 dark:bg-surface-700 relative"> 463 + <div className="absolute inset-0 flex items-center justify-center overflow-hidden"> 464 + <img 465 + src={displayImage} 466 + alt="" 467 + onError={() => setImgError(true)} 468 + className="w-full h-full object-cover" 469 + /> 470 + </div> 444 471 </div> 445 472 )} 446 - <div className="p-4"> 447 - <h3 className="font-semibold text-surface-900 dark:text-white text-base leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 transition-colors"> 473 + <div className="p-3 flex-1 min-w-0 flex flex-col justify-center font-sans"> 474 + <h3 className="font-semibold text-surface-900 dark:text-white text-sm leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-1.5 transition-colors line-clamp-2"> 448 475 {displayTitle} 449 476 </h3> 450 477 451 478 {displayDescription && ( 452 - <p className="text-surface-600 dark:text-surface-400 text-sm leading-relaxed mb-3 line-clamp-2"> 479 + <p className="text-surface-600 dark:text-surface-400 text-xs leading-relaxed mb-2 line-clamp-2"> 453 480 {displayDescription} 454 481 </p> 455 482 )} 456 483 457 - <div className="flex items-center gap-2 text-xs text-surface-500 dark:text-surface-500"> 458 - <div className="w-5 h-5 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> 484 + <div className="flex items-center gap-2 text-[11px] text-surface-500 dark:text-surface-500 mt-auto"> 485 + <div className="w-4 h-4 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> 459 486 {ogData?.icon && !iconError ? ( 460 487 <img 461 488 src={ogData.icon} 462 489 alt="" 463 490 onError={() => setIconError(true)} 464 - className="w-3.5 h-3.5 object-contain" 491 + className="w-3 h-3 object-contain" 465 492 /> 466 493 ) : ( 467 - <Globe size={10} /> 494 + <Globe size={9} /> 468 495 )} 469 496 </div> 470 497 <span className="truncate max-w-[200px]"> ··· 472 499 </span> 473 500 </div> 474 501 </div> 475 - </a> 502 + </div> 476 503 )} 477 504 478 505 {item.target?.selector?.exact && (
+137 -25
web/src/components/common/RichText.tsx
··· 1 1 import React from "react"; 2 2 import { Link } from "react-router-dom"; 3 + import ExternalLinkModal from "../modals/ExternalLinkModal"; 4 + import { useStore } from "@nanostores/react"; 5 + import { $preferences } from "../../store/preferences"; 3 6 4 7 interface RichTextProps { 5 8 text: string; ··· 9 12 const MENTION_REGEX = 10 13 /(^|[\s(])@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)/g; 11 14 15 + const URL_REGEX = /(^|[\s(])(https?:\/\/[^\s]+)/g; 16 + 12 17 export default function RichText({ text, className }: RichTextProps) { 13 - const parts: React.ReactNode[] = []; 14 - let lastIndex = 0; 18 + const urlParts: { text: string; isUrl: boolean }[] = []; 19 + let lastUrlIndex = 0; 15 20 16 - for (const match of text.matchAll(MENTION_REGEX)) { 21 + for (const match of text.matchAll(URL_REGEX)) { 17 22 const fullMatch = match[0]; 18 23 const prefix = match[1]; 19 - const handle = match[2]; 24 + const url = match[2]; 20 25 const startIndex = match.index!; 21 26 22 - if (startIndex > lastIndex) { 23 - parts.push(text.slice(lastIndex, startIndex)); 27 + if (startIndex > lastUrlIndex) { 28 + urlParts.push({ 29 + text: text.slice(lastUrlIndex, startIndex), 30 + isUrl: false, 31 + }); 24 32 } 25 - 26 33 if (prefix) { 27 - parts.push(prefix); 34 + urlParts.push({ text: prefix, isUrl: false }); 28 35 } 29 36 30 - parts.push( 31 - <Link 32 - key={startIndex} 33 - to={`/profile/${handle}`} 34 - className="text-primary-600 dark:text-primary-400 hover:underline" 35 - onClick={(e) => e.stopPropagation()} 36 - > 37 - @{handle} 38 - </Link>, 39 - ); 37 + urlParts.push({ text: url, isUrl: true }); 40 38 41 - lastIndex = startIndex + fullMatch.length; 39 + lastUrlIndex = startIndex + fullMatch.length; 40 + } 41 + if (lastUrlIndex < text.length) { 42 + urlParts.push({ text: text.slice(lastUrlIndex), isUrl: false }); 42 43 } 43 44 44 - if (lastIndex < text.length) { 45 - parts.push(text.slice(lastIndex)); 45 + if (urlParts.length === 0) { 46 + urlParts.push({ text, isUrl: false }); 46 47 } 47 48 48 - if (parts.length === 0) { 49 - return <span className={className}>{text}</span>; 50 - } 49 + const [showExternalLinkModal, setShowExternalLinkModal] = 50 + React.useState(false); 51 + const [externalLinkUrl, setExternalLinkUrl] = React.useState<string | null>( 52 + null, 53 + ); 54 + const preferences = useStore($preferences); 51 55 52 - return <span className={className}>{parts}</span>; 56 + const safeUrlHostname = (url: string | null | undefined) => { 57 + if (!url) return null; 58 + try { 59 + return new URL(url).hostname; 60 + } catch { 61 + return null; 62 + } 63 + }; 64 + 65 + const handleExternalClick = (e: React.MouseEvent, url: string) => { 66 + e.preventDefault(); 67 + e.stopPropagation(); 68 + 69 + try { 70 + const hostname = safeUrlHostname(url); 71 + if (hostname) { 72 + if ( 73 + hostname === "margin.at" || 74 + hostname.endsWith(".margin.at") || 75 + hostname === "semble.so" || 76 + hostname.endsWith(".semble.so") 77 + ) { 78 + window.open(url, "_blank", "noopener,noreferrer"); 79 + return; 80 + } 81 + const skipped = preferences.externalLinkSkippedHostnames || []; 82 + if (skipped.includes(hostname)) { 83 + window.open(url, "_blank", "noopener,noreferrer"); 84 + return; 85 + } 86 + } 87 + } catch (err) { 88 + if (err instanceof Error && err.name !== "TypeError") { 89 + console.debug("Failed to check skipped hostname:", err); 90 + } 91 + } 92 + 93 + setExternalLinkUrl(url); 94 + setShowExternalLinkModal(true); 95 + }; 96 + 97 + const finalParts: React.ReactNode[] = []; 98 + 99 + urlParts.forEach((part, partIndex) => { 100 + if (part.isUrl) { 101 + finalParts.push( 102 + <a 103 + key={`url-${partIndex}`} 104 + href={part.text} 105 + target="_blank" 106 + rel="noopener noreferrer" 107 + className="text-primary-600 dark:text-primary-400 hover:underline break-all cursor-pointer" 108 + onClick={(e) => handleExternalClick(e, part.text)} 109 + > 110 + {part.text} 111 + </a>, 112 + ); 113 + } else { 114 + let lastMentionIndex = 0; 115 + const mentionMatches = Array.from(part.text.matchAll(MENTION_REGEX)); 116 + 117 + if (mentionMatches.length === 0) { 118 + finalParts.push(part.text); 119 + } else { 120 + for (const match of mentionMatches) { 121 + const fullMatch = match[0]; 122 + const prefix = match[1]; 123 + const handle = match[2]; 124 + const startIndex = match.index!; 125 + 126 + if (startIndex > lastMentionIndex) { 127 + finalParts.push(part.text.slice(lastMentionIndex, startIndex)); 128 + } 129 + 130 + if (prefix) { 131 + finalParts.push(prefix); 132 + } 133 + 134 + finalParts.push( 135 + <Link 136 + key={`mention-${partIndex}-${startIndex}`} 137 + to={`/profile/${handle}`} 138 + className="text-primary-600 dark:text-primary-400 hover:underline" 139 + onClick={(e) => e.stopPropagation()} 140 + > 141 + @{handle} 142 + </Link>, 143 + ); 144 + 145 + lastMentionIndex = startIndex + fullMatch.length; 146 + } 147 + 148 + if (lastMentionIndex < part.text.length) { 149 + finalParts.push(part.text.slice(lastMentionIndex)); 150 + } 151 + } 152 + } 153 + }); 154 + 155 + return ( 156 + <> 157 + <span className={className}>{finalParts}</span> 158 + <ExternalLinkModal 159 + isOpen={showExternalLinkModal} 160 + onClose={() => setShowExternalLinkModal(false)} 161 + url={externalLinkUrl} 162 + /> 163 + </> 164 + ); 53 165 }
+1 -1
web/src/components/navigation/RightSidebar.tsx
··· 84 84 {tags.map((t) => ( 85 85 <a 86 86 key={t.tag} 87 - href={`/search?q=${t.tag}`} 87 + href={`/home?tag=${encodeURIComponent(t.tag)}`} 88 88 className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800/60 rounded-lg transition-colors group" 89 89 > 90 90 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
+5
web/src/types.ts
··· 68 68 name: string; 69 69 icon?: string; 70 70 }; 71 + context?: { 72 + uri: string; 73 + name: string; 74 + icon?: string; 75 + }[]; 71 76 addedBy?: UserProfile; 72 77 collectionItemUri?: string; 73 78 reply?: {