an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useAtom, useAtomValue, useSetAtom } from "jotai";
3import { Slider, Switch } from "radix-ui";
4import { useEffect, useState } from "react";
5
6import { Header } from "~/components/Header";
7import Login from "~/components/Login";
8import {
9 constellationURLAtom,
10 defaultconstellationURL,
11 defaulthue,
12 defaultImgCDN,
13 defaultLycanURL,
14 defaultslingshotURL,
15 defaultVideoCDN,
16 enableBitesAtom,
17 enableBridgyTextAtom,
18 enableWafrnTextAtom,
19 hueAtom,
20 imgCDNAtom,
21 lycanURLAtom,
22 slingshotURLAtom,
23 videoCDNAtom,
24} from "~/utils/atoms";
25
26import { MaterialNavItem } from "./__root";
27
28export const Route = createFileRoute("/settings")({
29 component: Settings,
30});
31
32export function Settings() {
33 const navigate = useNavigate();
34 return (
35 <>
36 <Header
37 title="Settings"
38 backButtonCallback={() => {
39 if (window.history.length > 1) {
40 window.history.back();
41 } else {
42 window.location.assign("/");
43 }
44 }}
45 />
46 <div className="lg:hidden">
47 <Login />
48 </div>
49 <div className="sm:hidden flex flex-col justify-around mt-4">
50 <SettingHeading title="Other Pages" top />
51 <MaterialNavItem
52 InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
53 ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
54 active={false}
55 onClickCallbback={() =>
56 navigate({
57 to: "/feeds",
58 //params: { did: agent.assertDid },
59 })
60 }
61 text="Feeds"
62 />
63 <MaterialNavItem
64 InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
65 ActiveIcon={<IconMdiShield className="w-6 h-6" />}
66 active={false}
67 onClickCallbback={() =>
68 navigate({
69 to: "/moderation",
70 //params: { did: agent.assertDid },
71 })
72 }
73 text="Moderation"
74 />
75 </div>
76 <div className="h-4" />
77
78 <SettingHeading title="Personalization" top />
79 <Hue />
80
81 <SettingHeading title="Network Configuration" />
82 <div className="flex flex-col px-4 pb-2">
83 <span className="text-md">Service Endpoints</span>
84 <span className="text-sm text-gray-500 dark:text-gray-400">
85 Customize the servers to be used by the app
86 </span>
87 </div>
88 <TextInputSetting
89 atom={constellationURLAtom}
90 title={"Constellation"}
91 description={
92 "Customize the Constellation instance to be used by Red Dwarf"
93 }
94 init={defaultconstellationURL}
95 />
96 <TextInputSetting
97 atom={slingshotURLAtom}
98 title={"Slingshot"}
99 description={"Customize the Slingshot instance to be used by Red Dwarf"}
100 init={defaultslingshotURL}
101 />
102 <TextInputSetting
103 atom={imgCDNAtom}
104 title={"Image CDN"}
105 description={
106 "Customize the Constellation instance to be used by Red Dwarf"
107 }
108 init={defaultImgCDN}
109 />
110 <TextInputSetting
111 atom={videoCDNAtom}
112 title={"Video CDN"}
113 description={"Customize the Slingshot instance to be used by Red Dwarf"}
114 init={defaultVideoCDN}
115 />
116 <TextInputSetting
117 atom={lycanURLAtom}
118 title={"Lycan Search"}
119 description={"Enable text search across posts you've interacted with"}
120 init={defaultLycanURL}
121 />
122
123 <SettingHeading title="Experimental" />
124 <SwitchSetting
125 atom={enableBitesAtom}
126 title={"Bites"}
127 description={"Enable Wafrn Bites to bite and be bitten by other people"}
128 //init={false}
129 />
130 <div className="h-4" />
131 <SwitchSetting
132 atom={enableBridgyTextAtom}
133 title={"Bridgy Text"}
134 description={
135 "Show the original text of posts bridged from the Fediverse"
136 }
137 //init={false}
138 />
139 <div className="h-4" />
140 <SwitchSetting
141 atom={enableWafrnTextAtom}
142 title={"Wafrn Text"}
143 description={"Show the original text of posts from Wafrn instances"}
144 //init={false}
145 />
146 <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">
147 Notice: Please restart/refresh the app if changes arent applying
148 correctly
149 </p>
150 </>
151 );
152}
153
154export function SettingHeading({
155 title,
156 top,
157}: {
158 title: string;
159 top?: boolean;
160}) {
161 return (
162 <div
163 className="px-4"
164 style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }}
165 >
166 <span className=" text-sm font-medium text-gray-500 dark:text-gray-400">
167 {title}
168 </span>
169 </div>
170 );
171}
172
173export function SwitchSetting({
174 atom,
175 title,
176 description,
177}: {
178 atom: typeof enableBitesAtom;
179 title?: string;
180 description?: string;
181}) {
182 const value = useAtomValue(atom);
183 const setValue = useSetAtom(atom);
184
185 const [hydrated, setHydrated] = useState(false);
186 // eslint-disable-next-line react-hooks/set-state-in-effect
187 useEffect(() => setHydrated(true), []);
188
189 if (!hydrated) {
190 // Avoid rendering Switch until we know storage is loaded
191 return null;
192 }
193
194 return (
195 <div className="flex items-center gap-4 px-4 ">
196 <label htmlFor={`switch-${title}`} className="flex flex-row flex-1">
197 <div className="flex flex-col">
198 <span className="text-md">{title}</span>
199 <span className="text-sm text-gray-500 dark:text-gray-400">
200 {description}
201 </span>
202 </div>
203 </label>
204
205 <Switch.Root
206 id={`switch-${title}`}
207 checked={value}
208 onCheckedChange={(v) => setValue(v)}
209 className="m3switch root"
210 >
211 <Switch.Thumb className="m3switch thumb " />
212 </Switch.Root>
213 </div>
214 );
215}
216
217function Hue() {
218 const [hue, setHue] = useAtom(hueAtom);
219 return (
220 <div className="flex flex-col px-4">
221 <span className="z-[2] text-md">Hue</span>
222 <span className="z-[2] text-sm text-gray-500 dark:text-gray-400">
223 Change the colors of the app
224 </span>
225 <div className="z-[1] flex flex-row items-center gap-4">
226 <SliderComponent atom={hueAtom} max={360} />
227 <button
228 onClick={() => setHue(defaulthue ?? 28)}
229 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
230 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
231 >
232 Reset
233 </button>
234 </div>
235 </div>
236 );
237}
238
239export function TextInputSetting({
240 atom,
241 title,
242 description,
243 init,
244}: {
245 atom: typeof constellationURLAtom;
246 title?: string;
247 description?: string;
248 init?: string;
249}) {
250 const [value, setValue] = useAtom(atom);
251 return (
252 <div className="flex flex-col gap-2 px-4 py-2">
253 {/* <div>
254 {title && (
255 <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
256 {title}
257 </h3>
258 )}
259 {description && (
260 <p className="text-sm text-gray-500 dark:text-gray-400">
261 {description}
262 </p>
263 )}
264 </div> */}
265
266 <div className="flex flex-row gap-2 items-center">
267 <div className="m3input-field m3input-label m3input-border size-md flex-1">
268 <input
269 type="text"
270 placeholder=" "
271 value={value}
272 onChange={(e) => setValue(e.target.value)}
273 />
274 <label>{title}</label>
275 </div>
276 {/* <input
277 type="text"
278 value={value}
279 onChange={(e) => setValue(e.target.value)}
280 className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700
281 text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
282 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
283 placeholder="Enter value..."
284 /> */}
285 <button
286 onClick={() => setValue(init ?? "")}
287 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
288 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
289 >
290 Reset
291 </button>
292 </div>
293 </div>
294 );
295}
296
297interface SliderProps {
298 atom: typeof hueAtom;
299 min?: number;
300 max?: number;
301 step?: number;
302}
303
304export const SliderComponent: React.FC<SliderProps> = ({
305 atom,
306 min = 0,
307 max = 100,
308 step = 1,
309}) => {
310 const [value, setValue] = useAtom(atom);
311
312 return (
313 <Slider.Root
314 className="relative flex items-center w-full h-4"
315 value={[value]}
316 min={min}
317 max={max}
318 step={step}
319 onValueChange={(v: number[]) => setValue(v[0])}
320 >
321 <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
322 <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
323 </Slider.Track>
324 <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" />
325 </Slider.Root>
326 );
327};
328
329
330interface SliderPProps {
331 value: number;
332 min?: number;
333 max?: number;
334 step?: number;
335}
336
337
338export const SliderPrimitive: React.FC<SliderPProps> = ({
339 value,
340 min = 0,
341 max = 100,
342 step = 1,
343}) => {
344
345 return (
346 <Slider.Root
347 className="relative flex items-center w-full h-4"
348 value={[value]}
349 min={min}
350 max={max}
351 step={step}
352 onValueChange={(v: number[]) => {}}
353 >
354 <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
355 <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
356 </Slider.Track>
357 <Slider.Thumb className=" hidden 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" />
358 </Slider.Root>
359 );
360};