create-fate 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -0
  3. package/bin/create-fate.mjs +412 -0
  4. package/package.json +26 -3
  5. package/templates/fate/README.md +8 -0
  6. package/templates/fate/drizzle/.github/workflows/push.yml +27 -0
  7. package/templates/fate/drizzle/.oxfmtrc.jsonc +23 -0
  8. package/templates/fate/drizzle/.vite-hooks/pre-commit +1 -0
  9. package/templates/fate/drizzle/.vscode/extensions.json +12 -0
  10. package/templates/fate/drizzle/.vscode/settings.json +25 -0
  11. package/templates/fate/drizzle/AGENTS.md +55 -0
  12. package/templates/fate/drizzle/README.md +112 -0
  13. package/templates/fate/drizzle/_gitignore +20 -0
  14. package/templates/fate/drizzle/client/package.json +58 -0
  15. package/templates/fate/drizzle/client/pages/index.tsx +96 -0
  16. package/templates/fate/drizzle/client/pages/layout.tsx +104 -0
  17. package/templates/fate/drizzle/client/pages/login.tsx +107 -0
  18. package/templates/fate/drizzle/client/pages/post/[id].tsx +22 -0
  19. package/templates/fate/drizzle/client/pages/search.tsx +95 -0
  20. package/templates/fate/drizzle/client/public/icon.svg +1 -0
  21. package/templates/fate/drizzle/client/src/App.css +153 -0
  22. package/templates/fate/drizzle/client/src/lib/AvailableLanguages.tsx +5 -0
  23. package/templates/fate/drizzle/client/src/lib/cx.tsx +6 -0
  24. package/templates/fate/drizzle/client/src/lib/env.tsx +5 -0
  25. package/templates/fate/drizzle/client/src/ui/Badge.tsx +31 -0
  26. package/templates/fate/drizzle/client/src/ui/Button.tsx +108 -0
  27. package/templates/fate/drizzle/client/src/ui/Card.tsx +32 -0
  28. package/templates/fate/drizzle/client/src/ui/CommentCard.tsx +75 -0
  29. package/templates/fate/drizzle/client/src/ui/Error.tsx +19 -0
  30. package/templates/fate/drizzle/client/src/ui/H2.tsx +15 -0
  31. package/templates/fate/drizzle/client/src/ui/H3.tsx +20 -0
  32. package/templates/fate/drizzle/client/src/ui/Header.tsx +96 -0
  33. package/templates/fate/drizzle/client/src/ui/Input.tsx +17 -0
  34. package/templates/fate/drizzle/client/src/ui/Link.tsx +11 -0
  35. package/templates/fate/drizzle/client/src/ui/LocaleSwitcher.tsx +52 -0
  36. package/templates/fate/drizzle/client/src/ui/PostCard.tsx +284 -0
  37. package/templates/fate/drizzle/client/src/ui/Section.tsx +23 -0
  38. package/templates/fate/drizzle/client/src/ui/UserCard.tsx +131 -0
  39. package/templates/fate/drizzle/client/src/user/AuthClient.tsx +6 -0
  40. package/templates/fate/drizzle/client/translations/de_DE.json +689 -0
  41. package/templates/fate/drizzle/client/translations/ja_JP.json +689 -0
  42. package/templates/fate/drizzle/client/vite.config.ts +39 -0
  43. package/templates/fate/drizzle/client/void.json +29 -0
  44. package/templates/fate/drizzle/docker-compose.yml +11 -0
  45. package/templates/fate/drizzle/package.json +46 -0
  46. package/templates/fate/drizzle/pnpm-workspace.yaml +36 -0
  47. package/templates/fate/drizzle/public/fate-logo-dark.svg +4 -0
  48. package/templates/fate/drizzle/public/fate-logo.svg +4 -0
  49. package/templates/fate/drizzle/server/.env.example +5 -0
  50. package/templates/fate/drizzle/server/drizzle.config.ts +11 -0
  51. package/templates/fate/drizzle/server/package.json +48 -0
  52. package/templates/fate/drizzle/server/src/__tests__/index.test.tsx +5 -0
  53. package/templates/fate/drizzle/server/src/app.tsx +44 -0
  54. package/templates/fate/drizzle/server/src/drizzle/db.ts +25 -0
  55. package/templates/fate/drizzle/server/src/drizzle/queries.ts +89 -0
  56. package/templates/fate/drizzle/server/src/drizzle/schema.ts +174 -0
  57. package/templates/fate/drizzle/server/src/drizzle/seed.tsx +77 -0
  58. package/templates/fate/drizzle/server/src/drizzle/seedData.ts +205 -0
  59. package/templates/fate/drizzle/server/src/index.tsx +24 -0
  60. package/templates/fate/drizzle/server/src/lib/auth.ts +33 -0
  61. package/templates/fate/drizzle/server/src/lib/env.ts +3 -0
  62. package/templates/fate/drizzle/server/src/lib/origins.ts +18 -0
  63. package/templates/fate/drizzle/server/src/router.ts +15 -0
  64. package/templates/fate/drizzle/server/src/trpc/connection.ts +4 -0
  65. package/templates/fate/drizzle/server/src/trpc/context.ts +22 -0
  66. package/templates/fate/drizzle/server/src/trpc/init.ts +51 -0
  67. package/templates/fate/drizzle/server/src/trpc/routers/comment.ts +146 -0
  68. package/templates/fate/drizzle/server/src/trpc/routers/post.ts +99 -0
  69. package/templates/fate/drizzle/server/src/trpc/routers/user.ts +68 -0
  70. package/templates/fate/drizzle/server/src/trpc/views.ts +75 -0
  71. package/templates/fate/drizzle/server/src/user/SessionUser.tsx +17 -0
  72. package/templates/fate/drizzle/server/vite.config.ts +8 -0
  73. package/templates/fate/drizzle/tsconfig.json +33 -0
  74. package/templates/fate/drizzle/vite.config.ts +71 -0
  75. package/templates/fate/http/.github/workflows/push.yml +27 -0
  76. package/templates/fate/http/.oxfmtrc.jsonc +23 -0
  77. package/templates/fate/http/.vite-hooks/pre-commit +1 -0
  78. package/templates/fate/http/.vscode/extensions.json +12 -0
  79. package/templates/fate/http/.vscode/settings.json +25 -0
  80. package/templates/fate/http/AGENTS.md +55 -0
  81. package/templates/fate/http/README.md +112 -0
  82. package/templates/fate/http/_gitignore +20 -0
  83. package/templates/fate/http/client/package.json +56 -0
  84. package/templates/fate/http/client/pages/index.tsx +96 -0
  85. package/templates/fate/http/client/pages/layout.tsx +89 -0
  86. package/templates/fate/http/client/pages/login.tsx +107 -0
  87. package/templates/fate/http/client/pages/post/[id].tsx +22 -0
  88. package/templates/fate/http/client/pages/search.tsx +95 -0
  89. package/templates/fate/http/client/public/icon.svg +1 -0
  90. package/templates/fate/http/client/src/App.css +153 -0
  91. package/templates/fate/http/client/src/lib/AvailableLanguages.tsx +5 -0
  92. package/templates/fate/http/client/src/lib/cx.tsx +6 -0
  93. package/templates/fate/http/client/src/lib/env.tsx +5 -0
  94. package/templates/fate/http/client/src/ui/Badge.tsx +31 -0
  95. package/templates/fate/http/client/src/ui/Button.tsx +108 -0
  96. package/templates/fate/http/client/src/ui/Card.tsx +32 -0
  97. package/templates/fate/http/client/src/ui/CommentCard.tsx +75 -0
  98. package/templates/fate/http/client/src/ui/Error.tsx +19 -0
  99. package/templates/fate/http/client/src/ui/H2.tsx +15 -0
  100. package/templates/fate/http/client/src/ui/H3.tsx +20 -0
  101. package/templates/fate/http/client/src/ui/Header.tsx +96 -0
  102. package/templates/fate/http/client/src/ui/Input.tsx +17 -0
  103. package/templates/fate/http/client/src/ui/Link.tsx +11 -0
  104. package/templates/fate/http/client/src/ui/LocaleSwitcher.tsx +52 -0
  105. package/templates/fate/http/client/src/ui/PostCard.tsx +281 -0
  106. package/templates/fate/http/client/src/ui/Section.tsx +23 -0
  107. package/templates/fate/http/client/src/ui/UserCard.tsx +131 -0
  108. package/templates/fate/http/client/src/user/AuthClient.tsx +6 -0
  109. package/templates/fate/http/client/translations/de_DE.json +689 -0
  110. package/templates/fate/http/client/translations/ja_JP.json +689 -0
  111. package/templates/fate/http/client/vite.config.ts +40 -0
  112. package/templates/fate/http/client/void.json +29 -0
  113. package/templates/fate/http/docker-compose.yml +11 -0
  114. package/templates/fate/http/package.json +46 -0
  115. package/templates/fate/http/pnpm-workspace.yaml +36 -0
  116. package/templates/fate/http/public/fate-logo-dark.svg +4 -0
  117. package/templates/fate/http/public/fate-logo.svg +4 -0
  118. package/templates/fate/http/server/.env.example +5 -0
  119. package/templates/fate/http/server/drizzle.config.ts +11 -0
  120. package/templates/fate/http/server/package.json +46 -0
  121. package/templates/fate/http/server/src/__tests__/index.test.tsx +5 -0
  122. package/templates/fate/http/server/src/app.tsx +36 -0
  123. package/templates/fate/http/server/src/drizzle/db.ts +25 -0
  124. package/templates/fate/http/server/src/drizzle/queries.ts +89 -0
  125. package/templates/fate/http/server/src/drizzle/schema.ts +174 -0
  126. package/templates/fate/http/server/src/drizzle/seed.tsx +77 -0
  127. package/templates/fate/http/server/src/drizzle/seedData.ts +205 -0
  128. package/templates/fate/http/server/src/fate/context.ts +20 -0
  129. package/templates/fate/http/server/src/fate/server.ts +285 -0
  130. package/templates/fate/http/server/src/fate/views.ts +75 -0
  131. package/templates/fate/http/server/src/index.tsx +24 -0
  132. package/templates/fate/http/server/src/lib/auth.ts +33 -0
  133. package/templates/fate/http/server/src/lib/env.ts +3 -0
  134. package/templates/fate/http/server/src/lib/origins.ts +18 -0
  135. package/templates/fate/http/server/src/router.ts +2 -0
  136. package/templates/fate/http/server/src/user/SessionUser.tsx +17 -0
  137. package/templates/fate/http/server/vite.config.ts +8 -0
  138. package/templates/fate/http/tsconfig.json +33 -0
  139. package/templates/fate/http/vite.config.ts +71 -0
  140. package/templates/fate/prisma/.github/workflows/push.yml +27 -0
  141. package/templates/fate/prisma/.oxfmtrc.jsonc +23 -0
  142. package/templates/fate/prisma/.vite-hooks/pre-commit +1 -0
  143. package/templates/fate/prisma/.vscode/extensions.json +12 -0
  144. package/templates/fate/prisma/.vscode/settings.json +25 -0
  145. package/templates/fate/prisma/AGENTS.md +55 -0
  146. package/templates/fate/prisma/README.md +126 -0
  147. package/templates/fate/prisma/_gitignore +20 -0
  148. package/templates/fate/prisma/client/package.json +58 -0
  149. package/templates/fate/prisma/client/pages/index.tsx +96 -0
  150. package/templates/fate/prisma/client/pages/layout.tsx +94 -0
  151. package/templates/fate/prisma/client/pages/login.tsx +107 -0
  152. package/templates/fate/prisma/client/pages/post/[id].tsx +22 -0
  153. package/templates/fate/prisma/client/pages/search.tsx +95 -0
  154. package/templates/fate/prisma/client/public/icon.svg +1 -0
  155. package/templates/fate/prisma/client/src/App.css +153 -0
  156. package/templates/fate/prisma/client/src/lib/AvailableLanguages.tsx +5 -0
  157. package/templates/fate/prisma/client/src/lib/cx.tsx +6 -0
  158. package/templates/fate/prisma/client/src/lib/env.tsx +5 -0
  159. package/templates/fate/prisma/client/src/ui/Badge.tsx +31 -0
  160. package/templates/fate/prisma/client/src/ui/Button.tsx +108 -0
  161. package/templates/fate/prisma/client/src/ui/Card.tsx +32 -0
  162. package/templates/fate/prisma/client/src/ui/CommentCard.tsx +75 -0
  163. package/templates/fate/prisma/client/src/ui/Error.tsx +19 -0
  164. package/templates/fate/prisma/client/src/ui/H2.tsx +15 -0
  165. package/templates/fate/prisma/client/src/ui/H3.tsx +20 -0
  166. package/templates/fate/prisma/client/src/ui/Header.tsx +96 -0
  167. package/templates/fate/prisma/client/src/ui/Input.tsx +17 -0
  168. package/templates/fate/prisma/client/src/ui/Link.tsx +11 -0
  169. package/templates/fate/prisma/client/src/ui/LocaleSwitcher.tsx +52 -0
  170. package/templates/fate/prisma/client/src/ui/PostCard.tsx +281 -0
  171. package/templates/fate/prisma/client/src/ui/Section.tsx +23 -0
  172. package/templates/fate/prisma/client/src/ui/UserCard.tsx +131 -0
  173. package/templates/fate/prisma/client/src/user/AuthClient.tsx +6 -0
  174. package/templates/fate/prisma/client/translations/de_DE.json +689 -0
  175. package/templates/fate/prisma/client/translations/ja_JP.json +689 -0
  176. package/templates/fate/prisma/client/vite.config.ts +39 -0
  177. package/templates/fate/prisma/client/void.json +29 -0
  178. package/templates/fate/prisma/docker-compose.yml +11 -0
  179. package/templates/fate/prisma/package.json +47 -0
  180. package/templates/fate/prisma/pnpm-workspace.yaml +39 -0
  181. package/templates/fate/prisma/public/fate-logo-dark.svg +4 -0
  182. package/templates/fate/prisma/public/fate-logo.svg +4 -0
  183. package/templates/fate/prisma/server/.env.example +5 -0
  184. package/templates/fate/prisma/server/package.json +42 -0
  185. package/templates/fate/prisma/server/prisma.config.ts +21 -0
  186. package/templates/fate/prisma/server/src/__tests__/index.test.tsx +5 -0
  187. package/templates/fate/prisma/server/src/app.tsx +40 -0
  188. package/templates/fate/prisma/server/src/index.tsx +24 -0
  189. package/templates/fate/prisma/server/src/lib/auth.ts +31 -0
  190. package/templates/fate/prisma/server/src/lib/env.ts +3 -0
  191. package/templates/fate/prisma/server/src/lib/origins.ts +18 -0
  192. package/templates/fate/prisma/server/src/prisma/prisma.ts +9 -0
  193. package/templates/fate/prisma/server/src/prisma/schema.prisma +98 -0
  194. package/templates/fate/prisma/server/src/prisma/seed.tsx +71 -0
  195. package/templates/fate/prisma/server/src/prisma/seedData.ts +205 -0
  196. package/templates/fate/prisma/server/src/router.ts +14 -0
  197. package/templates/fate/prisma/server/src/trpc/connection.ts +4 -0
  198. package/templates/fate/prisma/server/src/trpc/context.ts +22 -0
  199. package/templates/fate/prisma/server/src/trpc/init.ts +8 -0
  200. package/templates/fate/prisma/server/src/trpc/routers/comment.ts +176 -0
  201. package/templates/fate/prisma/server/src/trpc/routers/post.ts +154 -0
  202. package/templates/fate/prisma/server/src/trpc/routers/user.ts +71 -0
  203. package/templates/fate/prisma/server/src/trpc/views.ts +77 -0
  204. package/templates/fate/prisma/server/src/user/SessionUser.tsx +17 -0
  205. package/templates/fate/prisma/server/vite.config.ts +8 -0
  206. package/templates/fate/prisma/tsconfig.json +33 -0
  207. package/templates/fate/prisma/vite.config.ts +72 -0
  208. package/templates/fate/void/.oxfmtrc.jsonc +14 -0
  209. package/templates/fate/void/README.md +55 -0
  210. package/templates/fate/void/auth.ts +22 -0
  211. package/templates/fate/void/db/migrations/20260508073357_initial_schema.sql +133 -0
  212. package/templates/fate/void/db/migrations/20260508120500_seed_void_demo.sql +170 -0
  213. package/templates/fate/void/db/migrations/meta/20260508073357_snapshot.json +868 -0
  214. package/templates/fate/void/db/migrations/meta/_journal.json +20 -0
  215. package/templates/fate/void/db/queries.ts +211 -0
  216. package/templates/fate/void/db/schema.ts +325 -0
  217. package/templates/fate/void/db/seed-auth.ts +31 -0
  218. package/templates/fate/void/db/seed.ts +163 -0
  219. package/templates/fate/void/middleware/01.shared.ts +29 -0
  220. package/templates/fate/void/package.json +65 -0
  221. package/templates/fate/void/pages/category/[id].tsx +23 -0
  222. package/templates/fate/void/pages/index.tsx +153 -0
  223. package/templates/fate/void/pages/layout.tsx +46 -0
  224. package/templates/fate/void/pages/login.tsx +103 -0
  225. package/templates/fate/void/pages/post/[id].tsx +20 -0
  226. package/templates/fate/void/pages/search.tsx +81 -0
  227. package/templates/fate/void/pnpm-workspace.yaml +31 -0
  228. package/templates/fate/void/public/.assetsignore +1 -0
  229. package/templates/fate/void/public/icon.svg +1 -0
  230. package/templates/fate/void/routes/fate-live.ts +8 -0
  231. package/templates/fate/void/routes/fate.ts +8 -0
  232. package/templates/fate/void/seedData.ts +535 -0
  233. package/templates/fate/void/src/App.css +130 -0
  234. package/templates/fate/void/src/fate/__tests__/commentPermissions.test.ts +31 -0
  235. package/templates/fate/void/src/fate/commentPermissions.ts +17 -0
  236. package/templates/fate/void/src/fate/context.ts +15 -0
  237. package/templates/fate/void/src/fate/live.ts +6 -0
  238. package/templates/fate/void/src/fate/server.ts +401 -0
  239. package/templates/fate/void/src/fate/views.ts +164 -0
  240. package/templates/fate/void/src/lib/cx.tsx +6 -0
  241. package/templates/fate/void/src/lib/formatLabel.tsx +6 -0
  242. package/templates/fate/void/src/lib/shared.ts +10 -0
  243. package/templates/fate/void/src/ui/Badge.tsx +31 -0
  244. package/templates/fate/void/src/ui/Button.tsx +92 -0
  245. package/templates/fate/void/src/ui/Card.tsx +29 -0
  246. package/templates/fate/void/src/ui/CategoryCard.tsx +105 -0
  247. package/templates/fate/void/src/ui/CommentCard.tsx +80 -0
  248. package/templates/fate/void/src/ui/CreatePost.tsx +111 -0
  249. package/templates/fate/void/src/ui/Error.tsx +19 -0
  250. package/templates/fate/void/src/ui/EventCard.tsx +152 -0
  251. package/templates/fate/void/src/ui/H2.tsx +15 -0
  252. package/templates/fate/void/src/ui/H3.tsx +20 -0
  253. package/templates/fate/void/src/ui/Header.tsx +96 -0
  254. package/templates/fate/void/src/ui/Input.tsx +33 -0
  255. package/templates/fate/void/src/ui/Link.tsx +11 -0
  256. package/templates/fate/void/src/ui/Number.tsx +5 -0
  257. package/templates/fate/void/src/ui/PostCard.tsx +294 -0
  258. package/templates/fate/void/src/ui/Section.tsx +23 -0
  259. package/templates/fate/void/src/ui/TagBadge.tsx +23 -0
  260. package/templates/fate/void/src/ui/UserCard.tsx +129 -0
  261. package/templates/fate/void/src/user/AuthClient.tsx +1 -0
  262. package/templates/fate/void/src/user/SessionUser.tsx +17 -0
  263. package/templates/fate/void/tsconfig.json +34 -0
  264. package/templates/fate/void/vite.config.ts +83 -0
  265. package/templates/fate/void/void.json +32 -0
  266. package/templates/fate/void/wrangler.jsonc +16 -0
@@ -0,0 +1,284 @@
1
+ import type { Comment as InlineComment, Post } from '@app/server/src/router.ts';
2
+ import Stack, { VStack } from '@nkzw/stack';
3
+ import { cx } from 'class-variance-authority';
4
+ import { fbs } from 'fbtee';
5
+ import {
6
+ KeyboardEvent,
7
+ startTransition,
8
+ useActionState,
9
+ useCallback,
10
+ useEffect,
11
+ useState,
12
+ } from 'react';
13
+ import { ErrorBoundary } from 'react-error-boundary';
14
+ import { useFateClient, useLiveListView, useLiveView, useView, view, ViewRef } from 'react-fate';
15
+ import { Button } from '../ui/Button.tsx';
16
+ import Card from '../ui/Card.tsx';
17
+ import AuthClient from '../user/AuthClient.tsx';
18
+ import CommentCard, { CommentView } from './CommentCard.tsx';
19
+ import H3 from './H3.tsx';
20
+ import Link from './Link.tsx';
21
+ import { UserView } from './UserCard.tsx';
22
+
23
+ const CommentConnectionView = {
24
+ args: { first: 3 },
25
+ items: {
26
+ node: CommentView,
27
+ },
28
+ live: {
29
+ append: 'visible',
30
+ },
31
+ };
32
+
33
+ export const PostView = view<Post>()({
34
+ author: UserView,
35
+ commentCount: true,
36
+ comments: CommentConnectionView,
37
+ content: true,
38
+ id: true,
39
+ likes: true,
40
+ title: true,
41
+ });
42
+
43
+ const CommentInput = ({
44
+ error,
45
+ post,
46
+ }: {
47
+ error?: unknown;
48
+ post: { commentCount: number; id: string };
49
+ }) => {
50
+ const fate = useFateClient();
51
+ const { data: session } = AuthClient.useSession();
52
+ const user = session?.user;
53
+ const [commentText, setCommentText] = useState('');
54
+
55
+ const [addCommentResult, handleAddComment, addCommentIsPending] = useActionState(async () => {
56
+ const content = commentText.trim();
57
+
58
+ if (!content) {
59
+ return;
60
+ }
61
+
62
+ const result = await fate.mutations.comment.add({
63
+ input: { content, postId: post.id },
64
+ optimistic: {
65
+ author: user
66
+ ? {
67
+ id: user.id,
68
+ name: user.name ?? 'Anonymous',
69
+ }
70
+ : null,
71
+ content,
72
+ id: `optimistic:${Date.now().toString(36)}`,
73
+ post: { commentCount: post.commentCount + 1, id: post.id },
74
+ },
75
+ view: view<InlineComment>()({
76
+ ...CommentView,
77
+ post: { commentCount: true },
78
+ }),
79
+ });
80
+
81
+ setCommentText('');
82
+
83
+ return result;
84
+ }, null);
85
+
86
+ const maybeSubmitComment = (event: KeyboardEvent<HTMLTextAreaElement>) => {
87
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
88
+ startTransition(() => handleAddComment());
89
+ }
90
+ };
91
+
92
+ const commentingIsDisabled = addCommentIsPending || commentText.trim().length === 0;
93
+
94
+ const anyServerError = error || addCommentResult?.error;
95
+
96
+ return (
97
+ <VStack action={handleAddComment} as="form" gap>
98
+ <span className="text-sm font-medium text-foreground">
99
+ <fbt desc="Comment add label">Add a comment</fbt>
100
+ </span>
101
+ <textarea
102
+ className="squircle min-h-20 w-full border border-gray-200/80 bg-gray-100/50 p-3 text-sm placeholder-gray-500 transition outline-none focus:border-gray-500 focus:ring-2 focus:ring-gray-200 disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-900/40 dark:placeholder-gray-400"
103
+ disabled={addCommentIsPending}
104
+ onChange={(event) => setCommentText(event.target.value)}
105
+ onKeyDown={maybeSubmitComment}
106
+ placeholder={fbs('Share your thoughts...', 'Placeholder text for comment input')}
107
+ value={commentText}
108
+ />
109
+ {anyServerError ? (
110
+ <p className="text-sm text-destructive">
111
+ {anyServerError instanceof Error ? (
112
+ anyServerError.message
113
+ ) : (
114
+ <fbt desc="Error text">Something went wrong. Please try again.</fbt>
115
+ )}
116
+ </p>
117
+ ) : null}
118
+ <Stack end gap>
119
+ <Button disabled={commentingIsDisabled} size="sm" type="submit" variant="secondary">
120
+ <fbt desc="Post comment button">Post comment</fbt>
121
+ </Button>
122
+ </Stack>
123
+ </VStack>
124
+ );
125
+ };
126
+
127
+ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
128
+ const fate = useFateClient();
129
+ const post = useLiveView(PostView, postRef);
130
+ const author = useView(UserView, post.author);
131
+ const [comments, loadNext] = useLiveListView(CommentConnectionView, post.comments);
132
+
133
+ const [likeResult, likeAction, likeIsPending] = useActionState(fate.actions.post.like, null);
134
+
135
+ const [, unlikeAction, unlikeIsPending] = useActionState(fate.actions.post.unlike, null);
136
+
137
+ useEffect(() => {
138
+ if (likeResult?.error) {
139
+ const timeout = setTimeout(() => startTransition(() => likeAction('reset')), 3000);
140
+ return () => clearTimeout(timeout);
141
+ }
142
+ }, [likeAction, likeResult]);
143
+
144
+ const handleLike = useCallback(
145
+ async (options?: { error?: 'boundary' | 'callSite'; slow?: boolean }) => {
146
+ likeAction({
147
+ input: { id: post.id, ...options },
148
+ optimistic: { likes: post.likes + 1 },
149
+ view: PostView,
150
+ });
151
+ },
152
+ [likeAction, post.id, post.likes],
153
+ );
154
+
155
+ const handleUnlike = useCallback(async () => {
156
+ unlikeAction({
157
+ input: { id: post.id },
158
+ optimistic: {
159
+ likes: Math.max(post.likes - 1, 0),
160
+ },
161
+ view: PostView,
162
+ });
163
+ }, [post.id, post.likes, unlikeAction]);
164
+
165
+ return (
166
+ <Card>
167
+ <VStack gap={16}>
168
+ <Stack alignStart between gap={16} wrap>
169
+ <VStack gap>
170
+ <Link to={`/post/${post.id}`}>
171
+ <H3>{post.title}</H3>
172
+ </Link>
173
+ </VStack>
174
+ <Stack alignCenter gap={12} wrap>
175
+ <Stack
176
+ alignCenter
177
+ className="squircle bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-800 dark:bg-neutral-800 dark:text-white"
178
+ gap
179
+ >
180
+ <span>👍</span>
181
+ <span>
182
+ <fbt desc="Like count">
183
+ <fbt:plural count={post.likes} many="Likes" showCount="yes">
184
+ Like
185
+ </fbt:plural>
186
+ </fbt>
187
+ </span>
188
+ </Stack>
189
+ <Button action={handleLike} disabled={likeIsPending} size="sm" variant="outline">
190
+ <fbt desc="Like button">Like</fbt>
191
+ </Button>
192
+ {detail && (
193
+ <Button
194
+ action={() => handleLike({ slow: true })}
195
+ disabled={likeIsPending}
196
+ size="sm"
197
+ variant="outline"
198
+ >
199
+ <fbt desc="Like button with a slow request">Like (Slow)</fbt>
200
+ </Button>
201
+ )}
202
+ {detail && (
203
+ <Button
204
+ action={() => handleLike({ error: 'callSite' })}
205
+ className={cx(
206
+ 'w-32 transition-colors duration-150',
207
+ likeResult?.error ? 'border-red-500 text-red-500 hover:text-red-500' : '',
208
+ )}
209
+ disabled={likeIsPending}
210
+ size="sm"
211
+ variant="outline"
212
+ >
213
+ {likeResult?.error ? (
214
+ <fbt desc="Very short error messages">Oops, try again!</fbt>
215
+ ) : (
216
+ <fbt desc="Like button that triggers an error">Like (Error)</fbt>
217
+ )}
218
+ </Button>
219
+ )}
220
+ {detail && (
221
+ <Button
222
+ action={() => handleLike({ error: 'boundary' })}
223
+ disabled={likeIsPending}
224
+ size="sm"
225
+ variant="outline"
226
+ >
227
+ <fbt desc="Like button that triggers a network error">Like (Network Error)</fbt>
228
+ </Button>
229
+ )}
230
+ {detail && (
231
+ <Button
232
+ onClick={() =>
233
+ fate.mutations.post.like({
234
+ input: { id: post.id },
235
+ optimistic: { likes: post.likes + 1 },
236
+ view: PostView,
237
+ })
238
+ }
239
+ size="sm"
240
+ variant="outline"
241
+ >
242
+ <fbt desc="Like button that can be pressed many times">Like (Many)</fbt>
243
+ </Button>
244
+ )}
245
+ <Button
246
+ action={handleUnlike}
247
+ disabled={unlikeIsPending || post.likes === 0}
248
+ size="sm"
249
+ variant="outline"
250
+ >
251
+ <fbt desc="Unlike button">Unlike</fbt>
252
+ </Button>
253
+ </Stack>
254
+ </Stack>
255
+ <p className="text-sm leading-relaxed text-foreground/90 lg:text-base">{post.content}</p>
256
+ <p className="text-sm text-muted-foreground">- {author?.name ?? 'Unknown author'}</p>
257
+ <VStack gap={16}>
258
+ <h4 className="text-base font-semibold text-foreground">
259
+ <fbt desc="Comment headline">
260
+ <fbt:plural count={post.commentCount} many="Comments" showCount="ifMany">
261
+ One Comment
262
+ </fbt:plural>
263
+ </fbt>
264
+ </h4>
265
+ {comments.length > 0 ? (
266
+ <VStack gap={12}>
267
+ {comments.map(({ node }) => (
268
+ <CommentCard comment={node} key={node.id} post={post} />
269
+ ))}
270
+ {loadNext ? (
271
+ <Button onClick={loadNext} variant="ghost">
272
+ <fbt desc="Load more comments button">Load more comments</fbt>
273
+ </Button>
274
+ ) : null}
275
+ </VStack>
276
+ ) : null}
277
+ <ErrorBoundary fallbackRender={({ error }) => <CommentInput error={error} post={post} />}>
278
+ <CommentInput post={post} />
279
+ </ErrorBoundary>
280
+ </VStack>
281
+ </VStack>
282
+ </Card>
283
+ );
284
+ }
@@ -0,0 +1,23 @@
1
+ import { Gap, VStack } from '@nkzw/stack';
2
+ import { ReactNode } from 'react';
3
+ import cx from '../lib/cx.tsx';
4
+
5
+ export default function Section({
6
+ children,
7
+ className,
8
+ gap,
9
+ }: {
10
+ children: ReactNode;
11
+ className?: string;
12
+ gap?: Gap;
13
+ }) {
14
+ return (
15
+ <VStack
16
+ as="section"
17
+ className={cx('max-w-8xl container mx-auto px-4 py-6 lg:px-8 lg:py-10', className)}
18
+ gap={gap}
19
+ >
20
+ {children}
21
+ </VStack>
22
+ );
23
+ }
@@ -0,0 +1,131 @@
1
+ import { User } from '@app/server/src/router.ts';
2
+ import safeParse from '@nkzw/core/safeParse.js';
3
+ import Stack, { VStack } from '@nkzw/stack';
4
+ import { fbs } from 'fbtee';
5
+ import { ChangeEvent, useActionState, useState } from 'react';
6
+ import { view } from 'react-fate';
7
+ import { useFateClient } from 'react-fate';
8
+ import { ViewRef } from 'react-fate';
9
+ import { useView } from 'react-fate';
10
+ import { Button } from '../ui/Button.tsx';
11
+ import Card from '../ui/Card.tsx';
12
+ import AuthClient from '../user/AuthClient.tsx';
13
+ import H2 from './H2.tsx';
14
+ import Input from './Input.tsx';
15
+
16
+ export type SessionUser = {
17
+ id?: string | null;
18
+ name?: string | null;
19
+ username?: string | null;
20
+ };
21
+
22
+ export const UserView = view<User>()({
23
+ id: true,
24
+ name: true,
25
+ username: true,
26
+ });
27
+
28
+ const UserNameForm = ({ user }: { user: SessionUser }) => {
29
+ const fate = useFateClient();
30
+ const [name, setName] = useState(user.name ?? '');
31
+ const [error, setError] = useState<string | null>(null);
32
+
33
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
34
+ setError(null);
35
+ setName(event.target.value);
36
+ };
37
+
38
+ const [, submitAction, isPending] = useActionState(async () => {
39
+ const id = user?.id;
40
+ if (!id) {
41
+ return;
42
+ }
43
+
44
+ const newName = name.trim();
45
+ setName(newName);
46
+
47
+ if (newName === user.name) {
48
+ setError(null);
49
+ return;
50
+ }
51
+
52
+ try {
53
+ setError(null);
54
+ await fate.mutations.user.update({
55
+ input: { name: newName },
56
+ optimistic: {
57
+ id,
58
+ username: newName,
59
+ },
60
+ view: UserView,
61
+ });
62
+ await AuthClient.updateUser({ name });
63
+ } catch (error) {
64
+ setError(
65
+ (error instanceof Error &&
66
+ error.message &&
67
+ safeParse<Array<{ message: string }>>(error.message)?.[0]?.message) ||
68
+ 'Failed to update user name.',
69
+ );
70
+ }
71
+ }, null);
72
+
73
+ const trimmedName = name.trim();
74
+ const originalName = user.name ?? '';
75
+ const isSaveDisabled = !user.id || !trimmedName || trimmedName === originalName || isPending;
76
+
77
+ return (
78
+ <div>
79
+ <VStack action={submitAction} as="form" gap={12}>
80
+ <h3 className="font-semibold">
81
+ <fbt desc="Headline for update name section">Update Name</fbt>
82
+ </h3>
83
+ <label className="sr-only" htmlFor="header-username">
84
+ <fbt desc="Username label">Username</fbt>
85
+ </label>
86
+ <Input
87
+ aria-describedby={error ? 'header-username-error' : undefined}
88
+ aria-invalid={error ? 'true' : undefined}
89
+ className="w-48"
90
+ disabled={isPending}
91
+ id="header-username"
92
+ name="name"
93
+ onChange={handleChange}
94
+ placeholder={fbs('name', 'name input field placeholder')}
95
+ title={error ?? undefined}
96
+ value={name}
97
+ />
98
+ <div>
99
+ <Button disabled={isSaveDisabled} size="sm" type="submit" variant="secondary">
100
+ <fbt desc="Button for saving">Save</fbt>
101
+ </Button>
102
+ </div>
103
+ </VStack>
104
+ {error ? <span id="header-username-error">{error}</span> : null}
105
+ </div>
106
+ );
107
+ };
108
+
109
+ export default function UserCard({ user: userRef }: { user: ViewRef<'User'> }) {
110
+ const user = useView(UserView, userRef);
111
+
112
+ return (
113
+ <Card>
114
+ <VStack between className="h-full" gap={16}>
115
+ <VStack gap={16}>
116
+ <H2>
117
+ <fbt desc="Your account headline">Your account</fbt>
118
+ </H2>
119
+ <Stack alignCenter between gap={16}>
120
+ <p className="text-sm text-muted-foreground">
121
+ <fbt desc="Signed in text">
122
+ Signed in as <fbt:param name="username">{user.name}</fbt:param>.
123
+ </fbt>
124
+ </p>
125
+ </Stack>
126
+ </VStack>
127
+ <UserNameForm user={user} />
128
+ </VStack>
129
+ </Card>
130
+ );
131
+ }
@@ -0,0 +1,6 @@
1
+ import { createAuthClient } from 'better-auth/react';
2
+ import env from '../lib/env.tsx';
3
+
4
+ export default createAuthClient({
5
+ baseURL: env('SERVER_URL'),
6
+ });