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

hueshift

rimar1337 83082d78 074047b5

Changed files
+152 -44
src
components
routes
styles
utils
+1
package-lock.json
··· 9 9 "@atproto/api": "^0.16.6", 10 10 "@atproto/oauth-client-browser": "^0.3.33", 11 11 "@radix-ui/react-dropdown-menu": "^2.1.16", 12 + "@radix-ui/react-slider": "^1.3.6", 12 13 "@tailwindcss/vite": "^4.0.6", 13 14 "@tanstack/query-sync-storage-persister": "^5.85.6", 14 15 "@tanstack/react-devtools": "^0.2.2",
+1
package.json
··· 13 13 "@atproto/api": "^0.16.6", 14 14 "@atproto/oauth-client-browser": "^0.3.33", 15 15 "@radix-ui/react-dropdown-menu": "^2.1.16", 16 + "@radix-ui/react-slider": "^1.3.6", 16 17 "@tailwindcss/vite": "^4.0.6", 17 18 "@tanstack/query-sync-storage-persister": "^5.85.6", 18 19 "@tanstack/react-devtools": "^0.2.2",
+6
src/components/Star.tsx
··· 1 + import type { SVGProps } from 'react'; 2 + import React from 'react'; 3 + 4 + export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) { 5 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>); 6 + }
+2
src/main.tsx
··· 14 14 import { routeTree } from "./routeTree.gen"; 15 15 import { isAtTopAtom } from "./utils/atoms.ts"; 16 16 17 + //initAtomToCssVar(hueAtom, "--tw-gray-hue") 18 + 17 19 const queryClient = new QueryClient({ 18 20 defaultOptions: { 19 21 queries: {
+6 -4
src/routes/__root.tsx
··· 20 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 21 import Login from "~/components/Login"; 22 22 import { NotFound } from "~/components/NotFound"; 23 + import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 23 24 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 24 - import { composerAtom } from "~/utils/atoms"; 25 + import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 25 26 import { seo } from "~/utils/seo"; 26 27 27 28 export const Route = createRootRouteWithContext<{ ··· 87 88 } 88 89 89 90 function RootDocument({ children }: { children: React.ReactNode }) { 91 + useAtomCssVar(hueAtom, "--tw-gray-hue"); 90 92 const location = useLocation(); 91 93 const navigate = useNavigate(); 92 94 const { agent } = useAuth(); ··· 128 130 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 129 131 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 130 132 <div className="flex items-center gap-3 mb-4"> 131 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 133 + <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 132 134 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 133 135 Red Dwarf{" "} 134 136 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 367 369 368 370 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 369 371 <div className="flex items-center gap-3 mb-4"> 370 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 372 + <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 371 373 </div> 372 374 <MaterialNavItem 373 375 small ··· 676 678 ) : ( 677 679 <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"> 678 680 <div className="flex items-center gap-2"> 679 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 681 + <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 680 682 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 681 683 Red Dwarf{" "} 682 684 {/* <span className="text-gray-500 dark:text-gray-400 text-sm">
+73 -4
src/routes/settings.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { useAtom } from "jotai"; 3 + import { Slider } from "radix-ui"; 3 4 4 5 import { Header } from "~/components/Header"; 5 6 import Login from "~/components/Login"; 6 7 import { 7 8 constellationURLAtom, 8 9 defaultconstellationURL, 10 + defaulthue, 9 11 defaultImgCDN, 10 12 defaultslingshotURL, 11 13 defaultVideoCDN, 14 + hueAtom, 12 15 imgCDNAtom, 13 16 slingshotURLAtom, 14 17 videoCDNAtom, ··· 31 34 } 32 35 }} 33 36 /> 34 - <div className="lg:hidden"><Login /></div> 37 + <div className="lg:hidden"> 38 + <Login /> 39 + </div> 35 40 <div className="h-4" /> 36 41 <TextInputSetting 37 42 atom={constellationURLAtom} ··· 61 66 description={"Customize the Slingshot instance to be used by Red Dwarf"} 62 67 init={defaultVideoCDN} 63 68 /> 64 - <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">please restart/refresh the app if changes arent applying correctly</p> 69 + 70 + <Hue /> 71 + <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 72 + please restart/refresh the app if changes arent applying correctly 73 + </p> 65 74 </> 66 75 ); 67 76 } 77 + function Hue() { 78 + const [hue, setHue] = useAtom(hueAtom); 79 + return ( 80 + <div className="flex flex-col px-4 mt-4 "> 81 + <span className="z-10">Hue</span> 82 + <div className="flex flex-row items-center gap-4"> 83 + <SliderComponent 84 + atom={hueAtom} 85 + max={360} 86 + /> 87 + <button 88 + onClick={() => setHue(defaulthue ?? 28)} 89 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 90 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 91 + > 92 + Reset 93 + </button> 94 + </div> 95 + </div> 96 + ); 97 + } 68 98 69 99 export function TextInputSetting({ 70 100 atom, ··· 95 125 96 126 <div className="flex flex-row gap-2 items-center"> 97 127 <div className="m3input-field m3input-label m3input-border size-md flex-1"> 98 - <input type="text" placeholder=" " value={value} onChange={(e) => setValue(e.target.value)}/> 128 + <input 129 + type="text" 130 + placeholder=" " 131 + value={value} 132 + onChange={(e) => setValue(e.target.value)} 133 + /> 99 134 <label>{title}</label> 100 135 </div> 101 136 {/* <input ··· 117 152 </div> 118 153 </div> 119 154 ); 120 - } 155 + } 156 + 157 + 158 + interface SliderProps { 159 + atom: typeof hueAtom; 160 + min?: number; 161 + max?: number; 162 + step?: number; 163 + } 164 + 165 + export const SliderComponent: React.FC<SliderProps> = ({ 166 + atom, 167 + min = 0, 168 + max = 100, 169 + step = 1, 170 + }) => { 171 + 172 + const [value, setValue] = useAtom(atom) 173 + 174 + return ( 175 + <Slider.Root 176 + className="relative flex items-center w-full h-4" 177 + value={[value]} 178 + min={min} 179 + max={max} 180 + step={step} 181 + onValueChange={(v: number[]) => setValue(v[0])} 182 + > 183 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 184 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 185 + </Slider.Track> 186 + <Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 187 + </Slider.Root> 188 + ); 189 + };
+15 -11
src/styles/app.css
··· 15 15 --color-gray-950: oklch(0.129 0.050 222.000); 16 16 } */ 17 17 18 + :root { 19 + --safe-hue: var(--tw-gray-hue, 28) 20 + } 21 + 18 22 @theme { 19 - --color-gray-50: oklch(0.984 0.012 28); 20 - --color-gray-100: oklch(0.968 0.017 28); 21 - --color-gray-200: oklch(0.929 0.025 28); 22 - --color-gray-300: oklch(0.869 0.035 28); 23 - --color-gray-400: oklch(0.704 0.05 28); 24 - --color-gray-500: oklch(0.554 0.06 28); 25 - --color-gray-600: oklch(0.446 0.058 28); 26 - --color-gray-700: oklch(0.372 0.058 28); 27 - --color-gray-800: oklch(0.279 0.055 28); 28 - --color-gray-900: oklch(0.208 0.055 28); 29 - --color-gray-950: oklch(0.129 0.055 28); 23 + --color-gray-50: oklch(0.984 0.012 var(--safe-hue)); 24 + --color-gray-100: oklch(0.968 0.017 var(--safe-hue)); 25 + --color-gray-200: oklch(0.929 0.025 var(--safe-hue)); 26 + --color-gray-300: oklch(0.869 0.035 var(--safe-hue)); 27 + --color-gray-400: oklch(0.704 0.05 var(--safe-hue)); 28 + --color-gray-500: oklch(0.554 0.06 var(--safe-hue)); 29 + --color-gray-600: oklch(0.446 0.058 var(--safe-hue)); 30 + --color-gray-700: oklch(0.372 0.058 var(--safe-hue)); 31 + --color-gray-800: oklch(0.279 0.055 var(--safe-hue)); 32 + --color-gray-900: oklch(0.208 0.055 var(--safe-hue)); 33 + --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 30 34 } 31 35 32 36 @layer base {
+48 -25
src/utils/atoms.ts
··· 1 1 import type Agent from "@atproto/api"; 2 - import { atom, createStore } from "jotai"; 3 - import { atomWithStorage } from 'jotai/utils'; 2 + import { atom, createStore, useAtomValue } from "jotai"; 3 + import { atomWithStorage } from "jotai/utils"; 4 + import { useEffect } from "react"; 4 5 5 6 export const store = createStore(); 6 7 7 8 export const selectedFeedUriAtom = atomWithStorage<string | null>( 8 - 'selectedFeedUri', 9 + "selectedFeedUri", 9 10 null 10 11 ); 11 12 12 13 //export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 13 14 14 15 export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>( 15 - 'feedscrollpositions', 16 + "feedscrollpositions", 16 17 {} 17 18 ); 18 19 19 20 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 20 - 'likedPosts', 21 + "likedPosts", 21 22 {} 22 23 ); 23 24 24 - export const defaultconstellationURL = 'constellation.microcosm.blue' 25 + export const defaultconstellationURL = "constellation.microcosm.blue"; 25 26 export const constellationURLAtom = atomWithStorage<string>( 26 - 'constellationURL', 27 + "constellationURL", 27 28 defaultconstellationURL 28 - ) 29 - export const defaultslingshotURL = 'slingshot.microcosm.blue' 29 + ); 30 + export const defaultslingshotURL = "slingshot.microcosm.blue"; 30 31 export const slingshotURLAtom = atomWithStorage<string>( 31 - 'slingshotURL', 32 + "slingshotURL", 32 33 defaultslingshotURL 33 - ) 34 - export const defaultImgCDN = 'cdn.bsky.app' 35 - export const imgCDNAtom = atomWithStorage<string>( 36 - 'imgcdnurl', 37 - defaultImgCDN 38 - ) 39 - export const defaultVideoCDN = 'video.bsky.app' 34 + ); 35 + export const defaultImgCDN = "cdn.bsky.app"; 36 + export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 37 + export const defaultVideoCDN = "video.bsky.app"; 40 38 export const videoCDNAtom = atomWithStorage<string>( 41 - 'videocdnurl', 39 + "videocdnurl", 42 40 defaultVideoCDN 43 - ) 41 + ); 42 + 43 + export const defaulthue = 28; 44 + export const hueAtom = atomWithStorage<number>("hue", defaulthue); 44 45 45 46 export const isAtTopAtom = atom<boolean>(true); 46 47 47 48 type ComposerState = 48 - | { kind: 'closed' } 49 - | { kind: 'root' } 50 - | { kind: 'reply'; parent: string } 51 - | { kind: 'quote'; subject: string }; 52 - export const composerAtom = atom<ComposerState>({ kind: 'closed' }); 49 + | { kind: "closed" } 50 + | { kind: "root" } 51 + | { kind: "reply"; parent: string } 52 + | { kind: "quote"; subject: string }; 53 + export const composerAtom = atom<ComposerState>({ kind: "closed" }); 53 54 54 - export const agentAtom = atom<Agent|null>(null); 55 + export const agentAtom = atom<Agent | null>(null); 55 56 export const authedAtom = atom<boolean>(false); 57 + 58 + export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) { 59 + const value = useAtomValue(atom); 60 + 61 + useEffect(() => { 62 + document.documentElement.style.setProperty(cssVar, value.toString()); 63 + }, [value, cssVar]); 64 + 65 + useEffect(() => { 66 + document.documentElement.style.setProperty(cssVar, value.toString()); 67 + }, []); 68 + } 69 + 70 + hueAtom.onMount = (setAtom) => { 71 + const stored = localStorage.getItem("hue"); 72 + if (stored != null) setAtom(Number(stored)); 73 + }; 74 + // export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) { 75 + // const initial = store.get(atom); 76 + // console.log("atom get ", initial); 77 + // document.documentElement.style.setProperty(cssVar, initial.toString()); 78 + // }