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 1 import { Link, useRouter } from "@tanstack/react-router"; 2 + import { useAtom } from "jotai"; 3 + 4 + import { isAtTopAtom } from "~/utils/atoms"; 2 5 3 6 export function Header({ 4 7 backButtonCallback, ··· 8 11 title?: string; 9 12 }) { 10 13 const router = useRouter(); 14 + const [isAtTop] = useAtom(isAtTopAtom); 11 15 //const what = router.history. 12 16 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"> 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`}> 14 18 {backButtonCallback ? (<Link 15 19 to=".." 16 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 113 <button 114 114 onClick={handleRefresh} 115 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" 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 117 aria-label="Refresh feed" 118 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" />} 119 + <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 120 120 </button> 121 121 </> 122 122 );
+15 -15
src/components/UniversalPostRenderer.tsx
··· 1248 1248 // dont cursor: "pointer", 1249 1249 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1250 1250 }} 1251 - className="border-gray-300 dark:border-gray-600" 1251 + className="border-gray-300 dark:border-gray-800" 1252 1252 > 1253 1253 {isRepost && ( 1254 1254 <div ··· 1316 1316 width: isQuote ? 16 : 42, 1317 1317 height: isQuote ? 16 : 42, 1318 1318 }} 1319 - className="border border-gray-300 dark:border-gray-600 bg-gray-300 dark:bg-gray-600" 1319 + className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1320 1320 /> 1321 1321 </div> 1322 1322 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> ··· 1521 1521 hydrate embeds this deep but the connection here is implicit 1522 1522 todo: idk make this a real part of the embed shim so its not implicit */ 1523 1523 <> 1524 - <div className="border-gray-300 dark:border-gray-600 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1524 + <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1525 1525 (there is an embed here thats too deep to render) 1526 1526 </div> 1527 1527 </> ··· 1544 1544 borderBottomWidth: 1, 1545 1545 marginBottom: 8, 1546 1546 }} // important for height animation 1547 - className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700" 1547 + className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1548 1548 > 1549 1549 {fullDateTimeFormat(post.indexedAt)} 1550 1550 </div> ··· 1780 1780 //boxShadow: theme.cardShadow, 1781 1781 overflow: "hidden", 1782 1782 }} 1783 - className="shadow border border-gray-200 dark:border-gray-700" 1783 + className="shadow border border-gray-200 dark:border-gray-800 was7" 1784 1784 > 1785 1785 <UniversalPostRenderer 1786 1786 post={post} ··· 1897 1897 //boxShadow: theme.cardShadow, 1898 1898 overflow: "hidden", 1899 1899 }} 1900 - className="shadow border border-gray-200 dark:border-gray-700" 1900 + className="shadow border border-gray-200 dark:border-gray-800 was7" 1901 1901 > 1902 1902 <UniversalPostRenderer 1903 1903 post={post} ··· 1970 1970 //border: `1px solid ${theme.border}`, 1971 1971 overflow: "hidden", 1972 1972 }} 1973 - className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900" 1973 + className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 1974 1974 > 1975 1975 {lightboxIndex !== null && ( 1976 1976 <Lightbox ··· 2011 2011 overflow: "hidden", 2012 2012 //border: `1px solid ${theme.border}`, 2013 2013 }} 2014 - className="border border-gray-200 dark:border-gray-700" 2014 + className="border border-gray-200 dark:border-gray-800 was7" 2015 2015 > 2016 2016 {lightboxIndex !== null && ( 2017 2017 <Lightbox ··· 2061 2061 //border: `1px solid ${theme.border}`, 2062 2062 // height: 240, // fixed height for cropping 2063 2063 }} 2064 - className="border border-gray-200 dark:border-gray-700" 2064 + className="border border-gray-200 dark:border-gray-800 was7" 2065 2065 > 2066 2066 {lightboxIndex !== null && ( 2067 2067 <Lightbox ··· 2146 2146 //border: `1px solid ${theme.border}`, 2147 2147 //aspectRatio: "3 / 2", // overall grid aspect 2148 2148 }} 2149 - className="border border-gray-200 dark:border-gray-700" 2149 + className="border border-gray-200 dark:border-gray-800 was7" 2150 2150 > 2151 2151 {lightboxIndex !== null && ( 2152 2152 <Lightbox ··· 2283 2283 e.stopPropagation(); 2284 2284 e.nativeEvent.stopImmediatePropagation(); 2285 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" 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 2287 > 2288 2288 <ProfilePostComponent 2289 2289 did={post.did} ··· 2587 2587 > 2588 2588 <div 2589 2589 style={containerStyle as React.CSSProperties} 2590 - className="border border-gray-200 dark:border-gray-700" 2590 + className="border border-gray-200 dark:border-gray-800 was7" 2591 2591 > 2592 2592 {thumb && ( 2593 2593 <div ··· 2601 2601 marginBottom: 8, 2602 2602 //borderBottom: `1px solid ${theme.border}`, 2603 2603 }} 2604 - className="border-b border-gray-200 dark:border-gray-700" 2604 + className="border-b border-gray-200 dark:border-gray-800 was7" 2605 2605 > 2606 2606 <img 2607 2607 src={thumb} ··· 2727 2727 borderRadius: 12, 2728 2728 //border: `1px solid ${theme.border}`, 2729 2729 }} 2730 - className="border border-gray-200 dark:border-gray-700" 2730 + className="border border-gray-200 dark:border-gray-800 was7" 2731 2731 onClick={async (e) => { 2732 2732 e.stopPropagation(); 2733 2733 setPlaying(true); ··· 2768 2768 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 2769 2769 }%`, // 16:9 = 56.25%, 4:3 = 75% 2770 2770 }} 2771 - className="border border-gray-200 dark:border-gray-700" 2771 + className="border border-gray-200 dark:border-gray-800 was7" 2772 2772 > 2773 2773 <ReactPlayer 2774 2774 src={url}
+53 -10
src/main.tsx
··· 1 1 import "~/styles/app.css"; 2 2 3 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"; 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 9 //import { StrictMode } from "react"; 10 10 import ReactDOM from "react-dom/client"; 11 11 12 12 import reportWebVitals from "./reportWebVitals.ts"; 13 13 // Import the generated route tree 14 14 import { routeTree } from "./routeTree.gen"; 15 - 15 + import { isAtTopAtom } from "./utils/atoms.ts"; 16 16 17 17 const queryClient = new QueryClient({ 18 18 defaultOptions: { ··· 28 28 persistQueryClient({ 29 29 queryClient, 30 30 persister: localStoragePersister, 31 - }) 31 + }); 32 32 33 33 // Create a new router instance 34 34 const router = createRouter({ ··· 54 54 root.render( 55 55 // double queries annoys me 56 56 // <StrictMode> 57 - <QueryClientProvider client={queryClient}> 58 - <RouterProvider router={router} /> 59 - </QueryClientProvider> 57 + <QueryClientProvider client={queryClient}> 58 + <ScrollTopWatcher /> 59 + <RouterProvider router={router} /> 60 + </QueryClientProvider> 60 61 // </StrictMode> 61 62 ); 62 63 } ··· 65 66 // to log results (for example: reportWebVitals(// /*mass comment*/ console.log)) 66 67 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 67 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 431 </button> 432 432 )} 433 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"> 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 435 {children} 436 436 </main> 437 437 ··· 448 448 </div> 449 449 450 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"> 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 452 <div className="flex justify-around items-center p-2"> 453 453 <MaterialNavItem 454 454 small ··· 616 616 </div> 617 617 </nav> 618 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"> 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 620 <div className="flex items-center gap-2"> 621 621 <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 622 622 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> ··· 682 682 <button 683 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 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" 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 687 }`} 688 688 onClick={() => { 689 689 onClickCallbback(); ··· 693 693 {active ? ActiveIcon : InactiveIcon} 694 694 </div> 695 695 <span 696 - className={`text-[16px] text-roboto ${active ? "font-medium" : ""}`} 696 + className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`} 697 697 > 698 698 {text} 699 699 </span> ··· 732 732 {active ? ActiveIcon : InactiveIcon} 733 733 </div> 734 734 <span 735 - className={`text-[16px] text-roboto ${active ? "font-medium" : ""}`} 735 + className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`} 736 736 > 737 737 {text} 738 738 </span>
+6 -2
src/routes/index.tsx
··· 10 10 agentAtom, 11 11 authedAtom, 12 12 feedScrollPositionsAtom, 13 + isAtTopAtom, 13 14 selectedFeedUriAtom, 14 15 store, 15 16 } from "~/utils/atoms"; ··· 350 351 authed && agent && identity?.pds && feedServiceDid; 351 352 const isReadyForUnauthedFeed = !authed && selectedFeed; 352 353 354 + 355 + const [isAtTop] = useAtom(isAtTopAtom); 356 + 353 357 return ( 354 358 <div 355 - className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 359 + className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"} ${!isAtTop && "shadow"}`} 356 360 > 357 361 {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"> 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"> 359 363 {savedFeeds.map((item: any, idx: number) => { 360 364 const label = item.value.split("/").pop() || item.value; 361 365 const isActive = selectedFeed === item.value;
+19
src/styles/app.css
··· 86 86 } 87 87 .font-roboto { 88 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)); 89 108 }
+2
src/utils/atoms.ts
··· 21 21 {} 22 22 ); 23 23 24 + export const isAtTopAtom = atom<boolean>(true); 25 + 24 26 export const agentAtom = atom<Agent|null>(null); 25 27 export const authedAtom = atom<boolean>(false);