create-fate 1.0.4 → 1.3.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 (206) hide show
  1. package/README.md +3 -0
  2. package/bin/create-fate.mjs +594 -9
  3. package/package.json +2 -2
  4. package/templates/fate/README.md +3 -0
  5. package/templates/fate/_shared/vue-app/package.json +54 -0
  6. package/templates/fate/_shared/vue-app/pages/index.vue +57 -0
  7. package/templates/fate/_shared/vue-app/pages/layout.vue +32 -0
  8. package/templates/fate/_shared/vue-app/pages/post/[id].vue +24 -0
  9. package/templates/fate/_shared/vue-app/src/App.css +63 -0
  10. package/templates/fate/_shared/vue-app/src/fateViews.ts +14 -0
  11. package/templates/fate/_shared/vue-app/src/lib/cx.ts +6 -0
  12. package/templates/fate/_shared/vue-app/src/lib/env.ts +3 -0
  13. package/templates/fate/_shared/vue-app/src/ui/Button.vue +67 -0
  14. package/templates/fate/_shared/vue-app/src/ui/Card.vue +16 -0
  15. package/templates/fate/_shared/vue-app/src/ui/Error.vue +9 -0
  16. package/templates/fate/_shared/vue-app/src/ui/ErrorBoundary.vue +22 -0
  17. package/templates/fate/_shared/vue-app/src/ui/Header.vue +35 -0
  18. package/templates/fate/_shared/vue-app/src/ui/Link.vue +15 -0
  19. package/templates/fate/_shared/vue-app/src/ui/PostCard.vue +29 -0
  20. package/templates/fate/_shared/vue-app/src/ui/Section.vue +19 -0
  21. package/templates/fate/_shared/vue-app/src/ui/Thinking.vue +11 -0
  22. package/templates/fate/_shared/vue-app/src/ui/UserCard.vue +23 -0
  23. package/templates/fate/_shared/vue-app/src/vue-shims.d.ts +6 -0
  24. package/templates/fate/_shared/vue-app/tsconfig.json +22 -0
  25. package/templates/fate/_shared/vue-app/vite.config.ts +58 -0
  26. package/templates/fate/_shared/vue-app/void.json +5 -0
  27. package/templates/fate/cloudflare/.github/workflows/push.yml +27 -0
  28. package/templates/fate/cloudflare/.vite-hooks/pre-commit +1 -0
  29. package/templates/fate/cloudflare/.vscode/extensions.json +12 -0
  30. package/templates/fate/cloudflare/.vscode/settings.json +25 -0
  31. package/templates/fate/cloudflare/AGENTS.md +55 -0
  32. package/templates/fate/cloudflare/README.md +126 -0
  33. package/templates/fate/cloudflare/_gitignore +20 -0
  34. package/templates/fate/cloudflare/client/package.json +56 -0
  35. package/templates/fate/cloudflare/client/pages/index.tsx +96 -0
  36. package/templates/fate/cloudflare/client/pages/layout.tsx +82 -0
  37. package/templates/fate/cloudflare/client/pages/login.tsx +107 -0
  38. package/templates/fate/cloudflare/client/pages/post/[id].tsx +22 -0
  39. package/templates/fate/cloudflare/client/pages/search.tsx +99 -0
  40. package/templates/fate/cloudflare/client/public/icon.svg +1 -0
  41. package/templates/fate/cloudflare/client/src/App.css +153 -0
  42. package/templates/fate/cloudflare/client/src/lib/AvailableLanguages.tsx +5 -0
  43. package/templates/fate/cloudflare/client/src/lib/cx.tsx +6 -0
  44. package/templates/fate/cloudflare/client/src/lib/env.tsx +5 -0
  45. package/templates/fate/cloudflare/client/src/ui/Badge.tsx +31 -0
  46. package/templates/fate/cloudflare/client/src/ui/Button.tsx +108 -0
  47. package/templates/fate/cloudflare/client/src/ui/Card.tsx +32 -0
  48. package/templates/fate/cloudflare/client/src/ui/CommentCard.tsx +75 -0
  49. package/templates/fate/cloudflare/client/src/ui/Error.tsx +19 -0
  50. package/templates/fate/cloudflare/client/src/ui/H2.tsx +15 -0
  51. package/templates/fate/cloudflare/client/src/ui/H3.tsx +20 -0
  52. package/templates/fate/cloudflare/client/src/ui/Header.tsx +98 -0
  53. package/templates/fate/cloudflare/client/src/ui/Input.tsx +17 -0
  54. package/templates/fate/cloudflare/client/src/ui/Link.tsx +11 -0
  55. package/templates/fate/cloudflare/client/src/ui/LocaleSwitcher.tsx +52 -0
  56. package/templates/fate/cloudflare/client/src/ui/PostCard.tsx +303 -0
  57. package/templates/fate/cloudflare/client/src/ui/Section.tsx +23 -0
  58. package/templates/fate/cloudflare/client/src/ui/UserCard.tsx +131 -0
  59. package/templates/fate/cloudflare/client/src/user/AuthClient.tsx +6 -0
  60. package/templates/fate/cloudflare/client/translations/de_DE.json +689 -0
  61. package/templates/fate/cloudflare/client/translations/ja_JP.json +689 -0
  62. package/templates/fate/cloudflare/client/vite.config.ts +45 -0
  63. package/templates/fate/cloudflare/client/void.json +29 -0
  64. package/templates/fate/cloudflare/package.json +46 -0
  65. package/templates/fate/cloudflare/pnpm-workspace.yaml +39 -0
  66. package/templates/fate/cloudflare/server/.env.example +1 -0
  67. package/templates/fate/cloudflare/server/db/db.ts +30 -0
  68. package/templates/fate/cloudflare/server/db/migrations/20260508073357_initial_schema.sql +133 -0
  69. package/templates/fate/cloudflare/server/db/migrations/20260508120500_seed_cloudflare_demo.sql +170 -0
  70. package/templates/fate/cloudflare/server/db/migrations/meta/20260508073357_snapshot.json +868 -0
  71. package/templates/fate/cloudflare/server/db/migrations/meta/_journal.json +20 -0
  72. package/templates/fate/cloudflare/server/db/queries.ts +211 -0
  73. package/templates/fate/cloudflare/server/db/schema.ts +332 -0
  74. package/templates/fate/cloudflare/server/package.json +34 -0
  75. package/templates/fate/cloudflare/server/src/fate/commentPermissions.ts +17 -0
  76. package/templates/fate/cloudflare/server/src/fate/context.ts +15 -0
  77. package/templates/fate/cloudflare/server/src/fate/live.ts +7 -0
  78. package/templates/fate/cloudflare/server/src/fate/server.ts +411 -0
  79. package/templates/fate/cloudflare/server/src/fate/views.ts +164 -0
  80. package/templates/fate/cloudflare/server/src/index.ts +80 -0
  81. package/templates/fate/cloudflare/server/src/lib/auth.ts +66 -0
  82. package/templates/fate/cloudflare/server/src/lib/origins.ts +17 -0
  83. package/templates/fate/cloudflare/server/src/router.ts +2 -0
  84. package/templates/fate/cloudflare/server/src/user/SessionUser.tsx +17 -0
  85. package/templates/fate/cloudflare/server/tsconfig.json +10 -0
  86. package/templates/fate/cloudflare/server/wrangler.jsonc +29 -0
  87. package/templates/fate/cloudflare/tsconfig.json +33 -0
  88. package/templates/fate/cloudflare/vite.config.ts +72 -0
  89. package/templates/fate/drizzle/README.md +2 -8
  90. package/templates/fate/drizzle/client/pages/layout.tsx +2 -2
  91. package/templates/fate/drizzle/client/src/ui/PostCard.tsx +38 -15
  92. package/templates/fate/drizzle/pnpm-workspace.yaml +9 -3
  93. package/templates/fate/drizzle/server/vite.config.ts +1 -1
  94. package/templates/fate/graphql/.github/workflows/push.yml +27 -0
  95. package/templates/fate/graphql/.vite-hooks/pre-commit +1 -0
  96. package/templates/fate/graphql/.vscode/extensions.json +12 -0
  97. package/templates/fate/graphql/.vscode/settings.json +25 -0
  98. package/templates/fate/graphql/AGENTS.md +55 -0
  99. package/templates/fate/graphql/README.md +121 -0
  100. package/templates/fate/graphql/_gitignore +23 -0
  101. package/templates/fate/graphql/client/package.json +57 -0
  102. package/templates/fate/graphql/client/pages/index.tsx +96 -0
  103. package/templates/fate/graphql/client/pages/layout.tsx +83 -0
  104. package/templates/fate/graphql/client/pages/login.tsx +107 -0
  105. package/templates/fate/graphql/client/pages/post/[id].tsx +22 -0
  106. package/templates/fate/graphql/client/pages/search.tsx +99 -0
  107. package/templates/fate/graphql/client/public/icon.svg +1 -0
  108. package/templates/fate/graphql/client/src/App.css +153 -0
  109. package/templates/fate/graphql/client/src/lib/AvailableLanguages.tsx +5 -0
  110. package/templates/fate/graphql/client/src/lib/cx.tsx +6 -0
  111. package/templates/fate/graphql/client/src/lib/env.tsx +5 -0
  112. package/templates/fate/graphql/client/src/ui/Badge.tsx +31 -0
  113. package/templates/fate/graphql/client/src/ui/Button.tsx +108 -0
  114. package/templates/fate/graphql/client/src/ui/Card.tsx +32 -0
  115. package/templates/fate/graphql/client/src/ui/CommentCard.tsx +75 -0
  116. package/templates/fate/graphql/client/src/ui/Error.tsx +19 -0
  117. package/templates/fate/graphql/client/src/ui/H2.tsx +15 -0
  118. package/templates/fate/graphql/client/src/ui/H3.tsx +20 -0
  119. package/templates/fate/graphql/client/src/ui/Header.tsx +98 -0
  120. package/templates/fate/graphql/client/src/ui/Input.tsx +17 -0
  121. package/templates/fate/graphql/client/src/ui/Link.tsx +11 -0
  122. package/templates/fate/graphql/client/src/ui/LocaleSwitcher.tsx +52 -0
  123. package/templates/fate/graphql/client/src/ui/PostCard.tsx +303 -0
  124. package/templates/fate/graphql/client/src/ui/Section.tsx +23 -0
  125. package/templates/fate/graphql/client/src/ui/UserCard.tsx +131 -0
  126. package/templates/fate/graphql/client/src/user/AuthClient.tsx +6 -0
  127. package/templates/fate/graphql/client/translations/de_DE.json +689 -0
  128. package/templates/fate/graphql/client/translations/ja_JP.json +689 -0
  129. package/templates/fate/graphql/client/vite.config.ts +40 -0
  130. package/templates/fate/graphql/client/void.json +29 -0
  131. package/templates/fate/graphql/docker-compose.yml +11 -0
  132. package/templates/fate/graphql/package.json +47 -0
  133. package/templates/fate/graphql/pnpm-workspace.yaml +45 -0
  134. package/templates/fate/graphql/server/.env.example +5 -0
  135. package/templates/fate/graphql/server/README.md +27 -0
  136. package/templates/fate/graphql/server/package.json +64 -0
  137. package/templates/fate/graphql/server/prisma.config.ts +17 -0
  138. package/templates/fate/graphql/server/scripts/generate-graphql.tsx +40 -0
  139. package/templates/fate/graphql/server/src/__tests__/index.test.tsx +5 -0
  140. package/templates/fate/graphql/server/src/app.tsx +47 -0
  141. package/templates/fate/graphql/server/src/graphql/builder.tsx +121 -0
  142. package/templates/fate/graphql/server/src/graphql/context.tsx +22 -0
  143. package/templates/fate/graphql/server/src/graphql/fate.ts +85 -0
  144. package/templates/fate/graphql/server/src/graphql/lib/authDirectives.tsx +93 -0
  145. package/templates/fate/graphql/server/src/graphql/lib/decodeGlobalID.tsx +12 -0
  146. package/templates/fate/graphql/server/src/graphql/lib/decodeIDOrThrow.tsx +10 -0
  147. package/templates/fate/graphql/server/src/graphql/lib/encodeGlobalID.tsx +5 -0
  148. package/templates/fate/graphql/server/src/graphql/live.tsx +241 -0
  149. package/templates/fate/graphql/server/src/graphql/mutations.tsx +265 -0
  150. package/templates/fate/graphql/server/src/graphql/nodes/Category.tsx +35 -0
  151. package/templates/fate/graphql/server/src/graphql/nodes/Comment.tsx +46 -0
  152. package/templates/fate/graphql/server/src/graphql/nodes/Event.tsx +71 -0
  153. package/templates/fate/graphql/server/src/graphql/nodes/Post.tsx +45 -0
  154. package/templates/fate/graphql/server/src/graphql/nodes/Tag.tsx +11 -0
  155. package/templates/fate/graphql/server/src/graphql/nodes/User.tsx +28 -0
  156. package/templates/fate/graphql/server/src/graphql/schema.tsx +16 -0
  157. package/templates/fate/graphql/server/src/graphql/views.ts +193 -0
  158. package/templates/fate/graphql/server/src/index.tsx +45 -0
  159. package/templates/fate/graphql/server/src/lib/auth.tsx +31 -0
  160. package/templates/fate/graphql/server/src/lib/env.tsx +3 -0
  161. package/templates/fate/graphql/server/src/lib/origins.ts +18 -0
  162. package/templates/fate/graphql/server/src/prisma/prisma.tsx +9 -0
  163. package/templates/fate/graphql/server/src/prisma/schema.prisma +181 -0
  164. package/templates/fate/graphql/server/src/prisma/seed.tsx +161 -0
  165. package/templates/fate/graphql/server/src/user/SessionUser.tsx +17 -0
  166. package/templates/fate/graphql/server/src/user/isAdmin.tsx +3 -0
  167. package/templates/fate/graphql/server/tsconfig.json +33 -0
  168. package/templates/fate/graphql/server/vite.config.ts +55 -0
  169. package/templates/fate/graphql/tsconfig.json +33 -0
  170. package/templates/fate/graphql/vite.config.ts +80 -0
  171. package/templates/fate/graphql-client/.env.example +3 -0
  172. package/templates/fate/graphql-client/AGENTS.md +3 -0
  173. package/templates/fate/graphql-client/README.md +69 -0
  174. package/templates/fate/graphql-client/_gitignore +14 -0
  175. package/templates/fate/graphql-client/package.json +62 -0
  176. package/templates/fate/graphql-client/pages/index.tsx +69 -0
  177. package/templates/fate/graphql-client/pages/layout.tsx +41 -0
  178. package/templates/fate/graphql-client/pnpm-workspace.yaml +38 -0
  179. package/templates/fate/graphql-client/public/icon.svg +8 -0
  180. package/templates/fate/graphql-client/src/App.css +28 -0
  181. package/templates/fate/graphql-client/src/fate/graphql.ts +52 -0
  182. package/templates/fate/graphql-client/tsconfig.json +30 -0
  183. package/templates/fate/graphql-client/vite.config.ts +87 -0
  184. package/templates/fate/graphql-client/void.json +29 -0
  185. package/templates/fate/http/README.md +2 -8
  186. package/templates/fate/http/client/pages/layout.tsx +2 -2
  187. package/templates/fate/http/client/src/ui/PostCard.tsx +37 -15
  188. package/templates/fate/http/pnpm-workspace.yaml +9 -3
  189. package/templates/fate/http/server/vite.config.ts +1 -1
  190. package/templates/fate/prisma/README.md +2 -8
  191. package/templates/fate/prisma/client/pages/layout.tsx +2 -2
  192. package/templates/fate/prisma/client/src/ui/PostCard.tsx +37 -15
  193. package/templates/fate/prisma/pnpm-workspace.yaml +9 -3
  194. package/templates/fate/prisma/server/vite.config.ts +1 -1
  195. package/templates/fate/prisma/vite.config.ts +1 -1
  196. package/templates/fate/void/package.json +10 -2
  197. package/templates/fate/void/pnpm-workspace.yaml +12 -4
  198. package/templates/fate/void/src/ui/CategoryCard.tsx +3 -7
  199. package/templates/fate/void/src/ui/PostCard.tsx +38 -15
  200. package/templates/fate/void/vite.config.ts +5 -0
  201. package/templates/fate/drizzle/public/fate-logo-dark.svg +0 -4
  202. package/templates/fate/drizzle/public/fate-logo.svg +0 -4
  203. package/templates/fate/http/public/fate-logo-dark.svg +0 -4
  204. package/templates/fate/http/public/fate-logo.svg +0 -4
  205. package/templates/fate/prisma/public/fate-logo-dark.svg +0 -4
  206. package/templates/fate/prisma/public/fate-logo.svg +0 -4
@@ -0,0 +1,98 @@
1
+ import Stack from '@nkzw/stack';
2
+ import { useRouter } from '@void/react';
3
+ import { ChevronLeft, LogIn, LogOut, Search } from 'lucide-react';
4
+ import AuthClient from '../user/AuthClient.tsx';
5
+ import { Button } from './Button.tsx';
6
+ import Link from './Link.tsx';
7
+ import LocaleSwitcher from './LocaleSwitcher.tsx';
8
+
9
+ export default function Header() {
10
+ const router = useRouter();
11
+ const pathname = router.path;
12
+ const { data: session, isPending } = AuthClient.useSession();
13
+
14
+ return (
15
+ <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">
16
+ <Stack
17
+ alignCenter
18
+ between
19
+ className="max-w-8xl relative container mx-auto h-16 px-6 lg:px-8"
20
+ gap={16}
21
+ >
22
+ <Link
23
+ href="/"
24
+ onClick={() =>
25
+ pathname !== '/' && history.state?.idx > 0 ? history.back() : router.visit('/')
26
+ }
27
+ >
28
+ <Stack alignCenter gap={12}>
29
+ <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">
30
+ {pathname === '/' ? (
31
+ <svg
32
+ className="h-6 w-6 text-gray-500"
33
+ preserveAspectRatio="xMidYMax"
34
+ viewBox="0 0 160 160"
35
+ xmlns="http://www.w3.org/2000/svg"
36
+ >
37
+ <path
38
+ 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"
39
+ fill="currentColor"
40
+ style={{ transformBox: 'fill-box', transformOrigin: '50% 50%' }}
41
+ transform="rotate(-31 0 0)"
42
+ />
43
+ </svg>
44
+ ) : (
45
+ <ChevronLeft className="h-6 w-6 text-gray-500" />
46
+ )}
47
+ </div>
48
+ <div className="opacity-100 transition-opacity duration-250 hover:opacity-70">
49
+ <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">
50
+ <span className="italic">fate</span>
51
+ </span>
52
+ <p className="hidden text-xs text-muted-foreground sm:block">
53
+ <fbt desc="fate library tagline">A modern data client for React.</fbt>
54
+ </p>
55
+ </div>
56
+ </Stack>
57
+ </Link>
58
+ <Stack alignCenter>
59
+ <LocaleSwitcher />
60
+ <Button asChild size="sm" variant="ghost">
61
+ <Link to="/search">
62
+ <Search className="h-4 w-4" />
63
+ <span className="hidden sm:inline">
64
+ <fbt desc="Search button">Search</fbt>
65
+ </span>
66
+ </Link>
67
+ </Button>
68
+ <div className="flex w-9 justify-end sm:w-24">
69
+ {session ? (
70
+ <Button asChild className="w-full" size="sm" variant="ghost">
71
+ <Stack
72
+ alignCenter
73
+ as="a"
74
+ className="text-sm font-medium text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-white"
75
+ gap={4}
76
+ onClick={() => AuthClient.signOut()}
77
+ >
78
+ <LogOut className="h-4 w-4" />
79
+ <span className="hidden sm:inline">
80
+ <fbt desc="Logout button">Logout</fbt>
81
+ </span>
82
+ </Stack>
83
+ </Button>
84
+ ) : !isPending ? (
85
+ <Button asChild className="w-full" size="sm" variant="ghost">
86
+ <Link to="/login">
87
+ <LogIn className="h-4 w-4" /> <fbt desc="Login button">Login</fbt>
88
+ </Link>
89
+ </Button>
90
+ ) : (
91
+ <div aria-hidden className="h-9 w-full" />
92
+ )}
93
+ </div>
94
+ </Stack>
95
+ </Stack>
96
+ </header>
97
+ );
98
+ }
@@ -0,0 +1,17 @@
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 bg-background text-foreground focus-visible:ring-ring focus-visible:ring-offset-background flex w-32 squircle border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-900/40',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
@@ -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,52 @@
1
+ import { VStack } from '@nkzw/stack';
2
+ import { cx } from 'class-variance-authority';
3
+ import { useLocaleContext } from 'fbtee';
4
+ import { Languages } from 'lucide-react';
5
+ import AvailableLanguages from '../lib/AvailableLanguages.tsx';
6
+ import { Button } from './Button.tsx';
7
+ import Card from './Card.tsx';
8
+
9
+ export default function LocaleSwitcher() {
10
+ const { locale: currentLocale, setLocale } = useLocaleContext();
11
+
12
+ return (
13
+ <>
14
+ <Button asChild size="sm" variant="ghost">
15
+ <button popoverTarget="locale-switcher" popoverTargetAction="show">
16
+ <Languages className="h-4 w-4" />
17
+ <span className="hidden sm:inline">
18
+ <fbt desc="Locale switcher button">Change Language</fbt>
19
+ </span>
20
+ </button>
21
+ </Button>
22
+ <Card
23
+ className="translate-y-50 justify-self-center border-slate-100 bg-white shadow transition-all dark:bg-black"
24
+ id="locale-switcher"
25
+ popover="auto"
26
+ >
27
+ <VStack alignCenter center className="w-64" gap={12} padding={16}>
28
+ {[...AvailableLanguages.entries()].map(([locale, name]) => (
29
+ <Button
30
+ action={() => {
31
+ setLocale(locale);
32
+ localStorage.setItem('fbtee:locale', locale);
33
+ }}
34
+ asChild
35
+ className={cx(
36
+ 'transition-background border-slate-200 bg-transparent duration-150 hover:border-slate-300 hover:bg-slate-200 dark:hover:bg-slate-900 dark:text-white active:py-2',
37
+ {
38
+ 'bg-slate-100 dark:bg-slate-900': currentLocale === locale,
39
+ },
40
+ )}
41
+ key={locale}
42
+ size="sm"
43
+ variant="outline"
44
+ >
45
+ <a className="w-full flex-1 cursor-pointer p-2">{name}</a>
46
+ </Button>
47
+ ))}
48
+ </VStack>
49
+ </Card>
50
+ </>
51
+ );
52
+ }
@@ -0,0 +1,303 @@
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
+ Suspense,
8
+ startTransition,
9
+ useActionState,
10
+ useCallback,
11
+ useEffect,
12
+ useState,
13
+ } from 'react';
14
+ import { ErrorBoundary } from 'react-error-boundary';
15
+ import {
16
+ defer,
17
+ type Deferred,
18
+ useListView,
19
+ useFateClient,
20
+ useView,
21
+ view,
22
+ ViewRef,
23
+ } from 'react-fate';
24
+ import { Button } from '../ui/Button.tsx';
25
+ import Card from '../ui/Card.tsx';
26
+ import AuthClient from '../user/AuthClient.tsx';
27
+ import CommentCard, { CommentView } from './CommentCard.tsx';
28
+ import H3 from './H3.tsx';
29
+ import Link from './Link.tsx';
30
+ import { UserView } from './UserCard.tsx';
31
+
32
+ const CommentConnectionView = {
33
+ args: { first: 3 },
34
+ items: {
35
+ node: CommentView,
36
+ },
37
+ };
38
+
39
+ export const PostView = view<Post>()({
40
+ author: UserView,
41
+ commentCount: true,
42
+ comments: defer(CommentConnectionView),
43
+ content: true,
44
+ id: true,
45
+ likes: true,
46
+ title: true,
47
+ });
48
+
49
+ const PostComments = ({
50
+ comments,
51
+ post,
52
+ }: {
53
+ comments: Deferred<{ items: ReadonlyArray<{ node: ViewRef<'Comment'> }> }>;
54
+ post: { commentCount: number; id: string; title: string };
55
+ }) => {
56
+ const [items, loadNext] = useListView(CommentConnectionView, comments);
57
+
58
+ return items.length > 0 ? (
59
+ <VStack gap={12}>
60
+ {items.map(({ node }) => (
61
+ <CommentCard comment={node} key={node.id} post={post} />
62
+ ))}
63
+ {loadNext ? (
64
+ <Button onClick={loadNext} variant="ghost">
65
+ <fbt desc="Load more comments button">Load more comments</fbt>
66
+ </Button>
67
+ ) : null}
68
+ </VStack>
69
+ ) : null;
70
+ };
71
+
72
+ const CommentInput = ({
73
+ error,
74
+ post,
75
+ }: {
76
+ error?: unknown;
77
+ post: { commentCount: number; id: string };
78
+ }) => {
79
+ const fate = useFateClient();
80
+ const { data: session } = AuthClient.useSession();
81
+ const user = session?.user;
82
+ const [commentText, setCommentText] = useState('');
83
+
84
+ const [addCommentResult, handleAddComment, addCommentIsPending] = useActionState(async () => {
85
+ const content = commentText.trim();
86
+
87
+ if (!content) {
88
+ return;
89
+ }
90
+
91
+ const result = await fate.mutations.comment.add({
92
+ input: { content, postId: post.id },
93
+ optimistic: {
94
+ author: user
95
+ ? {
96
+ id: user.id,
97
+ name: user.name ?? 'Anonymous',
98
+ }
99
+ : null,
100
+ content,
101
+ id: `optimistic:${Date.now().toString(36)}`,
102
+ post: { commentCount: post.commentCount + 1, id: post.id },
103
+ },
104
+ view: view<InlineComment>()({
105
+ ...CommentView,
106
+ post: { commentCount: true },
107
+ }),
108
+ });
109
+
110
+ setCommentText('');
111
+
112
+ return result;
113
+ }, null);
114
+
115
+ const maybeSubmitComment = (event: KeyboardEvent<HTMLTextAreaElement>) => {
116
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
117
+ startTransition(() => handleAddComment());
118
+ }
119
+ };
120
+
121
+ const commentingIsDisabled = addCommentIsPending || commentText.trim().length === 0;
122
+
123
+ const anyServerError = error || addCommentResult?.error;
124
+
125
+ return (
126
+ <VStack action={handleAddComment} as="form" gap>
127
+ <span className="text-sm font-medium text-foreground">
128
+ <fbt desc="Comment add label">Add a comment</fbt>
129
+ </span>
130
+ <textarea
131
+ 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"
132
+ disabled={addCommentIsPending}
133
+ onChange={(event) => setCommentText(event.target.value)}
134
+ onKeyDown={maybeSubmitComment}
135
+ placeholder={fbs('Share your thoughts...', 'Placeholder text for comment input')}
136
+ value={commentText}
137
+ />
138
+ {anyServerError ? (
139
+ <p className="text-sm text-destructive">
140
+ {anyServerError instanceof Error ? (
141
+ anyServerError.message
142
+ ) : (
143
+ <fbt desc="Error text">Something went wrong. Please try again.</fbt>
144
+ )}
145
+ </p>
146
+ ) : null}
147
+ <Stack end gap>
148
+ <Button disabled={commentingIsDisabled} size="sm" type="submit" variant="secondary">
149
+ <fbt desc="Post comment button">Post comment</fbt>
150
+ </Button>
151
+ </Stack>
152
+ </VStack>
153
+ );
154
+ };
155
+
156
+ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
157
+ const fate = useFateClient();
158
+ const post = useView(PostView, postRef);
159
+ const author = useView(UserView, post.author);
160
+
161
+ const [likeResult, likeAction, likeIsPending] = useActionState(fate.actions.post.like, null);
162
+
163
+ const [, unlikeAction, unlikeIsPending] = useActionState(fate.actions.post.unlike, null);
164
+
165
+ useEffect(() => {
166
+ if (likeResult?.error) {
167
+ const timeout = setTimeout(() => startTransition(() => likeAction('reset')), 3000);
168
+ return () => clearTimeout(timeout);
169
+ }
170
+ }, [likeAction, likeResult]);
171
+
172
+ const handleLike = useCallback(
173
+ async (options?: { error?: 'boundary' | 'callSite'; slow?: boolean }) => {
174
+ likeAction({
175
+ input: { id: post.id, ...options },
176
+ optimistic: { likes: post.likes + 1 },
177
+ view: PostView,
178
+ });
179
+ },
180
+ [likeAction, post.id, post.likes],
181
+ );
182
+
183
+ const handleUnlike = useCallback(async () => {
184
+ unlikeAction({
185
+ input: { id: post.id },
186
+ optimistic: {
187
+ likes: Math.max(post.likes - 1, 0),
188
+ },
189
+ view: PostView,
190
+ });
191
+ }, [post.id, post.likes, unlikeAction]);
192
+
193
+ return (
194
+ <Card>
195
+ <VStack gap={16}>
196
+ <Stack alignStart between gap={16} wrap>
197
+ <VStack gap>
198
+ <Link to={`/post/${post.id}`}>
199
+ <H3>{post.title}</H3>
200
+ </Link>
201
+ </VStack>
202
+ <Stack alignCenter gap={12} wrap>
203
+ <Stack
204
+ alignCenter
205
+ className="squircle bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-800 dark:bg-neutral-800 dark:text-white"
206
+ gap
207
+ >
208
+ <span>👍</span>
209
+ <span>
210
+ <fbt desc="Like count">
211
+ <fbt:plural count={post.likes} many="Likes" showCount="yes">
212
+ Like
213
+ </fbt:plural>
214
+ </fbt>
215
+ </span>
216
+ </Stack>
217
+ <Button action={handleLike} disabled={likeIsPending} size="sm" variant="outline">
218
+ <fbt desc="Like button">Like</fbt>
219
+ </Button>
220
+ {detail && (
221
+ <Button
222
+ action={() => handleLike({ slow: true })}
223
+ disabled={likeIsPending}
224
+ size="sm"
225
+ variant="outline"
226
+ >
227
+ <fbt desc="Like button with a slow request">Like (Slow)</fbt>
228
+ </Button>
229
+ )}
230
+ {detail && (
231
+ <Button
232
+ action={() => handleLike({ error: 'callSite' })}
233
+ className={cx(
234
+ 'w-32 transition-colors duration-150',
235
+ likeResult?.error ? 'border-red-500 text-red-500 hover:text-red-500' : '',
236
+ )}
237
+ disabled={likeIsPending}
238
+ size="sm"
239
+ variant="outline"
240
+ >
241
+ {likeResult?.error ? (
242
+ <fbt desc="Very short error messages">Oops, try again!</fbt>
243
+ ) : (
244
+ <fbt desc="Like button that triggers an error">Like (Error)</fbt>
245
+ )}
246
+ </Button>
247
+ )}
248
+ {detail && (
249
+ <Button
250
+ action={() => handleLike({ error: 'boundary' })}
251
+ disabled={likeIsPending}
252
+ size="sm"
253
+ variant="outline"
254
+ >
255
+ <fbt desc="Like button that triggers a network error">Like (Network Error)</fbt>
256
+ </Button>
257
+ )}
258
+ {detail && (
259
+ <Button
260
+ onClick={() =>
261
+ fate.mutations.post.like({
262
+ input: { id: post.id },
263
+ optimistic: { likes: post.likes + 1 },
264
+ view: PostView,
265
+ })
266
+ }
267
+ size="sm"
268
+ variant="outline"
269
+ >
270
+ <fbt desc="Like button that can be pressed many times">Like (Many)</fbt>
271
+ </Button>
272
+ )}
273
+ <Button
274
+ action={handleUnlike}
275
+ disabled={unlikeIsPending || post.likes === 0}
276
+ size="sm"
277
+ variant="outline"
278
+ >
279
+ <fbt desc="Unlike button">Unlike</fbt>
280
+ </Button>
281
+ </Stack>
282
+ </Stack>
283
+ <p className="text-sm leading-relaxed text-foreground/90 lg:text-base">{post.content}</p>
284
+ <p className="text-sm text-muted-foreground">- {author?.name ?? 'Unknown author'}</p>
285
+ <VStack gap={16}>
286
+ <h4 className="text-base font-semibold text-foreground">
287
+ <fbt desc="Comment headline">
288
+ <fbt:plural count={post.commentCount} many="Comments" showCount="ifMany">
289
+ One Comment
290
+ </fbt:plural>
291
+ </fbt>
292
+ </h4>
293
+ <Suspense fallback={null}>
294
+ <PostComments comments={post.comments} post={post} />
295
+ </Suspense>
296
+ <ErrorBoundary fallbackRender={({ error }) => <CommentInput error={error} post={post} />}>
297
+ <CommentInput post={post} />
298
+ </ErrorBoundary>
299
+ </VStack>
300
+ </VStack>
301
+ </Card>
302
+ );
303
+ }
@@ -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
+ });