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,281 @@
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, useListView, 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
+ };
29
+
30
+ export const PostView = view<Post>()({
31
+ author: UserView,
32
+ commentCount: true,
33
+ comments: CommentConnectionView,
34
+ content: true,
35
+ id: true,
36
+ likes: true,
37
+ title: true,
38
+ });
39
+
40
+ const CommentInput = ({
41
+ error,
42
+ post,
43
+ }: {
44
+ error?: unknown;
45
+ post: { commentCount: number; id: string };
46
+ }) => {
47
+ const fate = useFateClient();
48
+ const { data: session } = AuthClient.useSession();
49
+ const user = session?.user;
50
+ const [commentText, setCommentText] = useState('');
51
+
52
+ const [addCommentResult, handleAddComment, addCommentIsPending] = useActionState(async () => {
53
+ const content = commentText.trim();
54
+
55
+ if (!content) {
56
+ return;
57
+ }
58
+
59
+ const result = await fate.mutations.comment.add({
60
+ input: { content, postId: post.id },
61
+ optimistic: {
62
+ author: user
63
+ ? {
64
+ id: user.id,
65
+ name: user.name ?? 'Anonymous',
66
+ }
67
+ : null,
68
+ content,
69
+ id: `optimistic:${Date.now().toString(36)}`,
70
+ post: { commentCount: post.commentCount + 1, id: post.id },
71
+ },
72
+ view: view<InlineComment>()({
73
+ ...CommentView,
74
+ post: { commentCount: true },
75
+ }),
76
+ });
77
+
78
+ setCommentText('');
79
+
80
+ return result;
81
+ }, null);
82
+
83
+ const maybeSubmitComment = (event: KeyboardEvent<HTMLTextAreaElement>) => {
84
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
85
+ startTransition(() => handleAddComment());
86
+ }
87
+ };
88
+
89
+ const commentingIsDisabled = addCommentIsPending || commentText.trim().length === 0;
90
+
91
+ const anyServerError = error || addCommentResult?.error;
92
+
93
+ return (
94
+ <VStack action={handleAddComment} as="form" gap>
95
+ <span className="text-sm font-medium text-foreground">
96
+ <fbt desc="Comment add label">Add a comment</fbt>
97
+ </span>
98
+ <textarea
99
+ 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"
100
+ disabled={addCommentIsPending}
101
+ onChange={(event) => setCommentText(event.target.value)}
102
+ onKeyDown={maybeSubmitComment}
103
+ placeholder={fbs('Share your thoughts...', 'Placeholder text for comment input')}
104
+ value={commentText}
105
+ />
106
+ {anyServerError ? (
107
+ <p className="text-sm text-destructive">
108
+ {anyServerError instanceof Error ? (
109
+ anyServerError.message
110
+ ) : (
111
+ <fbt desc="Error text">Something went wrong. Please try again.</fbt>
112
+ )}
113
+ </p>
114
+ ) : null}
115
+ <Stack end gap>
116
+ <Button disabled={commentingIsDisabled} size="sm" type="submit" variant="secondary">
117
+ <fbt desc="Post comment button">Post comment</fbt>
118
+ </Button>
119
+ </Stack>
120
+ </VStack>
121
+ );
122
+ };
123
+
124
+ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
125
+ const fate = useFateClient();
126
+ const post = useView(PostView, postRef);
127
+ const author = useView(UserView, post.author);
128
+ const [comments, loadNext] = useListView(CommentConnectionView, post.comments);
129
+
130
+ const [likeResult, likeAction, likeIsPending] = useActionState(fate.actions.post.like, null);
131
+
132
+ const [, unlikeAction, unlikeIsPending] = useActionState(fate.actions.post.unlike, null);
133
+
134
+ useEffect(() => {
135
+ if (likeResult?.error) {
136
+ const timeout = setTimeout(() => startTransition(() => likeAction('reset')), 3000);
137
+ return () => clearTimeout(timeout);
138
+ }
139
+ }, [likeAction, likeResult]);
140
+
141
+ const handleLike = useCallback(
142
+ async (options?: { error?: 'boundary' | 'callSite'; slow?: boolean }) => {
143
+ likeAction({
144
+ input: { id: post.id, ...options },
145
+ optimistic: { likes: post.likes + 1 },
146
+ view: PostView,
147
+ });
148
+ },
149
+ [likeAction, post.id, post.likes],
150
+ );
151
+
152
+ const handleUnlike = useCallback(async () => {
153
+ unlikeAction({
154
+ input: { id: post.id },
155
+ optimistic: {
156
+ likes: Math.max(post.likes - 1, 0),
157
+ },
158
+ view: PostView,
159
+ });
160
+ }, [post.id, post.likes, unlikeAction]);
161
+
162
+ return (
163
+ <Card>
164
+ <VStack gap={16}>
165
+ <Stack alignStart between gap={16} wrap>
166
+ <VStack gap>
167
+ <Link to={`/post/${post.id}`}>
168
+ <H3>{post.title}</H3>
169
+ </Link>
170
+ </VStack>
171
+ <Stack alignCenter gap={12} wrap>
172
+ <Stack
173
+ alignCenter
174
+ className="squircle bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-800 dark:bg-neutral-800 dark:text-white"
175
+ gap
176
+ >
177
+ <span>👍</span>
178
+ <span>
179
+ <fbt desc="Like count">
180
+ <fbt:plural count={post.likes} many="Likes" showCount="yes">
181
+ Like
182
+ </fbt:plural>
183
+ </fbt>
184
+ </span>
185
+ </Stack>
186
+ <Button action={handleLike} disabled={likeIsPending} size="sm" variant="outline">
187
+ <fbt desc="Like button">Like</fbt>
188
+ </Button>
189
+ {detail && (
190
+ <Button
191
+ action={() => handleLike({ slow: true })}
192
+ disabled={likeIsPending}
193
+ size="sm"
194
+ variant="outline"
195
+ >
196
+ <fbt desc="Like button with a slow request">Like (Slow)</fbt>
197
+ </Button>
198
+ )}
199
+ {detail && (
200
+ <Button
201
+ action={() => handleLike({ error: 'callSite' })}
202
+ className={cx(
203
+ 'w-32 transition-colors duration-150',
204
+ likeResult?.error ? 'border-red-500 text-red-500 hover:text-red-500' : '',
205
+ )}
206
+ disabled={likeIsPending}
207
+ size="sm"
208
+ variant="outline"
209
+ >
210
+ {likeResult?.error ? (
211
+ <fbt desc="Very short error messages">Oops, try again!</fbt>
212
+ ) : (
213
+ <fbt desc="Like button that triggers an error">Like (Error)</fbt>
214
+ )}
215
+ </Button>
216
+ )}
217
+ {detail && (
218
+ <Button
219
+ action={() => handleLike({ error: 'boundary' })}
220
+ disabled={likeIsPending}
221
+ size="sm"
222
+ variant="outline"
223
+ >
224
+ <fbt desc="Like button that triggers a network error">Like (Network Error)</fbt>
225
+ </Button>
226
+ )}
227
+ {detail && (
228
+ <Button
229
+ onClick={() =>
230
+ fate.mutations.post.like({
231
+ input: { id: post.id },
232
+ optimistic: { likes: post.likes + 1 },
233
+ view: PostView,
234
+ })
235
+ }
236
+ size="sm"
237
+ variant="outline"
238
+ >
239
+ <fbt desc="Like button that can be pressed many times">Like (Many)</fbt>
240
+ </Button>
241
+ )}
242
+ <Button
243
+ action={handleUnlike}
244
+ disabled={unlikeIsPending || post.likes === 0}
245
+ size="sm"
246
+ variant="outline"
247
+ >
248
+ <fbt desc="Unlike button">Unlike</fbt>
249
+ </Button>
250
+ </Stack>
251
+ </Stack>
252
+ <p className="text-sm leading-relaxed text-foreground/90 lg:text-base">{post.content}</p>
253
+ <p className="text-sm text-muted-foreground">- {author?.name ?? 'Unknown author'}</p>
254
+ <VStack gap={16}>
255
+ <h4 className="text-base font-semibold text-foreground">
256
+ <fbt desc="Comment headline">
257
+ <fbt:plural count={post.commentCount} many="Comments" showCount="ifMany">
258
+ One Comment
259
+ </fbt:plural>
260
+ </fbt>
261
+ </h4>
262
+ {comments.length > 0 ? (
263
+ <VStack gap={12}>
264
+ {comments.map(({ node }) => (
265
+ <CommentCard comment={node} key={node.id} post={post} />
266
+ ))}
267
+ {loadNext ? (
268
+ <Button onClick={loadNext} variant="ghost">
269
+ <fbt desc="Load more comments button">Load more comments</fbt>
270
+ </Button>
271
+ ) : null}
272
+ </VStack>
273
+ ) : null}
274
+ <ErrorBoundary fallbackRender={({ error }) => <CommentInput error={error} post={post} />}>
275
+ <CommentInput post={post} />
276
+ </ErrorBoundary>
277
+ </VStack>
278
+ </VStack>
279
+ </Card>
280
+ );
281
+ }
@@ -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
+ });