tangled
alpha
login
or
join now
dabric.xyz
/
red-dwarf
forked from
whey.party/red-dwarf
an appview-less Bluesky client using Constellation and PDS Queries - https://reddwarf.app/
0
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
tanstack-virtual
main
layout
button
aturilistservice.reddwarf.app
no tags found
compare:
tanstack-virtual
main
layout
button
aturilistservice.reddwarf.app
no tags found
go
+242
-135
5 changed files
expand all
collapse all
unified
split
src
components
Composer.tsx
Login.tsx
UniversalPostRenderer.tsx
radix-m3-rd
Button.tsx
routes
profile.$did
index.tsx
+168
-121
src/components/Composer.tsx
···
8
8
import { useQueryPost } from "~/utils/useQuery";
9
9
10
10
import { ProfileThing } from "./Login";
11
11
+
import { Button } from "./radix-m3-rd/Button";
11
12
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
13
13
14
const MAX_POST_LENGTH = 300;
14
15
15
16
export function Composer() {
16
17
const [composerState, setComposerState] = useAtom(composerAtom);
18
18
+
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
17
19
const { agent } = useAuth();
18
20
19
21
const [postText, setPostText] = useState("");
···
112
114
setPosting(false);
113
115
}
114
116
}
115
115
-
// if (composerState.kind === "closed") {
116
116
-
// return null;
117
117
-
// }
118
117
119
118
const getPlaceholder = () => {
120
119
switch (composerState.kind) {
···
132
131
const isPostButtonDisabled =
133
132
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
133
134
134
+
function handleAttemptClose() {
135
135
+
if (postText.trim() && !posting) {
136
136
+
setCloseConfirmState(true);
137
137
+
} else {
138
138
+
setComposerState({ kind: "closed" });
139
139
+
}
140
140
+
}
141
141
+
142
142
+
function handleConfirmClose() {
143
143
+
setComposerState({ kind: "closed" });
144
144
+
setCloseConfirmState(false);
145
145
+
setPostText("");
146
146
+
}
147
147
+
135
148
return (
136
136
-
<Dialog.Root
137
137
-
open={composerState.kind !== "closed"}
138
138
-
onOpenChange={(open) => {
139
139
-
if (!open) setComposerState({ kind: "closed" });
140
140
-
}}
141
141
-
>
142
142
-
<Dialog.Portal>
143
143
-
<Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
144
144
-
145
145
-
<Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
146
146
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
147
147
-
<div className="flex flex-row justify-between p-2">
148
148
-
<Dialog.Close asChild>
149
149
-
<button
150
150
-
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
151
151
-
disabled={posting}
152
152
-
aria-label="Close"
153
153
-
>
154
154
-
<svg
155
155
-
xmlns="http://www.w3.org/2000/svg"
156
156
-
width="20"
157
157
-
height="20"
158
158
-
viewBox="0 0 24 24"
159
159
-
fill="none"
160
160
-
stroke="currentColor"
161
161
-
strokeWidth="2.5"
162
162
-
strokeLinecap="round"
163
163
-
strokeLinejoin="round"
149
149
+
<>
150
150
+
<Dialog.Root
151
151
+
open={composerState.kind !== "closed"}
152
152
+
onOpenChange={(open) => {
153
153
+
if (!open) handleAttemptClose();
154
154
+
}}
155
155
+
>
156
156
+
<Dialog.Portal>
157
157
+
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
158
158
+
159
159
+
<Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
160
160
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
161
161
+
<div className="flex flex-row justify-between p-2">
162
162
+
<Dialog.Close asChild>
163
163
+
<button
164
164
+
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
165
165
+
disabled={posting}
166
166
+
aria-label="Close"
167
167
+
onClick={handleAttemptClose}
164
168
>
165
165
-
<line x1="18" y1="6" x2="6" y2="18"></line>
166
166
-
<line x1="6" y1="6" x2="18" y2="18"></line>
167
167
-
</svg>
168
168
-
</button>
169
169
-
</Dialog.Close>
170
170
-
171
171
-
<div className="flex-1" />
172
172
-
<div className="flex items-center gap-4">
173
173
-
<span
174
174
-
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
175
175
-
>
176
176
-
{charsLeft}
177
177
-
</span>
178
178
-
<button
179
179
-
className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
180
180
-
onClick={handlePost}
181
181
-
disabled={isPostButtonDisabled}
182
182
-
>
183
183
-
{posting ? "Posting..." : "Post"}
184
184
-
</button>
169
169
+
<svg
170
170
+
xmlns="http://www.w3.org/2000/svg"
171
171
+
width="20"
172
172
+
height="20"
173
173
+
viewBox="0 0 24 24"
174
174
+
fill="none"
175
175
+
stroke="currentColor"
176
176
+
strokeWidth="2.5"
177
177
+
strokeLinecap="round"
178
178
+
strokeLinejoin="round"
179
179
+
>
180
180
+
<line x1="18" y1="6" x2="6" y2="18"></line>
181
181
+
<line x1="6" y1="6" x2="18" y2="18"></line>
182
182
+
</svg>
183
183
+
</button>
184
184
+
</Dialog.Close>
185
185
+
186
186
+
<div className="flex-1" />
187
187
+
<div className="flex items-center gap-4">
188
188
+
<span
189
189
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
190
190
+
>
191
191
+
{charsLeft}
192
192
+
</span>
193
193
+
<Button
194
194
+
onClick={handlePost}
195
195
+
disabled={isPostButtonDisabled}
196
196
+
>
197
197
+
{posting ? "Posting..." : "Post"}
198
198
+
</Button>
199
199
+
</div>
185
200
</div>
186
186
-
</div>
187
201
188
188
-
{postSuccess ? (
189
189
-
<div className="flex flex-col items-center justify-center py-16">
190
190
-
<span className="text-gray-500 text-6xl mb-4">โ</span>
191
191
-
<span className="text-xl font-bold text-black dark:text-white">
192
192
-
Posted!
193
193
-
</span>
194
194
-
</div>
195
195
-
) : (
196
196
-
<div className="px-4">
197
197
-
{composerState.kind === "reply" && (
198
198
-
<div className="mb-1 -mx-4">
199
199
-
{isParentLoading ? (
200
200
-
<div className="text-sm text-gray-500 animate-pulse">
201
201
-
Loading parent post...
202
202
-
</div>
203
203
-
) : parentUri ? (
204
204
-
<UniversalPostRendererATURILoader
205
205
-
atUri={parentUri}
206
206
-
bottomReplyLine
207
207
-
bottomBorder={false}
208
208
-
/>
209
209
-
) : (
210
210
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
211
211
-
Could not load parent post.
212
212
-
</div>
213
213
-
)}
214
214
-
</div>
215
215
-
)}
216
216
-
217
217
-
<div className="flex w-full gap-1 flex-col">
218
218
-
<ProfileThing agent={agent} large />
219
219
-
<div className="flex pl-[50px]">
220
220
-
<AutoGrowTextarea
221
221
-
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
222
222
-
rows={5}
223
223
-
placeholder={getPlaceholder()}
224
224
-
value={postText}
225
225
-
onChange={(e) => setPostText(e.target.value)}
226
226
-
disabled={posting}
227
227
-
autoFocus
228
228
-
/>
229
229
-
</div>
202
202
+
{postSuccess ? (
203
203
+
<div className="flex flex-col items-center justify-center py-16">
204
204
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
205
205
+
<span className="text-xl font-bold text-black dark:text-white">
206
206
+
Posted!
207
207
+
</span>
230
208
</div>
231
231
-
232
232
-
{composerState.kind === "quote" && (
233
233
-
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
234
234
-
{isParentLoading ? (
235
235
-
<div className="text-sm text-gray-500 animate-pulse">
236
236
-
Loading parent post...
237
237
-
</div>
238
238
-
) : parentUri ? (
239
239
-
<UniversalPostRendererATURILoader
240
240
-
atUri={parentUri}
241
241
-
isQuote
209
209
+
) : (
210
210
+
<div className="px-4">
211
211
+
{composerState.kind === "reply" && (
212
212
+
<div className="mb-1 -mx-4">
213
213
+
{isParentLoading ? (
214
214
+
<div className="text-sm text-gray-500 animate-pulse">
215
215
+
Loading parent post...
216
216
+
</div>
217
217
+
) : parentUri ? (
218
218
+
<UniversalPostRendererATURILoader
219
219
+
atUri={parentUri}
220
220
+
bottomReplyLine
221
221
+
bottomBorder={false}
222
222
+
/>
223
223
+
) : (
224
224
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
225
225
+
Could not load parent post.
226
226
+
</div>
227
227
+
)}
228
228
+
</div>
229
229
+
)}
230
230
+
231
231
+
<div className="flex w-full gap-1 flex-col">
232
232
+
<ProfileThing agent={agent} large />
233
233
+
<div className="flex pl-[50px]">
234
234
+
<AutoGrowTextarea
235
235
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
236
236
+
rows={5}
237
237
+
placeholder={getPlaceholder()}
238
238
+
value={postText}
239
239
+
onChange={(e) => setPostText(e.target.value)}
240
240
+
disabled={posting}
241
241
+
autoFocus
242
242
/>
243
243
-
) : (
244
244
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
245
245
-
Could not load parent post.
246
246
-
</div>
247
247
-
)}
243
243
+
</div>
248
244
</div>
249
249
-
)}
250
245
251
251
-
{postError && (
252
252
-
<div className="text-red-500 text-sm my-2 text-center">
253
253
-
{postError}
254
254
-
</div>
255
255
-
)}
246
246
+
{composerState.kind === "quote" && (
247
247
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
248
248
+
{isParentLoading ? (
249
249
+
<div className="text-sm text-gray-500 animate-pulse">
250
250
+
Loading parent post...
251
251
+
</div>
252
252
+
) : parentUri ? (
253
253
+
<UniversalPostRendererATURILoader
254
254
+
atUri={parentUri}
255
255
+
isQuote
256
256
+
/>
257
257
+
) : (
258
258
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
259
259
+
Could not load parent post.
260
260
+
</div>
261
261
+
)}
262
262
+
</div>
263
263
+
)}
264
264
+
265
265
+
{postError && (
266
266
+
<div className="text-red-500 text-sm my-2 text-center">
267
267
+
{postError}
268
268
+
</div>
269
269
+
)}
270
270
+
</div>
271
271
+
)}
272
272
+
</div>
273
273
+
</Dialog.Content>
274
274
+
</Dialog.Portal>
275
275
+
</Dialog.Root>
276
276
+
277
277
+
{/* Close confirmation dialog */}
278
278
+
<Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}>
279
279
+
<Dialog.Portal>
280
280
+
281
281
+
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
282
282
+
283
283
+
<Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40">
284
284
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6">
285
285
+
<div className="text-xl mb-4 text-center">
286
286
+
Discard your post?
287
287
+
</div>
288
288
+
<div className="text-md mb-4 text-center">
289
289
+
You will lose your draft
290
290
+
</div>
291
291
+
<div className="flex justify-end gap-2 px-6">
292
292
+
<Button
293
293
+
onClick={handleConfirmClose}
294
294
+
>
295
295
+
Discard
296
296
+
</Button>
297
297
+
<Button
298
298
+
variant={"outlined"}
299
299
+
onClick={() => setCloseConfirmState(false)}
300
300
+
>
301
301
+
Cancel
302
302
+
</Button>
256
303
</div>
257
257
-
)}
258
258
-
</div>
259
259
-
</Dialog.Content>
260
260
-
</Dialog.Portal>
261
261
-
</Dialog.Root>
304
304
+
</div>
305
305
+
</Dialog.Content>
306
306
+
</Dialog.Portal>
307
307
+
</Dialog.Root>
308
308
+
</>
262
309
);
263
310
}
264
311
+5
-3
src/components/Login.tsx
···
7
7
import { imgCDNAtom } from "~/utils/atoms";
8
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
9
10
10
+
import { Button } from "./radix-m3-rd/Button";
11
11
+
10
12
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
11
13
export default function Login({
12
14
compact = false,
···
49
51
You are logged in!
50
52
</p>
51
53
<ProfileThing agent={agent} large />
52
52
-
<button
54
54
+
<Button
53
55
onClick={logout}
54
54
-
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors"
56
56
+
className="mt-4"
55
57
>
56
58
Log out
57
57
-
</button>
59
59
+
</Button>
58
60
</div>
59
61
</div>
60
62
);
+2
src/components/UniversalPostRenderer.tsx
···
1557
1557
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
1558
/>
1559
1559
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
1560
+
<div className=" flex flex-col justify-start">
1560
1561
<FollowButton targetdidorhandle={post.author.did} />
1561
1562
</div>
1563
1563
+
</div>
1562
1564
</div>
1563
1565
<div className="flex flex-col gap-3">
1564
1566
<div>
+59
src/components/radix-m3-rd/Button.tsx
···
1
1
+
import { Slot } from "@radix-ui/react-slot";
2
2
+
import clsx from "clsx";
3
3
+
import * as React from "react";
4
4
+
5
5
+
export type ButtonVariant = "filled" | "outlined" | "text" | "secondary";
6
6
+
export type ButtonSize = "sm" | "md" | "lg";
7
7
+
8
8
+
const variantClasses: Record<ButtonVariant, string> = {
9
9
+
filled:
10
10
+
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
11
11
+
secondary:
12
12
+
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
13
13
+
outlined:
14
14
+
"border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10",
15
15
+
text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10",
16
16
+
};
17
17
+
18
18
+
const sizeClasses: Record<ButtonSize, string> = {
19
19
+
sm: "px-3 py-1.5 text-sm",
20
20
+
md: "px-4 py-2 text-base",
21
21
+
lg: "px-6 py-3 text-lg",
22
22
+
};
23
23
+
24
24
+
export function Button({
25
25
+
variant = "filled",
26
26
+
size = "md",
27
27
+
asChild = false,
28
28
+
ref,
29
29
+
className,
30
30
+
children,
31
31
+
...props
32
32
+
}: {
33
33
+
variant?: ButtonVariant;
34
34
+
size?: ButtonSize;
35
35
+
asChild?: boolean;
36
36
+
className?: string;
37
37
+
children?: React.ReactNode;
38
38
+
ref?: React.Ref<HTMLButtonElement>;
39
39
+
} & React.ComponentPropsWithoutRef<"button">) {
40
40
+
const Comp = asChild ? Slot : "button";
41
41
+
42
42
+
return (
43
43
+
<Comp
44
44
+
ref={ref}
45
45
+
className={clsx(
46
46
+
//focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300
47
47
+
"inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
48
48
+
variantClasses[variant],
49
49
+
sizeClasses[size],
50
50
+
className
51
51
+
)}
52
52
+
{...props}
53
53
+
>
54
54
+
{children}
55
55
+
</Comp>
56
56
+
);
57
57
+
}
58
58
+
59
59
+
Button.displayName = "Button";
+8
-11
src/routes/profile.$did/index.tsx
···
5
5
import React, { type ReactNode, useEffect, useState } from "react";
6
6
7
7
import { Header } from "~/components/Header";
8
8
+
import { Button } from "~/components/radix-m3-rd/Button";
8
9
import {
9
10
renderTextWithFacets,
10
11
UniversalPostRendererATURILoader,
···
170
171
also save it persistently
171
172
*/}
172
173
<FollowButton targetdidorhandle={did} />
173
173
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
174
174
+
<Button className="rounded-full" variant={"secondary"}>
174
175
... {/* todo: icon */}
175
175
-
</button>
176
176
+
</Button>
176
177
</div>
177
178
178
179
{/* Info Card */}
···
248
249
{identity?.did !== agent?.did ? (
249
250
<>
250
251
{!(followRecords?.length && followRecords?.length > 0) ? (
251
251
-
<button
252
252
+
<Button
252
253
onClick={(e) => {
253
254
e.stopPropagation();
254
255
toggleFollow({
···
258
259
queryClient: queryClient,
259
260
});
260
261
}}
261
261
-
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
262
262
>
263
263
Follow
264
264
-
</button>
264
264
+
</Button>
265
265
) : (
266
266
-
<button
266
266
+
<Button
267
267
onClick={(e) => {
268
268
e.stopPropagation();
269
269
toggleFollow({
···
273
273
queryClient: queryClient,
274
274
});
275
275
}}
276
276
-
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
277
276
>
278
277
Unfollow
279
279
-
</button>
278
278
+
</Button>
280
279
)}
281
280
</>
282
281
) : (
283
283
-
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
284
284
-
Edit Profile
285
285
-
</button>
282
282
+
<Button variant={"secondary"}>Edit Profile</Button>
286
283
)}
287
284
</>
288
285
);