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