tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
more material 3 tweaks
rimar1337
4 months ago
91e90cba
fb3fbe80
+109
-37
8 changed files
expand all
collapse all
unified
split
src
components
Header.tsx
InfiniteCustomFeed.tsx
UniversalPostRenderer.tsx
main.tsx
routes
__root.tsx
index.tsx
styles
app.css
utils
atoms.ts
+5
-1
src/components/Header.tsx
···
1
import { Link, useRouter } from "@tanstack/react-router";
0
0
0
2
3
export function Header({
4
backButtonCallback,
···
8
title?: string;
9
}) {
10
const router = useRouter();
0
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>
0
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();
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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,
0
13
selectedFeedUriAtom,
14
store,
15
} from "~/utils/atoms";
···
350
authed && agent && identity?.pds && feedServiceDid;
351
const isReadyForUnauthedFeed = !authed && selectedFeed;
352
0
0
0
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;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
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);