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,152 @@
1
+ import Stack, { VStack } from '@nkzw/stack';
2
+ import { ArrowUpRight, CalendarDays, MapPin, Users } from 'lucide-react';
3
+ import { useEffect, useState } from 'react';
4
+ import { useView, view, ViewRef } from 'react-fate';
5
+ import type { Event, EventAttendee } from '../fate/views.ts';
6
+ import formatLabel from '../lib/formatLabel.tsx';
7
+ import { Badge } from '../ui/Badge.tsx';
8
+ import Card from '../ui/Card.tsx';
9
+ import { UserView } from '../ui/UserCard.tsx';
10
+
11
+ const EventAttendeeView = view<EventAttendee>()({
12
+ id: true,
13
+ notes: true,
14
+ status: true,
15
+ user: UserView,
16
+ });
17
+
18
+ export const EventView = view<Event>()({
19
+ attendees: {
20
+ items: {
21
+ node: EventAttendeeView,
22
+ },
23
+ },
24
+ attendingCount: true,
25
+ capacity: true,
26
+ description: true,
27
+ endAt: true,
28
+ host: UserView,
29
+ id: true,
30
+ livestreamUrl: true,
31
+ location: true,
32
+ name: true,
33
+ startAt: true,
34
+ topics: true,
35
+ type: true,
36
+ });
37
+
38
+ const EventAttendeeChip = ({ attendee: attendeeRef }: { attendee: ViewRef<'EventAttendee'> }) => {
39
+ const attendee = useView(EventAttendeeView, attendeeRef);
40
+ const user = useView(UserView, attendee.user);
41
+
42
+ return (
43
+ <Badge className="text-nowrap" key={attendee.id} variant="outline">
44
+ {user?.name ?? 'Guest'} · {formatLabel(attendee.status)}
45
+ </Badge>
46
+ );
47
+ };
48
+
49
+ const hydrationSafeDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
50
+ day: 'numeric',
51
+ hour: 'numeric',
52
+ minute: 'numeric',
53
+ month: 'short',
54
+ timeZone: 'UTC',
55
+ });
56
+
57
+ const localDateTimeFormatter = new Intl.DateTimeFormat(undefined, {
58
+ day: 'numeric',
59
+ hour: 'numeric',
60
+ minute: 'numeric',
61
+ month: 'short',
62
+ });
63
+
64
+ const formatDateTime = (date: string, local: boolean) =>
65
+ (local ? localDateTimeFormatter : hydrationSafeDateTimeFormatter).format(new Date(date));
66
+
67
+ const EventDateTime = ({ endAt, startAt }: { endAt: string; startAt: string }) => {
68
+ const [local, setLocal] = useState(false);
69
+
70
+ useEffect(() => {
71
+ const frame = requestAnimationFrame(() => setLocal(true));
72
+ return () => cancelAnimationFrame(frame);
73
+ }, []);
74
+
75
+ return (
76
+ <>
77
+ {formatDateTime(startAt, local)} → {formatDateTime(endAt, local)}
78
+ </>
79
+ );
80
+ };
81
+
82
+ export default function EventCard({ event: eventRef }: { event: ViewRef<'Event'> }) {
83
+ const event = useView(EventView, eventRef);
84
+ const host = useView(UserView, event.host);
85
+ const attendees = event.attendees?.items ?? [];
86
+ const topics = event.topics ?? [];
87
+
88
+ return (
89
+ <Card key={event.id}>
90
+ <VStack gap={12}>
91
+ <Stack alignCenter between gap={12}>
92
+ <div>
93
+ <h4 className="text-base font-semibold text-foreground">{event.name}</h4>
94
+ <p className="text-sm text-muted-foreground">{event.description}</p>
95
+ </div>
96
+ <Badge className="text-nowrap" variant="secondary">
97
+ {formatLabel(event.type)}
98
+ </Badge>
99
+ </Stack>
100
+ <Stack alignCenter gap>
101
+ <CalendarDays className="text-muted-foreground" size={14} />
102
+ <span className="text-sm text-foreground/80">
103
+ <EventDateTime endAt={event.endAt} startAt={event.startAt} />
104
+ </span>
105
+ </Stack>
106
+ <Stack alignCenter gap>
107
+ <MapPin className="text-muted-foreground" size={14} />
108
+ <span className="text-sm text-foreground/80">{event.location}</span>
109
+ </Stack>
110
+ <Stack alignCenter gap>
111
+ <Users className="text-muted-foreground" size={14} />
112
+ <span className="text-sm text-foreground/80">
113
+ {event.attendingCount ?? attendees.length} attending · capacity {event.capacity}
114
+ </span>
115
+ </Stack>
116
+ <Stack alignCenter gap>
117
+ <ArrowUpRight className="text-muted-foreground" size={14} />
118
+ <span className="text-sm text-foreground/80">Hosted by {host?.name ?? 'Unknown'}</span>
119
+ </Stack>
120
+ {topics.length ? (
121
+ <Stack gap wrap>
122
+ {topics.map((topic) => (
123
+ <Badge className="text-nowrap" key={topic} variant="outline">
124
+ {topic}
125
+ </Badge>
126
+ ))}
127
+ </Stack>
128
+ ) : null}
129
+ {attendees.length ? (
130
+ <VStack gap>
131
+ <span className="text-xs text-muted-foreground">Community RSVPs</span>
132
+ <Stack gap wrap>
133
+ {attendees.slice(0, 4).map(({ node }) => (
134
+ <EventAttendeeChip attendee={node} key={node.id} />
135
+ ))}
136
+ </Stack>
137
+ </VStack>
138
+ ) : null}
139
+ {event.livestreamUrl ? (
140
+ <a
141
+ className="text-primary text-sm font-medium hover:underline"
142
+ href={event.livestreamUrl}
143
+ rel="noreferrer"
144
+ target="_blank"
145
+ >
146
+ Join livestream
147
+ </a>
148
+ ) : null}
149
+ </VStack>
150
+ </Card>
151
+ );
152
+ }
@@ -0,0 +1,15 @@
1
+ import cx from '../lib/cx.tsx';
2
+
3
+ export default function H2({
4
+ children,
5
+ className,
6
+ }: {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <h2 className={cx('text-2xl font-semibold text-gray-900 dark:text-gray-50', className)}>
12
+ {children}
13
+ </h2>
14
+ );
15
+ }
@@ -0,0 +1,20 @@
1
+ import cx from '../lib/cx.tsx';
2
+
3
+ export default function H2({
4
+ children,
5
+ className,
6
+ }: {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <h3
12
+ className={cx(
13
+ 'bg-linear-to-r from-slate-900 to-slate-600 bg-clip-text text-lg font-semibold tracking-widest text-transparent uppercase opacity-100 transition duration-150 hover:opacity-70 active:translate-y-[1.5px] dark:from-white dark:to-slate-200',
14
+ className,
15
+ )}
16
+ >
17
+ {children}
18
+ </h3>
19
+ );
20
+ }
@@ -0,0 +1,96 @@
1
+ import Stack from '@nkzw/stack';
2
+ import { useRouter, useShared } from '@void/react';
3
+ import { ChevronLeft, LogIn, LogOut, Search } from 'lucide-react';
4
+ import type { SharedData } from '../lib/shared.ts';
5
+ import AuthClient from '../user/AuthClient.tsx';
6
+ import { Button } from './Button.tsx';
7
+ import Link from './Link.tsx';
8
+
9
+ export default function Header() {
10
+ const router = useRouter();
11
+ const { auth } = useShared<SharedData>();
12
+ const pathname = router.path;
13
+ const session = auth.user;
14
+
15
+ return (
16
+ <header className="sticky top-0 z-50 border-b border-white/60 bg-white/60 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/70">
17
+ <Stack
18
+ alignCenter
19
+ between
20
+ className="max-w-8xl relative container mx-auto h-16 px-6 lg:px-8"
21
+ gap={16}
22
+ >
23
+ <Link
24
+ href="/"
25
+ onClick={() =>
26
+ pathname !== '/' && history.state?.idx > 0 ? history.back() : router.visit('/')
27
+ }
28
+ >
29
+ <Stack alignCenter gap={12}>
30
+ <div className="squircle relative flex h-10 w-10 items-center justify-center bg-linear-to-br from-gray-100 to-gray-200 text-white shadow-lg shadow-gray-500/20 transition duration-250 hover:scale-110 active:scale-90">
31
+ {pathname === '/' ? (
32
+ <svg
33
+ className="h-6 w-6 text-gray-500"
34
+ preserveAspectRatio="xMidYMax"
35
+ viewBox="0 0 160 160"
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ >
38
+ <path
39
+ d="M108.301 17.465h.272c1.797-.005 3.563.177 5.331.496l.349.063c5.588 1.02 10.731 3.447 15.201 6.933l.347.267c1.579 1.26 3.083 2.684 4.386 4.227l.161.186c4.018 4.719 6.924 10.686 7.767 16.852l.033.239c.262 1.848.351 3.688.352 5.551l.001.307c-.001 1.623-.092 3.188-.386 4.788l-.05.292c-1.003 5.874-3.476 11.447-7.286 16.034l-.29.358c-1.422 1.721-3.011 3.382-4.797 4.729l-.444.354c-3.046 2.405-6.436 4.147-10.084 5.442l-.497.186c-1.766.627-3.615.974-5.465 1.266l-.364.058c-1.591.238-3.185.286-4.791.281h-.499l-1.339-.002h-1.408c-.886-.001-1.772-.001-2.656-.003-3.085-.001-6.163-.005-9.247-.008v-12.78l3.606-.006 2.282-.007c1.205-.006 2.407-.01 3.611-.011.878 0 1.754-.003 2.63-.007.462-.002.928-.005 1.389-.002 6.302.01 12.003-1.72 16.65-6.117l.291-.272c3.951-3.807 6.097-9.302 6.241-14.736.04-3.078-.476-6.048-1.68-8.892l-.104-.244c-2.382-5.559-6.98-9.439-12.504-11.675-2.204-.816-4.522-1.16-6.863-1.134-.301.004-.606.004-.909.001-1.677.003-3.304.127-4.924.591l-.331.098c-2.625.758-5.093 1.928-7.254 3.607l-.323.249c-1.046.817-2.031 1.682-2.873 2.71l-.154.179c-2.354 2.782-3.9 5.988-4.727 9.529l-.054.229c-.391 1.8-.423 3.633-.42 5.471l-.001.74c.002 1.4.001 2.801-.004 4.201l-.004 3.779c0 1.824-.001 3.649-.003 5.473l-.006 2.813-.005 6.076-.001.267-.008 8.614c-.001 2.023-.004 4.045-.006 6.066v.259l-.004 2.55-.006 5.46c0 1.331-.001 2.658-.003 3.985-.002.72-.002 1.438-.002 2.156 0 .658 0 1.317-.002 1.978v.708c.004 2.098-.07 4.214-.545 6.263l-.077.359-.161.716c-.071.302-.136.605-.199.909-.55 2.538-1.6 5.067-2.832 7.348l-.214.42c-.965 1.921-2.22 3.714-3.574 5.38l-.152.183c-.78.973-1.639 1.866-2.519 2.751l-.308.313a25.81 25.81 0 0 1-2.226 1.955l-.22.179c-3.698 2.953-7.975 5.044-12.538 6.262-.212.056-.422.115-.631.176-8.366 2.287-17.8.738-25.278-3.512-3.639-2.122-7.171-4.945-9.721-8.307-.121-.15-.241-.298-.356-.448a36.247 36.247 0 0 1-2.605-3.814l-.144-.234c-.889-1.466-1.562-3.034-2.222-4.617l-.11-.265c-3.253-7.791-2.911-17.367.184-25.133.865-2.069 1.869-4.089 3.123-5.951l.172-.261a31.91 31.91 0 0 1 2.547-3.29l.229-.263c.13-.152.266-.304.396-.452l.425-.493c1.216-1.389 2.593-2.615 4.068-3.723.208-.157.414-.315.62-.476 1.184-.901 2.437-1.665 3.729-2.4l.272-.159c1.586-.893 3.235-1.573 4.934-2.21l.253-.097c4.232-1.567 8.615-1.836 13.079-1.82 1.359 0 2.732.002 4.093.007 1.209.005 2.417.006 3.621.006 1.899.002 3.794.007 5.688.012v12.78l-3.612.013-2.282.011c-1.201.005-2.401.012-3.61.016-.876.003-1.751.005-2.629.013-.463.002-.924.005-1.387.006-4.322.005-8.193.592-12.033 2.662l-.217.114c-1.458.773-2.697 1.731-3.908 2.849-.206.185-.413.362-.63.535-.435.376-.794.788-1.16 1.232l-.153.18a21.18 21.18 0 0 0-4.474 8.651l-.094.342c-.42 1.682-.54 3.395-.536 5.127v.272c.019 5.672 2.392 10.671 6.206 14.769l.215.237c3.296 3.453 8.483 5.917 13.247 6.046.448.008.896.01 1.345.01.276 0 .553.003.835.005 4.917.019 9.864-1.73 13.577-4.995l.303-.267c.299-.263.592-.527.881-.799l.262-.247c.787-.756 1.451-1.599 2.104-2.474l.151-.201c1.929-2.606 3.117-5.723 3.635-8.909l.059-.362c.194-1.296.21-2.573.207-3.883l.003-.74v-2.02c0-.73 0-1.459.005-2.188v-3.782c0-1.827.004-3.654.007-5.481 0-.939 0-1.877.004-2.816l.003-6.082v-.268c.005-2.876.008-5.75.008-8.627l.013-8.883c.004-1.823.004-3.646.004-5.469 0-1.33.004-2.659.004-3.989.003-.721.003-1.442.003-2.159v-1.98c.005-.237.005-.473 0-.711-.011-8.523 3.072-17.023 8.941-23.268.244-.263.483-.531.717-.805.416-.48.861-.918 1.324-1.352l.252-.232a26.259 26.259 0 0 1 2.184-1.812l.46-.349c1.39-1.058 2.837-1.951 4.375-2.775l.318-.172c5.005-2.666 10.5-3.877 16.15-3.889Z"
40
+ fill="currentColor"
41
+ style={{ transformBox: 'fill-box', transformOrigin: '50% 50%' }}
42
+ transform="rotate(-31 0 0)"
43
+ />
44
+ </svg>
45
+ ) : (
46
+ <ChevronLeft className="h-6 w-6 text-gray-500" />
47
+ )}
48
+ </div>
49
+ <div className="opacity-100 transition-opacity duration-250 hover:opacity-70">
50
+ <span className="bg-linear-to-r from-gray-500 to-gray-900 bg-clip-text text-xl font-semibold text-transparent dark:from-gray-200 dark:to-white">
51
+ <span className="italic">fate</span>
52
+ </span>
53
+ <p className="hidden text-xs text-muted-foreground sm:block">
54
+ A modern data client for React.
55
+ </p>
56
+ </div>
57
+ </Stack>
58
+ </Link>
59
+ <Stack alignCenter>
60
+ <Button asChild size="sm" variant="ghost">
61
+ <Link href="/search">
62
+ <Search className="h-4 w-4" />
63
+ <span className="hidden sm:inline">Search</span>
64
+ </Link>
65
+ </Button>
66
+ {session ? (
67
+ <>
68
+ <Button asChild size="sm" variant="ghost">
69
+ <Stack
70
+ alignCenter
71
+ as="a"
72
+ className="text-sm font-medium text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-white"
73
+ gap={4}
74
+ onClick={async () => {
75
+ await AuthClient.signOut();
76
+ router.flushAll();
77
+ await router.refresh({ preserveState: false });
78
+ }}
79
+ >
80
+ <LogOut className="h-4 w-4" />
81
+ <span className="hidden sm:inline">Logout</span>
82
+ </Stack>
83
+ </Button>
84
+ </>
85
+ ) : (
86
+ <Button asChild size="sm" variant="ghost">
87
+ <Link href="/login">
88
+ <LogIn className="h-4 w-4" /> Login
89
+ </Link>
90
+ </Button>
91
+ )}
92
+ </Stack>
93
+ </Stack>
94
+ </header>
95
+ );
96
+ }
@@ -0,0 +1,33 @@
1
+ import { InputHTMLAttributes, Ref } from 'react';
2
+ import cx from '../lib/cx.tsx';
3
+
4
+ export default function Input({
5
+ className,
6
+ ...props
7
+ }: InputHTMLAttributes<HTMLInputElement> & { ref?: Ref<HTMLInputElement> }) {
8
+ return (
9
+ <input
10
+ className={cx(
11
+ 'border-input squircle flex w-32 border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-offset-background focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-900/40',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ export function CheckBox({
20
+ className,
21
+ ...props
22
+ }: InputHTMLAttributes<HTMLInputElement> & { ref?: Ref<HTMLInputElement> }) {
23
+ return (
24
+ <Input
25
+ className={cx(
26
+ "duration-150ms relative h-6 w-6 shrink-0 cursor-pointer appearance-none p-0 after:absolute after:inset-0.5 after:h-4.5 after:w-4.5 after:rounded-4xl after:bg-foreground/60 after:opacity-0 after:transition-opacity after:content-[''] after:[corner-shape:squircle] checked:border-foreground/50 checked:after:block checked:after:opacity-100 disabled:cursor-not-allowed disabled:opacity-50",
27
+ className,
28
+ )}
29
+ type="checkbox"
30
+ {...props}
31
+ />
32
+ );
33
+ }
@@ -0,0 +1,11 @@
1
+ import { Link as VoidLink } from '@void/react';
2
+ import type { ComponentProps } from 'react';
3
+
4
+ export type LinkProps = Omit<ComponentProps<typeof VoidLink>, 'href'> & {
5
+ href?: string;
6
+ to?: string;
7
+ };
8
+
9
+ export default function Link({ href, to, ...props }: LinkProps) {
10
+ return <VoidLink href={href ?? to ?? '/'} {...props} />;
11
+ }
@@ -0,0 +1,5 @@
1
+ const numberFormatter = new Intl.NumberFormat(undefined);
2
+
3
+ export default function Number({ value }: { value: number }) {
4
+ return <>{numberFormatter.format(value)}</>;
5
+ }
@@ -0,0 +1,294 @@
1
+ import Stack, { VStack } from '@nkzw/stack';
2
+ import { useShared } from '@void/react';
3
+ import { cx } from 'class-variance-authority';
4
+ import {
5
+ KeyboardEvent,
6
+ startTransition,
7
+ useActionState,
8
+ useCallback,
9
+ useEffect,
10
+ useState,
11
+ } from 'react';
12
+ import { ErrorBoundary } from 'react-error-boundary';
13
+ import { useFateClient, useLiveListView, useLiveView, useView, view, ViewRef } from 'react-fate';
14
+ import type { Category, Comment as InlineComment, Post } from '../fate/server.ts';
15
+ import type { SharedData } from '../lib/shared.ts';
16
+ import { Button } from '../ui/Button.tsx';
17
+ import Card from '../ui/Card.tsx';
18
+ import TagBadge, { TagView } from '../ui/TagBadge.tsx';
19
+ import { Badge } from './Badge.tsx';
20
+ import CommentCard, { CommentView } from './CommentCard.tsx';
21
+ import H3 from './H3.tsx';
22
+ import Link from './Link.tsx';
23
+ import { UserView } from './UserCard.tsx';
24
+
25
+ const CategorySummaryView = view<Category>()({
26
+ id: true,
27
+ name: true,
28
+ });
29
+
30
+ const CommentConnectionView = {
31
+ args: { first: 3 },
32
+ items: {
33
+ node: CommentView,
34
+ },
35
+ live: {
36
+ append: 'visible',
37
+ },
38
+ };
39
+
40
+ export const PostView = view<Post>()({
41
+ author: UserView,
42
+ category: CategorySummaryView,
43
+ commentCount: true,
44
+ comments: CommentConnectionView,
45
+ content: true,
46
+ id: true,
47
+ likes: true,
48
+ tags: {
49
+ items: {
50
+ node: TagView,
51
+ },
52
+ },
53
+ title: true,
54
+ });
55
+
56
+ const CommentInput = ({
57
+ error,
58
+ post,
59
+ }: {
60
+ error?: unknown;
61
+ post: { commentCount: number; id: string };
62
+ }) => {
63
+ const fate = useFateClient();
64
+ const { auth } = useShared<SharedData>();
65
+ const user = auth.user;
66
+ const [commentText, setCommentText] = useState('');
67
+
68
+ const [result, addComment, isPending] = useActionState(async () => {
69
+ const content = commentText.trim();
70
+
71
+ if (!content) {
72
+ return;
73
+ }
74
+
75
+ const result = await fate.mutations.comment.add({
76
+ input: { content, postId: post.id },
77
+ optimistic: {
78
+ author: user ? { id: user.id, name: user.name } : null,
79
+ content,
80
+ id: `optimistic:${Date.now().toString(36)}`,
81
+ post: { commentCount: post.commentCount + 1, id: post.id },
82
+ },
83
+ view: view<InlineComment>()({
84
+ ...CommentView,
85
+ post: { commentCount: true },
86
+ }),
87
+ });
88
+
89
+ setCommentText('');
90
+
91
+ return result;
92
+ }, null);
93
+
94
+ const maybeSubmitComment = (event: KeyboardEvent<HTMLTextAreaElement>) => {
95
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
96
+ startTransition(addComment);
97
+ }
98
+ };
99
+
100
+ const commentingIsDisabled = isPending || commentText.trim().length === 0;
101
+
102
+ const anyServerError = error || result?.error;
103
+
104
+ return (
105
+ <VStack action={addComment} as="form" gap>
106
+ <span className="text-sm font-medium text-foreground">Add a comment</span>
107
+ <textarea
108
+ 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"
109
+ disabled={isPending}
110
+ onChange={(event) => setCommentText(event.target.value)}
111
+ onKeyDown={maybeSubmitComment}
112
+ placeholder={user?.name ? `Share your thoughts, ${user.name}!` : 'Share your thoughts...'}
113
+ value={commentText}
114
+ />
115
+ {anyServerError ? (
116
+ <p className="text-sm text-destructive">
117
+ {anyServerError instanceof Error
118
+ ? anyServerError.message
119
+ : 'Something went wrong. Please try again.'}
120
+ </p>
121
+ ) : null}
122
+ <Stack end gap>
123
+ <Button disabled={commentingIsDisabled} size="sm" type="submit" variant="secondary">
124
+ Post comment
125
+ </Button>
126
+ </Stack>
127
+ </VStack>
128
+ );
129
+ };
130
+
131
+ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
132
+ const fate = useFateClient();
133
+ const post = useLiveView(PostView, postRef);
134
+ const author = useView(UserView, post.author);
135
+ const category = useView(CategorySummaryView, post.category);
136
+ const [comments, loadNext] = useLiveListView(CommentConnectionView, post.comments);
137
+ const tags = post.tags?.items ?? [];
138
+
139
+ const [likeResult, likeAction, likeIsPending] = useActionState(fate.actions.post.like, null);
140
+
141
+ const [, unlikeAction, unlikeIsPending] = useActionState(fate.actions.post.unlike, null);
142
+
143
+ useEffect(() => {
144
+ if (likeResult?.error) {
145
+ const timeout = setTimeout(() => startTransition(() => likeAction('reset')), 3000);
146
+ return () => clearTimeout(timeout);
147
+ }
148
+ }, [likeAction, likeResult]);
149
+
150
+ const handleLike = useCallback(
151
+ async (options?: { error?: 'boundary' | 'callSite'; slow?: boolean }) => {
152
+ likeAction({
153
+ input: { id: post.id, ...options },
154
+ optimistic: { likes: post.likes + 1 },
155
+ view: PostView,
156
+ });
157
+ },
158
+ [likeAction, post.id, post.likes],
159
+ );
160
+
161
+ const handleUnlike = useCallback(async () => {
162
+ unlikeAction({
163
+ input: { id: post.id },
164
+ optimistic: {
165
+ likes: Math.max(post.likes - 1, 0),
166
+ },
167
+ view: PostView,
168
+ });
169
+ }, [post.id, post.likes, unlikeAction]);
170
+
171
+ return (
172
+ <Card>
173
+ <VStack gap={16}>
174
+ <Stack alignStart between gap={16} wrap>
175
+ <VStack gap>
176
+ <Link href={`/post/${post.id}`}>
177
+ <H3>{post.title}</H3>
178
+ </Link>
179
+ <Stack alignCenter gap wrap>
180
+ {category ? (
181
+ <Link href={`/category/${category.id}`}>
182
+ <Badge className="bg-blue-50 text-blue-600 transition hover:bg-blue-100 dark:bg-blue-950/50 dark:text-blue-200 dark:hover:bg-blue-900/60">
183
+ {category.name}
184
+ </Badge>
185
+ </Link>
186
+ ) : null}
187
+ {tags.length ? (
188
+ <Stack gap wrap>
189
+ {tags.map(({ node }) => (
190
+ <TagBadge key={node.id} tag={node} />
191
+ ))}
192
+ </Stack>
193
+ ) : null}
194
+ </Stack>
195
+ </VStack>
196
+ <Stack alignCenter gap={12} wrap>
197
+ <Stack
198
+ alignCenter
199
+ className="squircle bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-800 dark:bg-neutral-800 dark:text-white"
200
+ gap
201
+ >
202
+ <span>👍</span>
203
+ <span>
204
+ {post.likes} {post.likes === 1 ? 'like' : 'likes'}
205
+ </span>
206
+ </Stack>
207
+ <Button action={handleLike} disabled={likeIsPending} size="sm" variant="outline">
208
+ Like
209
+ </Button>
210
+ {detail && (
211
+ <Button
212
+ action={() => handleLike({ slow: true })}
213
+ disabled={likeIsPending}
214
+ size="sm"
215
+ variant="outline"
216
+ >
217
+ Like (Slow)
218
+ </Button>
219
+ )}
220
+ {detail && (
221
+ <Button
222
+ action={() => handleLike({ error: 'callSite' })}
223
+ className={cx(
224
+ 'w-32 transition-colors duration-150',
225
+ likeResult?.error ? 'border-red-500 text-red-500 hover:text-red-500' : '',
226
+ )}
227
+ disabled={likeIsPending}
228
+ size="sm"
229
+ variant="outline"
230
+ >
231
+ {likeResult?.error ? 'Oops, try again!' : `Like (Error)`}
232
+ </Button>
233
+ )}
234
+ {detail && (
235
+ <Button
236
+ action={() => handleLike({ error: 'boundary' })}
237
+ disabled={likeIsPending}
238
+ size="sm"
239
+ variant="outline"
240
+ >
241
+ Like (Network Error)
242
+ </Button>
243
+ )}
244
+ {detail && (
245
+ <Button
246
+ onClick={() =>
247
+ fate.mutations.post.like({
248
+ input: { id: post.id },
249
+ optimistic: { likes: post.likes + 1 },
250
+ view: PostView,
251
+ })
252
+ }
253
+ size="sm"
254
+ variant="outline"
255
+ >
256
+ Like (Many)
257
+ </Button>
258
+ )}
259
+ <Button
260
+ action={handleUnlike}
261
+ disabled={unlikeIsPending || post.likes === 0}
262
+ size="sm"
263
+ variant="outline"
264
+ >
265
+ Unlike
266
+ </Button>
267
+ </Stack>
268
+ </Stack>
269
+ <p className="text-sm leading-relaxed text-foreground/90 lg:text-base">{post.content}</p>
270
+ <p className="text-sm text-muted-foreground">- {author?.name ?? 'Unknown author'}</p>
271
+ <VStack gap={16}>
272
+ <h4 className="text-base font-semibold text-foreground">
273
+ {post.commentCount} {post.commentCount === 1 ? 'Comment' : 'Comments'}
274
+ </h4>
275
+ {comments.length > 0 ? (
276
+ <VStack gap={12}>
277
+ {comments.map(({ node }) => (
278
+ <CommentCard comment={node} key={node.id} post={post} />
279
+ ))}
280
+ {loadNext ? (
281
+ <Button onClick={loadNext} variant="ghost">
282
+ Load more comments
283
+ </Button>
284
+ ) : null}
285
+ </VStack>
286
+ ) : null}
287
+ <ErrorBoundary fallbackRender={({ error }) => <CommentInput error={error} post={post} />}>
288
+ <CommentInput post={post} />
289
+ </ErrorBoundary>
290
+ </VStack>
291
+ </VStack>
292
+ </Card>
293
+ );
294
+ }
@@ -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,23 @@
1
+ import { useView, view, ViewRef } from 'react-fate';
2
+ import { Tag } from '../fate/server.ts';
3
+ import { Badge } from './Badge.tsx';
4
+
5
+ export const TagView = view<Tag>()({
6
+ description: true,
7
+ id: true,
8
+ name: true,
9
+ });
10
+
11
+ export default function TagBadge({ tag: tagRef }: { tag: ViewRef<'Tag'> }) {
12
+ const tag = useView(TagView, tagRef);
13
+
14
+ if (!tag) {
15
+ return null;
16
+ }
17
+
18
+ return (
19
+ <Badge className="bg-secondary/70 text-secondary-foreground" variant="secondary">
20
+ #{tag.name}
21
+ </Badge>
22
+ );
23
+ }