an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

image lightbox

rimar1337 268124d6 c64e32b7

Changed files
+148 -7
src
+1 -1
src/components/PostError.tsx
··· 1 - import { ErrorComponent, ErrorComponentProps } from "@tanstack/react-router"; 1 + import { ErrorComponent, type ErrorComponentProps } from "@tanstack/react-router"; 2 2 3 3 export function PostErrorComponent({ error }: ErrorComponentProps) { 4 4 return <ErrorComponent error={error} />;
+144 -3
src/components/UniversalPostRenderer.tsx
··· 1167 1167 // const agent = new AtpAgent({ 1168 1168 // service: 'https://public.api.bsky.app' 1169 1169 // }) 1170 + type HitSlopButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { 1171 + hitSlop?: number; 1172 + }; 1173 + 1174 + const HitSlopButtonCustom: React.FC<HitSlopButtonProps> = ({ 1175 + children, 1176 + hitSlop = 8, 1177 + style, 1178 + ...rest 1179 + }) => ( 1180 + <button 1181 + {...rest} 1182 + style={{ 1183 + position: "relative", 1184 + background: "none", 1185 + border: "none", 1186 + padding: 0, 1187 + cursor: "pointer", 1188 + ...style, 1189 + }} 1190 + > 1191 + {/* Invisible hit slop area */} 1192 + <span 1193 + style={{ 1194 + position: "absolute", 1195 + top: -hitSlop, 1196 + left: -hitSlop, 1197 + right: -hitSlop, 1198 + bottom: -hitSlop, 1199 + }} 1200 + /> 1201 + {/* Actual button content stays positioned normally */} 1202 + <span style={{ position: "relative", zIndex: 1 }}>{children}</span> 1203 + </button> 1204 + ); 1170 1205 1171 1206 const HitSlopButton = ({ 1172 1207 onClick, 1173 1208 children, 1174 1209 style = {}, 1175 - }: { 1210 + ...rest 1211 + }: React.HTMLAttributes<HTMLSpanElement> & { 1176 1212 onClick?: (e: React.MouseEvent) => void; 1177 1213 children: React.ReactNode; 1178 1214 style?: React.CSSProperties; ··· 1201 1237 zIndex: 1, 1202 1238 pointerEvents: "none", 1203 1239 }} 1240 + {...rest} 1204 1241 > 1205 1242 {children} 1206 1243 </span> ··· 1350 1387 //boxShadow: "0 2px 8px rgba(0,0,0,0.04)", 1351 1388 position: "relative", 1352 1389 // dont cursor: "pointer", 1353 - borderBottomWidth: bottomBorder ? 1 : 0, 1390 + borderBottomWidth: bottomBorder ? isQuote ? 0 : 1 : 0, 1354 1391 }} 1355 1392 className="border-gray-300 dark:border-gray-600" 1356 1393 > ··· 1577 1614 <div 1578 1615 style={{ 1579 1616 fontSize: 16, 1580 - marginBottom: 8, 1617 + marginBottom: (!post.embed && !expanded) ? 0 : 8, 1581 1618 whiteSpace: "pre-wrap", 1582 1619 textAlign: "left", 1583 1620 overflowWrap: "anywhere", ··· 1799 1836 salt: string; 1800 1837 navigate: ({}: any) => void; 1801 1838 }) { 1839 + const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); 1802 1840 if ( 1803 1841 AppBskyEmbedRecordWithMedia.isView(embed) && 1804 1842 AppBskyEmbedRecord.isViewRecord(embed.record.record) && ··· 2000 2038 if (AppBskyEmbedImages.isView(embed)) { 2001 2039 const { images } = embed; 2002 2040 2041 + const lightboxImages = images.map((img) => ({ 2042 + src: img.fullsize, 2043 + alt: img.alt, 2044 + })); 2045 + 2046 + 2003 2047 if (images.length > 0) { 2004 2048 // const items = embed.images.map(img => ({ 2005 2049 // uri: img.fullsize, ··· 2030 2074 }} 2031 2075 className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900" 2032 2076 > 2077 + {lightboxIndex !== null && ( 2078 + <Lightbox 2079 + images={lightboxImages} 2080 + index={lightboxIndex} 2081 + onClose={() => setLightboxIndex(null)} 2082 + onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2083 + /> 2084 + )} 2033 2085 <img 2034 2086 src={image.fullsize} 2035 2087 alt={image.alt} ··· 2038 2090 height: "100%", 2039 2091 objectFit: "contain", // letterbox or scale to fit 2040 2092 }} 2093 + onClick={(e) => {e.stopPropagation();setLightboxIndex(0)}} 2041 2094 /> 2042 2095 </div> 2043 2096 </div> ··· 2058 2111 }} 2059 2112 className="border border-gray-200 dark:border-gray-700" 2060 2113 > 2114 + {lightboxIndex !== null && ( 2115 + <Lightbox 2116 + images={lightboxImages} 2117 + index={lightboxIndex} 2118 + onClose={() => setLightboxIndex(null)} 2119 + onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2120 + /> 2121 + )} 2061 2122 {images.map((img, i) => ( 2062 2123 <div 2063 2124 key={i} ··· 2072 2133 objectFit: "cover", 2073 2134 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 2074 2135 }} 2136 + onClick={(e) => {e.stopPropagation();setLightboxIndex(i)}} 2075 2137 /> 2076 2138 </div> 2077 2139 ))} ··· 2095 2157 }} 2096 2158 className="border border-gray-200 dark:border-gray-700" 2097 2159 > 2160 + {lightboxIndex !== null && ( 2161 + <Lightbox 2162 + images={lightboxImages} 2163 + index={lightboxIndex} 2164 + onClose={() => setLightboxIndex(null)} 2165 + onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2166 + /> 2167 + )} 2098 2168 {/* Left: 1:1 */} 2099 2169 <div 2100 2170 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} ··· 2108 2178 objectFit: "cover", 2109 2179 borderRadius: "12px 0 0 12px", 2110 2180 }} 2181 + onClick={(e) => {e.stopPropagation();setLightboxIndex(0)}} 2111 2182 /> 2112 2183 </div> 2113 2184 {/* Right: two stacked 2:1 */} ··· 2137 2208 objectFit: "cover", 2138 2209 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 2139 2210 }} 2211 + onClick={(e) => {e.stopPropagation();setLightboxIndex(i+1)}} 2140 2212 /> 2141 2213 </div> 2142 2214 ))} ··· 2163 2235 }} 2164 2236 className="border border-gray-200 dark:border-gray-700" 2165 2237 > 2238 + {lightboxIndex !== null && ( 2239 + <Lightbox 2240 + images={lightboxImages} 2241 + index={lightboxIndex} 2242 + onClose={() => setLightboxIndex(null)} 2243 + onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2244 + /> 2245 + )} 2166 2246 {images.map((img, i) => ( 2167 2247 <div 2168 2248 key={i} ··· 2189 2269 ? "0 0 0 12px" 2190 2270 : "0 0 12px 0", 2191 2271 }} 2272 + onClick={(e) => {e.stopPropagation();setLightboxIndex(i)}} 2192 2273 /> 2193 2274 </div> 2194 2275 ))} ··· 2248 2329 2249 2330 return <div />; 2250 2331 } 2332 + 2333 + import { createPortal } from "react-dom"; 2334 + type LightboxProps = { 2335 + images: { src: string; alt?: string }[]; 2336 + index: number; 2337 + onClose: () => void; 2338 + onNavigate?: (newIndex: number) => void; 2339 + }; 2340 + export function Lightbox({ images, index, onClose, onNavigate }: LightboxProps) { 2341 + const image = images[index]; 2342 + 2343 + useEffect(() => { 2344 + function handleKey(e: KeyboardEvent) { 2345 + if (e.key === "Escape") onClose(); 2346 + if (e.key === "ArrowRight" && onNavigate) onNavigate((index + 1) % images.length); 2347 + if (e.key === "ArrowLeft" && onNavigate) onNavigate((index - 1 + images.length) % images.length); 2348 + } 2349 + window.addEventListener("keydown", handleKey); 2350 + return () => window.removeEventListener("keydown", handleKey); 2351 + }, [index, images.length, onClose, onNavigate]); 2352 + 2353 + return createPortal( 2354 + <div 2355 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/80" 2356 + onClick={(e)=>{e.stopPropagation();onClose()}} 2357 + > 2358 + <img 2359 + src={image.src} 2360 + alt={image.alt} 2361 + className="max-h-[90vh] max-w-[90vw] object-contain rounded-lg shadow-lg" 2362 + onClick={(e) => e.stopPropagation()} 2363 + /> 2364 + 2365 + {images.length > 1 && ( 2366 + <> 2367 + <button 2368 + onClick={(e) => { 2369 + e.stopPropagation(); 2370 + onNavigate?.((index - 1 + images.length) % images.length); 2371 + }} 2372 + className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center" 2373 + > 2374 + <svg xmlns="http://www.w3.org/2000/svg" width={28} height={28} viewBox="0 0 24 24"><g fill="none" fillRule="evenodd"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path><path fill="currentColor" d="M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414z"></path></g></svg> 2375 + </button> 2376 + <button 2377 + onClick={(e) => { 2378 + e.stopPropagation(); 2379 + onNavigate?.((index + 1) % images.length); 2380 + }} 2381 + className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center" 2382 + > 2383 + <svg xmlns="http://www.w3.org/2000/svg" width={28} height={28} viewBox="0 0 24 24"><g fill="none" fillRule="evenodd"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path><path fill="currentColor" d="M15.707 11.293a1 1 0 0 1 0 1.414l-5.657 5.657a1 1 0 1 1-1.414-1.414l4.95-4.95l-4.95-4.95a1 1 0 0 1 1.414-1.414z"></path></g></svg> 2384 + </button> 2385 + </> 2386 + )} 2387 + </div>, 2388 + document.body 2389 + ); 2390 + } 2391 + 2251 2392 function getDomain(url: string) { 2252 2393 try { 2253 2394 const { hostname } = new URL(url);
+1 -1
src/components/UserError.tsx
··· 1 - import { ErrorComponent, ErrorComponentProps } from "@tanstack/react-router"; 1 + import { ErrorComponent, type ErrorComponentProps } from "@tanstack/react-router"; 2 2 3 3 export function UserErrorComponent({ error }: ErrorComponentProps) { 4 4 return <ErrorComponent error={error} />;
+2 -2
src/routes/__root.tsx
··· 329 329 /> 330 330 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 331 331 Red Dwarf{" "} 332 - <span className="text-gray-500 dark:text-gray-400 text-sm"> 332 + {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> 333 333 lite 334 - </span> 334 + </span> */} 335 335 </span> 336 336 </div> 337 337 <div className="flex items-center gap-2">