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

more material 3 tweaks

rimar1337 91e90cba fb3fbe80

+5 -1
src/components/Header.tsx
··· 1 import { Link, useRouter } from "@tanstack/react-router"; 2 3 export function Header({ 4 backButtonCallback, ··· 8 title?: string; 9 }) { 10 const router = useRouter(); 11 //const what = router.history. 12 return ( 13 - <div className="flex items-center gap-4 px-4 py-3 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700"> 14 {backButtonCallback ? (<Link 15 to=".." 16 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
··· 1 import { Link, useRouter } from "@tanstack/react-router"; 2 + import { useAtom } from "jotai"; 3 + 4 + import { isAtTopAtom } from "~/utils/atoms"; 5 6 export function Header({ 7 backButtonCallback, ··· 11 title?: string; 12 }) { 13 const router = useRouter(); 14 + const [isAtTop] = useAtom(isAtTopAtom); 15 //const what = router.history. 16 return ( 17 + <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!isAtTop && "shadow"} border-gray-200 dark:border-gray-700`}> 18 {backButtonCallback ? (<Link 19 to=".." 20 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+2 -2
src/components/InfiniteCustomFeed.tsx
··· 113 <button 114 onClick={handleRefresh} 115 disabled={isRefetching} 116 - className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:bg-gray-400 disabled:cursor-not-allowed" 117 aria-label="Refresh feed" 118 > 119 - {isRefetching ? <RefreshIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 animate-spin" /> : <RefreshIcon className="h-6 w-6 text-gray-600 dark:text-gray-400" />} 120 </button> 121 </> 122 );
··· 113 <button 114 onClick={handleRefresh} 115 disabled={isRefetching} 116 + className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 117 aria-label="Refresh feed" 118 > 119 + <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 120 </button> 121 </> 122 );
+15 -15
src/components/UniversalPostRenderer.tsx
··· 1248 // dont cursor: "pointer", 1249 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1250 }} 1251 - className="border-gray-300 dark:border-gray-600" 1252 > 1253 {isRepost && ( 1254 <div ··· 1316 width: isQuote ? 16 : 42, 1317 height: isQuote ? 16 : 42, 1318 }} 1319 - className="border border-gray-300 dark:border-gray-600 bg-gray-300 dark:bg-gray-600" 1320 /> 1321 </div> 1322 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> ··· 1521 hydrate embeds this deep but the connection here is implicit 1522 todo: idk make this a real part of the embed shim so its not implicit */ 1523 <> 1524 - <div className="border-gray-300 dark:border-gray-600 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1525 (there is an embed here thats too deep to render) 1526 </div> 1527 </> ··· 1544 borderBottomWidth: 1, 1545 marginBottom: 8, 1546 }} // important for height animation 1547 - className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700" 1548 > 1549 {fullDateTimeFormat(post.indexedAt)} 1550 </div> ··· 1780 //boxShadow: theme.cardShadow, 1781 overflow: "hidden", 1782 }} 1783 - className="shadow border border-gray-200 dark:border-gray-700" 1784 > 1785 <UniversalPostRenderer 1786 post={post} ··· 1897 //boxShadow: theme.cardShadow, 1898 overflow: "hidden", 1899 }} 1900 - className="shadow border border-gray-200 dark:border-gray-700" 1901 > 1902 <UniversalPostRenderer 1903 post={post} ··· 1970 //border: `1px solid ${theme.border}`, 1971 overflow: "hidden", 1972 }} 1973 - className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900" 1974 > 1975 {lightboxIndex !== null && ( 1976 <Lightbox ··· 2011 overflow: "hidden", 2012 //border: `1px solid ${theme.border}`, 2013 }} 2014 - className="border border-gray-200 dark:border-gray-700" 2015 > 2016 {lightboxIndex !== null && ( 2017 <Lightbox ··· 2061 //border: `1px solid ${theme.border}`, 2062 // height: 240, // fixed height for cropping 2063 }} 2064 - className="border border-gray-200 dark:border-gray-700" 2065 > 2066 {lightboxIndex !== null && ( 2067 <Lightbox ··· 2146 //border: `1px solid ${theme.border}`, 2147 //aspectRatio: "3 / 2", // overall grid aspect 2148 }} 2149 - className="border border-gray-200 dark:border-gray-700" 2150 > 2151 {lightboxIndex !== null && ( 2152 <Lightbox ··· 2283 e.stopPropagation(); 2284 e.nativeEvent.stopImmediatePropagation(); 2285 }} 2286 - className="lightbox-sidebar overscroll-none disablegutter border-l dark:border-gray-700 border-gray-300 fixed z-50 flex top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 2287 > 2288 <ProfilePostComponent 2289 did={post.did} ··· 2587 > 2588 <div 2589 style={containerStyle as React.CSSProperties} 2590 - className="border border-gray-200 dark:border-gray-700" 2591 > 2592 {thumb && ( 2593 <div ··· 2601 marginBottom: 8, 2602 //borderBottom: `1px solid ${theme.border}`, 2603 }} 2604 - className="border-b border-gray-200 dark:border-gray-700" 2605 > 2606 <img 2607 src={thumb} ··· 2727 borderRadius: 12, 2728 //border: `1px solid ${theme.border}`, 2729 }} 2730 - className="border border-gray-200 dark:border-gray-700" 2731 onClick={async (e) => { 2732 e.stopPropagation(); 2733 setPlaying(true); ··· 2768 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 2769 }%`, // 16:9 = 56.25%, 4:3 = 75% 2770 }} 2771 - className="border border-gray-200 dark:border-gray-700" 2772 > 2773 <ReactPlayer 2774 src={url}
··· 1248 // dont cursor: "pointer", 1249 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1250 }} 1251 + className="border-gray-300 dark:border-gray-800" 1252 > 1253 {isRepost && ( 1254 <div ··· 1316 width: isQuote ? 16 : 42, 1317 height: isQuote ? 16 : 42, 1318 }} 1319 + className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1320 /> 1321 </div> 1322 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> ··· 1521 hydrate embeds this deep but the connection here is implicit 1522 todo: idk make this a real part of the embed shim so its not implicit */ 1523 <> 1524 + <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1525 (there is an embed here thats too deep to render) 1526 </div> 1527 </> ··· 1544 borderBottomWidth: 1, 1545 marginBottom: 8, 1546 }} // important for height animation 1547 + className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1548 > 1549 {fullDateTimeFormat(post.indexedAt)} 1550 </div> ··· 1780 //boxShadow: theme.cardShadow, 1781 overflow: "hidden", 1782 }} 1783 + className="shadow border border-gray-200 dark:border-gray-800 was7" 1784 > 1785 <UniversalPostRenderer 1786 post={post} ··· 1897 //boxShadow: theme.cardShadow, 1898 overflow: "hidden", 1899 }} 1900 + className="shadow border border-gray-200 dark:border-gray-800 was7" 1901 > 1902 <UniversalPostRenderer 1903 post={post} ··· 1970 //border: `1px solid ${theme.border}`, 1971 overflow: "hidden", 1972 }} 1973 + className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 1974 > 1975 {lightboxIndex !== null && ( 1976 <Lightbox ··· 2011 overflow: "hidden", 2012 //border: `1px solid ${theme.border}`, 2013 }} 2014 + className="border border-gray-200 dark:border-gray-800 was7" 2015 > 2016 {lightboxIndex !== null && ( 2017 <Lightbox ··· 2061 //border: `1px solid ${theme.border}`, 2062 // height: 240, // fixed height for cropping 2063 }} 2064 + className="border border-gray-200 dark:border-gray-800 was7" 2065 > 2066 {lightboxIndex !== null && ( 2067 <Lightbox ··· 2146 //border: `1px solid ${theme.border}`, 2147 //aspectRatio: "3 / 2", // overall grid aspect 2148 }} 2149 + className="border border-gray-200 dark:border-gray-800 was7" 2150 > 2151 {lightboxIndex !== null && ( 2152 <Lightbox ··· 2283 e.stopPropagation(); 2284 e.nativeEvent.stopImmediatePropagation(); 2285 }} 2286 + className="lightbox-sidebar overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 flex top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 2287 > 2288 <ProfilePostComponent 2289 did={post.did} ··· 2587 > 2588 <div 2589 style={containerStyle as React.CSSProperties} 2590 + className="border border-gray-200 dark:border-gray-800 was7" 2591 > 2592 {thumb && ( 2593 <div ··· 2601 marginBottom: 8, 2602 //borderBottom: `1px solid ${theme.border}`, 2603 }} 2604 + className="border-b border-gray-200 dark:border-gray-800 was7" 2605 > 2606 <img 2607 src={thumb} ··· 2727 borderRadius: 12, 2728 //border: `1px solid ${theme.border}`, 2729 }} 2730 + className="border border-gray-200 dark:border-gray-800 was7" 2731 onClick={async (e) => { 2732 e.stopPropagation(); 2733 setPlaying(true); ··· 2768 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 2769 }%`, // 16:9 = 56.25%, 4:3 = 75% 2770 }} 2771 + className="border border-gray-200 dark:border-gray-800 was7" 2772 > 2773 <ReactPlayer 2774 src={url}
+53 -10
src/main.tsx
··· 1 import "~/styles/app.css"; 2 3 import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; 4 - import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; 5 - import { 6 - persistQueryClient, 7 - } from "@tanstack/react-query-persist-client"; 8 - import { createRouter,RouterProvider } from "@tanstack/react-router"; 9 //import { StrictMode } from "react"; 10 import ReactDOM from "react-dom/client"; 11 12 import reportWebVitals from "./reportWebVitals.ts"; 13 // Import the generated route tree 14 import { routeTree } from "./routeTree.gen"; 15 - 16 17 const queryClient = new QueryClient({ 18 defaultOptions: { ··· 28 persistQueryClient({ 29 queryClient, 30 persister: localStoragePersister, 31 - }) 32 33 // Create a new router instance 34 const router = createRouter({ ··· 54 root.render( 55 // double queries annoys me 56 // <StrictMode> 57 - <QueryClientProvider client={queryClient}> 58 - <RouterProvider router={router} /> 59 - </QueryClientProvider> 60 // </StrictMode> 61 ); 62 } ··· 65 // to log results (for example: reportWebVitals(// /*mass comment*/ console.log)) 66 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 67 reportWebVitals();
··· 1 import "~/styles/app.css"; 2 3 import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; 4 + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 + import { persistQueryClient } from "@tanstack/react-query-persist-client"; 6 + import { createRouter, RouterProvider } from "@tanstack/react-router"; 7 + import { useSetAtom } from "jotai"; 8 + import { useEffect } from "react"; 9 //import { StrictMode } from "react"; 10 import ReactDOM from "react-dom/client"; 11 12 import reportWebVitals from "./reportWebVitals.ts"; 13 // Import the generated route tree 14 import { routeTree } from "./routeTree.gen"; 15 + import { isAtTopAtom } from "./utils/atoms.ts"; 16 17 const queryClient = new QueryClient({ 18 defaultOptions: { ··· 28 persistQueryClient({ 29 queryClient, 30 persister: localStoragePersister, 31 + }); 32 33 // Create a new router instance 34 const router = createRouter({ ··· 54 root.render( 55 // double queries annoys me 56 // <StrictMode> 57 + <QueryClientProvider client={queryClient}> 58 + <ScrollTopWatcher /> 59 + <RouterProvider router={router} /> 60 + </QueryClientProvider> 61 // </StrictMode> 62 ); 63 } ··· 66 // to log results (for example: reportWebVitals(// /*mass comment*/ console.log)) 67 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 68 reportWebVitals(); 69 + 70 + export default function ScrollTopWatcher() { 71 + const setIsAtTop = useSetAtom(isAtTopAtom); 72 + useEffect(() => { 73 + const meta = document.querySelector('meta[name="theme-color"]'); 74 + let lastAtTop = window.scrollY === 0; 75 + let timeoutId: number | undefined; 76 + 77 + const setVars = (atTop: boolean) => { 78 + const root = document.documentElement; 79 + root.style.setProperty("--is-top", atTop ? "1" : "0"); 80 + 81 + const bg = getComputedStyle(root).getPropertyValue("--header-bg").trim(); 82 + if (meta && bg) meta.setAttribute("content", bg); 83 + setIsAtTop(atTop); 84 + }; 85 + 86 + const check = () => { 87 + const atTop = window.scrollY === 0; 88 + if (atTop !== lastAtTop) { 89 + lastAtTop = atTop; 90 + setVars(atTop); 91 + } 92 + }; 93 + 94 + const handleScroll = () => { 95 + if (timeoutId) clearTimeout(timeoutId); 96 + timeoutId = window.setTimeout(check, 2); 97 + }; 98 + 99 + // initialize 100 + setVars(lastAtTop); 101 + window.addEventListener("scroll", handleScroll, { passive: true }); 102 + 103 + return () => { 104 + window.removeEventListener("scroll", handleScroll); 105 + if (timeoutId) clearTimeout(timeoutId); 106 + }; 107 + }, []); 108 + 109 + return null; 110 + }
+7 -7
src/routes/__root.tsx
··· 431 </button> 432 )} 433 434 - <main className="w-full max-w-[600px] lg:border-x border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-950 pb-16 lg:pb-0"> 435 {children} 436 </main> 437 ··· 448 </div> 449 450 {agent?.did ? ( 451 - <nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-950 border-t border-gray-200 dark:border-gray-700 z-40"> 452 <div className="flex justify-around items-center p-2"> 453 <MaterialNavItem 454 small ··· 616 </div> 617 </nav> 618 ) : ( 619 - <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-950 z-10"> 620 <div className="flex items-center gap-2"> 621 <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 622 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> ··· 682 <button 683 className={`flex flex-row h-12 min-h-12 max-h-12 px-4 py-0.5 w-full items-center rounded-full transition-colors flex-1 gap-1 ${ 684 active 685 - ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 686 - : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 687 }`} 688 onClick={() => { 689 onClickCallbback(); ··· 693 {active ? ActiveIcon : InactiveIcon} 694 </div> 695 <span 696 - className={`text-[16px] text-roboto ${active ? "font-medium" : ""}`} 697 > 698 {text} 699 </span> ··· 732 {active ? ActiveIcon : InactiveIcon} 733 </div> 734 <span 735 - className={`text-[16px] text-roboto ${active ? "font-medium" : ""}`} 736 > 737 {text} 738 </span>
··· 431 </button> 432 )} 433 434 + <main className="w-full max-w-[600px] lg:border-x border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 pb-16 lg:pb-0 overflow-x-clip"> 435 {children} 436 </main> 437 ··· 448 </div> 449 450 {agent?.did ? ( 451 + <nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-gray-50 dark:bg-gray-900 border-0 shadow border-gray-200 dark:border-gray-700 z-40"> 452 <div className="flex justify-around items-center p-2"> 453 <MaterialNavItem 454 small ··· 616 </div> 617 </nav> 618 ) : ( 619 + <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 620 <div className="flex items-center gap-2"> 621 <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 622 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> ··· 682 <button 683 className={`flex flex-row h-12 min-h-12 max-h-12 px-4 py-0.5 w-full items-center rounded-full transition-colors flex-1 gap-1 ${ 684 active 685 + ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-800 bg-gray-200 hover:dark:bg-gray-700" 686 + : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-900" 687 }`} 688 onClick={() => { 689 onClickCallbback(); ··· 693 {active ? ActiveIcon : InactiveIcon} 694 </div> 695 <span 696 + className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`} 697 > 698 {text} 699 </span> ··· 732 {active ? ActiveIcon : InactiveIcon} 733 </div> 734 <span 735 + className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`} 736 > 737 {text} 738 </span>
+6 -2
src/routes/index.tsx
··· 10 agentAtom, 11 authedAtom, 12 feedScrollPositionsAtom, 13 selectedFeedUriAtom, 14 store, 15 } from "~/utils/atoms"; ··· 350 authed && agent && identity?.pds && feedServiceDid; 351 const isReadyForUnauthedFeed = !authed && selectedFeed; 352 353 return ( 354 <div 355 - className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 356 > 357 {savedFeeds.length > 0 ? ( 358 - <div className="flex items-center px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin"> 359 {savedFeeds.map((item: any, idx: number) => { 360 const label = item.value.split("/").pop() || item.value; 361 const isActive = selectedFeed === item.value;
··· 10 agentAtom, 11 authedAtom, 12 feedScrollPositionsAtom, 13 + isAtTopAtom, 14 selectedFeedUriAtom, 15 store, 16 } from "~/utils/atoms"; ··· 351 authed && agent && identity?.pds && feedServiceDid; 352 const isReadyForUnauthedFeed = !authed && selectedFeed; 353 354 + 355 + const [isAtTop] = useAtom(isAtTopAtom); 356 + 357 return ( 358 <div 359 + className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"} ${!isAtTop && "shadow"}`} 360 > 361 {savedFeeds.length > 0 ? ( 362 + <div className="flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin"> 363 {savedFeeds.map((item: any, idx: number) => { 364 const label = item.value.split("/").pop() || item.value; 365 const isActive = selectedFeed === item.value;
+19
src/styles/app.css
··· 86 } 87 .font-roboto { 88 font-family: "Roboto", sans-serif; 89 }
··· 86 } 87 .font-roboto { 88 font-family: "Roboto", sans-serif; 89 + } 90 + 91 + :root { 92 + --header-bg-light: color-mix(in srgb, var(--color-white) calc(var(--is-top) * 100%), var(--color-gray-50)); 93 + --header-bg-dark: color-mix(in srgb, var(--color-gray-950) calc(var(--is-top) * 100%), var(--color-gray-900)); 94 + } 95 + 96 + :root { 97 + --header-bg: var(--header-bg-light); 98 + } 99 + @media (prefers-color-scheme: dark) { 100 + :root { 101 + --header-bg: var(--header-bg-dark); 102 + } 103 + } 104 + 105 + :root { 106 + --shadow-opacity: calc(1 - var(--is-top)); 107 + --tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15)); 108 }
+2
src/utils/atoms.ts
··· 21 {} 22 ); 23 24 export const agentAtom = atom<Agent|null>(null); 25 export const authedAtom = atom<boolean>(false);
··· 21 {} 22 ); 23 24 + export const isAtTopAtom = atom<boolean>(true); 25 + 26 export const agentAtom = atom<Agent|null>(null); 27 export const authedAtom = atom<boolean>(false);