create-fate 1.4.0 → 1.5.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 (89) hide show
  1. package/bin/create-fate.mjs +30 -1
  2. package/package.json +1 -1
  3. package/templates/fate/cloudflare/README.md +11 -0
  4. package/templates/fate/cloudflare/client/package.json +10 -5
  5. package/templates/fate/cloudflare/client/pages/layout.tsx +2 -16
  6. package/templates/fate/cloudflare/client/src/lib/AvailableLanguages.tsx +3 -3
  7. package/templates/fate/cloudflare/client/src/lib/LocaleContext.tsx +61 -0
  8. package/templates/fate/cloudflare/client/src/ui/LocaleSwitcher.tsx +26 -44
  9. package/templates/fate/cloudflare/client/start.mjs +11 -0
  10. package/templates/fate/cloudflare/client/translations/{de_DE.json → de-DE.json} +56 -113
  11. package/templates/fate/{graphql/client/translations/ja_JP.json → cloudflare/client/translations/ja-JP.json} +56 -113
  12. package/templates/fate/cloudflare/client/vite.config.ts +1 -1
  13. package/templates/fate/cloudflare/server/src/lib/auth.ts +0 -2
  14. package/templates/fate/cloudflare/server/tsconfig.json +1 -1
  15. package/templates/fate/cloudflare/tsconfig.json +1 -1
  16. package/templates/fate/drizzle/README.md +11 -0
  17. package/templates/fate/drizzle/client/package.json +9 -5
  18. package/templates/fate/drizzle/client/pages/layout.tsx +2 -16
  19. package/templates/fate/drizzle/client/src/lib/AvailableLanguages.tsx +3 -3
  20. package/templates/fate/drizzle/client/src/lib/LocaleContext.tsx +61 -0
  21. package/templates/fate/drizzle/client/src/ui/LocaleSwitcher.tsx +26 -44
  22. package/templates/fate/drizzle/client/start.mjs +11 -0
  23. package/templates/fate/{graphql/client/translations/de_DE.json → drizzle/client/translations/de-DE.json} +56 -113
  24. package/templates/fate/{cloudflare/client/translations/ja_JP.json → drizzle/client/translations/ja-JP.json} +56 -113
  25. package/templates/fate/drizzle/client/vite.config.ts +1 -1
  26. package/templates/fate/graphql/README.md +11 -0
  27. package/templates/fate/graphql/client/package.json +9 -5
  28. package/templates/fate/graphql/client/pages/layout.tsx +2 -16
  29. package/templates/fate/graphql/client/src/lib/AvailableLanguages.tsx +3 -3
  30. package/templates/fate/graphql/client/src/lib/LocaleContext.tsx +61 -0
  31. package/templates/fate/graphql/client/src/ui/LocaleSwitcher.tsx +26 -44
  32. package/templates/fate/graphql/client/start.mjs +11 -0
  33. package/templates/fate/{drizzle/client/translations/de_DE.json → graphql/client/translations/de-DE.json} +56 -113
  34. package/templates/fate/{http/client/translations/ja_JP.json → graphql/client/translations/ja-JP.json} +56 -113
  35. package/templates/fate/graphql/client/vite.config.ts +1 -1
  36. package/templates/fate/graphql-client/README.md +9 -0
  37. package/templates/fate/graphql-client/_gitignore +3 -0
  38. package/templates/fate/graphql-client/package.json +9 -2
  39. package/templates/fate/graphql-client/pages/index.tsx +13 -5
  40. package/templates/fate/graphql-client/pages/layout.tsx +22 -15
  41. package/templates/fate/graphql-client/src/lib/AvailableLanguages.tsx +5 -0
  42. package/templates/fate/graphql-client/src/lib/LocaleContext.tsx +61 -0
  43. package/templates/fate/graphql-client/src/ui/LocaleSwitcher.tsx +34 -0
  44. package/templates/fate/graphql-client/translations/de-DE.json +225 -0
  45. package/templates/fate/graphql-client/translations/ja-JP.json +225 -0
  46. package/templates/fate/graphql-client/vite.config.ts +2 -0
  47. package/templates/fate/http/README.md +11 -0
  48. package/templates/fate/http/client/package.json +9 -5
  49. package/templates/fate/http/client/pages/layout.tsx +2 -16
  50. package/templates/fate/http/client/src/lib/AvailableLanguages.tsx +3 -3
  51. package/templates/fate/http/client/src/lib/LocaleContext.tsx +61 -0
  52. package/templates/fate/http/client/src/ui/LocaleSwitcher.tsx +26 -44
  53. package/templates/fate/http/client/start.mjs +11 -0
  54. package/templates/fate/http/client/translations/{de_DE.json → de-DE.json} +56 -113
  55. package/templates/fate/{drizzle/client/translations/ja_JP.json → http/client/translations/ja-JP.json} +56 -113
  56. package/templates/fate/http/client/vite.config.ts +1 -1
  57. package/templates/fate/prisma/README.md +11 -0
  58. package/templates/fate/prisma/client/package.json +9 -5
  59. package/templates/fate/prisma/client/pages/layout.tsx +2 -16
  60. package/templates/fate/prisma/client/src/lib/AvailableLanguages.tsx +3 -3
  61. package/templates/fate/prisma/client/src/lib/LocaleContext.tsx +61 -0
  62. package/templates/fate/prisma/client/src/ui/LocaleSwitcher.tsx +26 -44
  63. package/templates/fate/prisma/client/start.mjs +11 -0
  64. package/templates/fate/prisma/client/translations/de-DE.json +632 -0
  65. package/templates/fate/prisma/client/translations/ja-JP.json +632 -0
  66. package/templates/fate/prisma/client/vite.config.ts +1 -1
  67. package/templates/fate/void/README.md +9 -0
  68. package/templates/fate/void/_gitignore +3 -0
  69. package/templates/fate/void/middleware/01.shared.ts +3 -0
  70. package/templates/fate/void/package.json +9 -2
  71. package/templates/fate/void/pages/index.tsx +22 -10
  72. package/templates/fate/void/pages/layout.tsx +22 -19
  73. package/templates/fate/void/pages/login.tsx +22 -18
  74. package/templates/fate/void/pages/search.tsx +18 -4
  75. package/templates/fate/void/src/lib/AvailableLanguages.tsx +5 -0
  76. package/templates/fate/void/src/lib/LocaleContext.tsx +61 -0
  77. package/templates/fate/void/src/ui/CategoryCard.tsx +22 -4
  78. package/templates/fate/void/src/ui/CreatePost.tsx +12 -7
  79. package/templates/fate/void/src/ui/Error.tsx +4 -2
  80. package/templates/fate/void/src/ui/EventCard.tsx +18 -5
  81. package/templates/fate/void/src/ui/Header.tsx +10 -4
  82. package/templates/fate/void/src/ui/LocaleSwitcher.tsx +34 -0
  83. package/templates/fate/void/src/ui/PostCard.tsx +29 -14
  84. package/templates/fate/void/src/ui/UserCard.tsx +15 -8
  85. package/templates/fate/void/translations/de-DE.json +863 -0
  86. package/templates/fate/void/translations/ja-JP.json +863 -0
  87. package/templates/fate/void/vite.config.ts +2 -0
  88. package/templates/fate/prisma/client/translations/de_DE.json +0 -689
  89. package/templates/fate/prisma/client/translations/ja_JP.json +0 -689
@@ -10,13 +10,17 @@
10
10
  },
11
11
  "type": "module",
12
12
  "scripts": {
13
- "build": "vp build",
13
+ "build": "vp run fbtee:all && vp build",
14
14
  "db:generate": "void db generate",
15
15
  "db:migrate": "void db migrate",
16
16
  "db:seed": "void db seed --file db/seed.ts",
17
17
  "dev": "vp dev",
18
- "dev:setup": "vp run prepare:void && vp run db:seed && vp run fate:generate",
18
+ "dev:setup": "vp run fbtee:all && vp run prepare:void && vp run db:seed && vp run fate:generate",
19
19
  "fate:generate": "vp exec fate generate",
20
+ "fbtee:all": "vp run fbtee:collect && vp run fbtee:translate",
21
+ "fbtee:collect": "vp exec fbtee collect",
22
+ "fbtee:prepare": "vp run fbtee:collect && vp exec fbtee prepare-translations --source-strings source_strings.json --output-dir translations --locales de-DE ja-JP",
23
+ "fbtee:translate": "vp exec fbtee translate --source-strings source_strings.json --output-dir src/translations --output-locale-style=bcp47",
20
24
  "prepare:void": "void prepare",
21
25
  "test:all": "vp check && vp test"
22
26
  },
@@ -30,6 +34,7 @@
30
34
  "class-variance-authority": "^0.7.1",
31
35
  "clsx": "^2.1.1",
32
36
  "drizzle-orm": "^0.45.2",
37
+ "fbtee": "^4.0.0",
33
38
  "lucide-react": "^1.34.0",
34
39
  "react": "^19.2.8",
35
40
  "react-dom": "^19.2.8",
@@ -43,7 +48,9 @@
43
48
  "devDependencies": {
44
49
  "@cloudflare/workers-types": "4.20260702.1",
45
50
  "@nkzw/eslint-plugin": "^2.0.0",
51
+ "@nkzw/fbtee-cli": "^4.0.0",
46
52
  "@nkzw/oxlint-config": "^2.0.1",
53
+ "@nkzw/vite-plugin-fbtee": "^4.0.0",
47
54
  "@oxc-node/core": "^0.1.0",
48
55
  "@tailwindcss/vite": "^4.3.3",
49
56
  "@types/node": "^26.4.0",
@@ -38,7 +38,9 @@ const PostFeed = ({
38
38
  return posts.length ? (
39
39
  <VStack gap={16}>
40
40
  <Stack alignCenter between gap={16}>
41
- <H2 className="pl-5">Latest posts</H2>
41
+ <H2 className="pl-5">
42
+ <fbt desc="Latest posts header">Latest posts</fbt>
43
+ </H2>
42
44
  <div>
43
45
  <button
44
46
  className={cx(
@@ -66,7 +68,7 @@ const PostFeed = ({
66
68
  {loadNext ? (
67
69
  <Stack center>
68
70
  <Button onClick={loadNext} variant="ghost">
69
- Load more posts
71
+ <fbt desc="Load more posts button">Load more posts</fbt>{' '}
70
72
  </Button>
71
73
  </Stack>
72
74
  ) : null}
@@ -78,7 +80,9 @@ const PostFeed = ({
78
80
  const CategoryFeed = ({ categories }: { categories: Array<ViewRef<'Category'>> }) =>
79
81
  categories.length ? (
80
82
  <VStack gap={16}>
81
- <H2 className="pl-5">Explore by theme</H2>
83
+ <H2 className="pl-5">
84
+ <fbt desc="index: Explore by theme">Explore by theme</fbt>
85
+ </H2>
82
86
  <VStack gap={24}>
83
87
  {categories.map((category) => (
84
88
  <CategoryCard category={category} key={category.id} />
@@ -90,7 +94,9 @@ const CategoryFeed = ({ categories }: { categories: Array<ViewRef<'Category'>> }
90
94
  const EventFeed = ({ events }: { events: Array<ViewRef<'Event'>> }) =>
91
95
  events.length ? (
92
96
  <VStack gap={16}>
93
- <H2 className="pl-5">Events</H2>
97
+ <H2 className="pl-5">
98
+ <fbt desc="index: Events">Events</fbt>
99
+ </H2>
94
100
  <VStack gap={24}>
95
101
  {events.map((event) => (
96
102
  <EventCard event={event} key={event.id} />
@@ -115,16 +121,20 @@ export default function HomePage() {
115
121
  <Card className="border border-white/20 bg-linear-to-r from-blue-500 to-sky-500 text-white dark:from-blue-600 dark:to-sky-600">
116
122
  <Stack alignCenter gap={12} wrap>
117
123
  <span className="squircle bg-white/20 px-2 py-1 text-xs font-semibold tracking-widest uppercase">
118
- <span className="lowercase italic">fate</span> demo
124
+ <span className="lowercase italic">fate</span> <fbt desc="index: demo">demo</fbt>{' '}
119
125
  </span>
120
126
  </Stack>
121
127
  <div className="space-y-3">
122
128
  <h1 className="text-3xl leading-tight font-semibold text-balance lg:text-4xl">
123
- fate is a modern data client for React inspired by Relay and GraphQL.
129
+ <fbt desc="Description of fate">
130
+ fate is a modern data client for React inspired by Relay and GraphQL.
131
+ </fbt>{' '}
124
132
  </h1>
125
133
  <p className="text-sm text-white/80 lg:text-base">
126
- fate combines view composition, normalized caching, data masking, Async React
127
- features, and type-safe data fetching.
134
+ <fbt desc="index: fate combines view composition, normalized caching, data masking, Async React features, and type-safe data fetching.">
135
+ fate combines view composition, normalized caching, data masking, Async React
136
+ features, and type-safe data fetching.
137
+ </fbt>{' '}
128
138
  </p>
129
139
  </div>
130
140
  <Stack alignCenter gap={12} wrap>
@@ -132,10 +142,12 @@ export default function HomePage() {
132
142
  <>
133
143
  <Button asChild size="sm" variant="secondary">
134
144
  <Link className="squircle px-4 py-2 text-sm font-semibold" href="/login">
135
- Login
145
+ <fbt desc="Login button">Login</fbt>{' '}
136
146
  </Link>
137
147
  </Button>
138
- <span className="text-sm text-white/80">Sign in to post comments.</span>
148
+ <span className="text-sm text-white/80">
149
+ <fbt desc="Login button label">Sign in to post comments.</fbt>
150
+ </span>
139
151
  </>
140
152
  )}
141
153
  </Stack>
@@ -1,9 +1,10 @@
1
- import '../src/App.css';
2
1
  import Stack from '@nkzw/stack';
2
+ import '../src/App.css';
3
3
  import { useShared } from '@void/react';
4
4
  import { ReactNode, Suspense } from 'react';
5
5
  import { ErrorBoundary } from 'react-error-boundary';
6
6
  import { VoidFateClient } from 'void-fate/react';
7
+ import LocaleContext from '../src/lib/LocaleContext.tsx';
7
8
  import type { SharedData } from '../src/lib/shared.ts';
8
9
  import Card from '../src/ui/Card.tsx';
9
10
  import Error from '../src/ui/Error.tsx';
@@ -13,7 +14,7 @@ import Section from '../src/ui/Section.tsx';
13
14
  const Thinking = () => (
14
15
  <Section>
15
16
  <Stack center className="animate-pulse text-gray-500 italic" verticalPadding={48}>
16
- Thinking...
17
+ <fbt desc="Text for thinking/loading screen">Thinking...</fbt>
17
18
  </Stack>
18
19
  </Section>
19
20
  );
@@ -24,23 +25,25 @@ export default function Layout({ children }: { children: ReactNode }) {
24
25
  const origin = typeof window === 'undefined' ? shared.origin : window.location.origin;
25
26
 
26
27
  return (
27
- <VoidFateClient origin={origin} userId={userId}>
28
- <div className="min-h-screen bg-background text-foreground">
29
- <div className="min-h-screen bg-[radial-gradient(circle_at_20%_20%,rgba(59,130,246,0.08),transparent_35%),radial-gradient(circle_at_80%_0,rgba(99,102,241,0.08),transparent_28%)]">
30
- <Header />
31
- <ErrorBoundary
32
- fallbackRender={({ error }) => (
33
- <Section>
34
- <Card>
35
- <Error error={error} />
36
- </Card>
37
- </Section>
38
- )}
39
- >
40
- <Suspense fallback={<Thinking />}>{children}</Suspense>
41
- </ErrorBoundary>
28
+ <LocaleContext>
29
+ <VoidFateClient origin={origin} userId={userId}>
30
+ <div className="min-h-screen bg-background text-foreground">
31
+ <div className="min-h-screen bg-[radial-gradient(circle_at_20%_20%,rgba(59,130,246,0.08),transparent_35%),radial-gradient(circle_at_80%_0,rgba(99,102,241,0.08),transparent_28%)]">
32
+ <Header />
33
+ <ErrorBoundary
34
+ fallbackRender={({ error }) => (
35
+ <Section>
36
+ <Card>
37
+ <Error error={error} />
38
+ </Card>
39
+ </Section>
40
+ )}
41
+ >
42
+ <Suspense fallback={<Thinking />}>{children}</Suspense>
43
+ </ErrorBoundary>
44
+ </div>
42
45
  </div>
43
- </div>
44
- </VoidFateClient>
46
+ </VoidFateClient>
47
+ </LocaleContext>
45
48
  );
46
49
  }
@@ -1,5 +1,6 @@
1
1
  import Stack, { VStack } from '@nkzw/stack';
2
2
  import { useRouter, useShared } from '@void/react';
3
+ import { fbs } from 'fbtee';
3
4
  import { ExternalLinkIcon } from 'lucide-react';
4
5
  import { useActionState, useEffect, useState } from 'react';
5
6
  import type { SharedData } from '../src/lib/shared.ts';
@@ -49,7 +50,9 @@ export default function LoginPage() {
49
50
  return (
50
51
  <Section>
51
52
  <VStack center gap={16}>
52
- <H2 className="pl-5">Sign In</H2>
53
+ <H2 className="pl-5">
54
+ <fbt desc="Sign in button">Sign In</fbt>
55
+ </H2>
53
56
  <Stack gap={32} wrap>
54
57
  <Card className="w-84">
55
58
  <Stack gap vertical>
@@ -58,7 +61,7 @@ export default function LoginPage() {
58
61
  className="w-48"
59
62
  name="email"
60
63
  onChange={(e) => setEmail(e.target.value)}
61
- placeholder="email"
64
+ placeholder={fbs('email', 'login: placeholder')}
62
65
  type="email"
63
66
  value={email}
64
67
  />
@@ -66,13 +69,13 @@ export default function LoginPage() {
66
69
  className="w-48"
67
70
  name="password"
68
71
  onChange={(e) => setPassword(e.target.value)}
69
- placeholder="password"
72
+ placeholder={fbs('password', 'login: placeholder')}
70
73
  type="password"
71
74
  value={password}
72
75
  />
73
76
  <div>
74
77
  <AsyncButton type="submit" variant="outline">
75
- Sign In
78
+ <fbt desc="Sign in button">Sign In</fbt>{' '}
76
79
  </AsyncButton>
77
80
  </div>
78
81
  </VStack>
@@ -80,20 +83,21 @@ export default function LoginPage() {
80
83
  </Card>
81
84
  <Card className="w-84">
82
85
  <p>
83
- Try one of the
84
- <Stack
85
- alignCenter
86
- as="a"
87
- className="inline-flex! px-1 underline hover:no-underline"
88
- gap={4}
89
- href="https://github.com/nkzw-tech/fate/blob/main/packages/create-fate/templates/fate/void/seedData.ts#L1"
90
- rel="noreferrer"
91
- target="_blank"
92
- >
93
- Example Accounts
94
- <ExternalLinkIcon className="h-4 w-4" />
95
- </Stack>{' '}
96
- in the seed data.
86
+ <fbt desc="Example account instructions">
87
+ Try one of the{' '}
88
+ <Stack
89
+ alignCenter
90
+ as="a"
91
+ className="inline-flex! px-1 underline hover:no-underline"
92
+ gap={4}
93
+ href="https://github.com/nkzw-tech/fate/blob/main/packages/create-fate/templates/fate/void/seedData.ts#L1"
94
+ rel="noreferrer"
95
+ target="_blank"
96
+ >
97
+ Example Accounts <ExternalLinkIcon className="h-4 w-4" />
98
+ </Stack>{' '}
99
+ in the seed data.
100
+ </fbt>{' '}
97
101
  </p>
98
102
  </Card>
99
103
  </Stack>
@@ -1,4 +1,5 @@
1
1
  import Stack, { VStack } from '@nkzw/stack';
2
+ import { fbs } from 'fbtee';
2
3
  import { Suspense, useDeferredValue, useState } from 'react';
3
4
  import { ErrorBoundary } from 'react-error-boundary';
4
5
  import { useRequest, useView, view, ViewRef } from 'react-fate';
@@ -39,7 +40,12 @@ const SearchResults = ({ isStale, query }: { isStale: boolean; query: string })
39
40
  if (commentSearch.length === 0) {
40
41
  return (
41
42
  <p>
42
- No matches for <i>&quot;{query}&quot;</i>
43
+ <fbt desc="Empty comment search results">
44
+ No matches for{' '}
45
+ <i>
46
+ &quot;<fbt:param name="query">{query}</fbt:param>&quot;
47
+ </i>
48
+ </fbt>
43
49
  </p>
44
50
  );
45
51
  }
@@ -65,17 +71,25 @@ export default function SearchPage() {
65
71
  <Input
66
72
  className="w-64"
67
73
  onChange={(e) => setQuery(e.target.value)}
68
- placeholder="Search comments..."
74
+ placeholder={fbs('Search comments...', 'search: placeholder')}
69
75
  ref={(ref) => ref?.focus()}
70
76
  value={query}
71
77
  />
72
78
  {isDevelopment ? (
73
- <div className="text-xs text-muted-foreground">500ms artificial slowdown</div>
79
+ <div className="text-xs text-muted-foreground">
80
+ <fbt desc="Slowdown label explanation">500ms artificial slowdown</fbt>
81
+ </div>
74
82
  ) : null}
75
83
  </Stack>
76
84
 
77
85
  <ErrorBoundary FallbackComponent={Error}>
78
- <Suspense fallback={<h2>Thinking…</h2>}>
86
+ <Suspense
87
+ fallback={
88
+ <h2>
89
+ <fbt desc="Text for thinking/loading screen">Thinking…</fbt>
90
+ </h2>
91
+ }
92
+ >
79
93
  {query.trim().length > 0 ? <SearchResults isStale={isStale} query={query} /> : null}
80
94
  </Suspense>
81
95
  </ErrorBoundary>
@@ -0,0 +1,5 @@
1
+ export default new Map([
2
+ ['en-US', 'English'],
3
+ ['de-DE', 'Deutsch (German)'],
4
+ ['ja-JP', '日本語 (Japanese)'],
5
+ ]);
@@ -0,0 +1,61 @@
1
+ /// <reference types="fbtee/ReactTypes.d.ts" />
2
+
3
+ import { createLocaleContext, useLocaleContext } from 'fbtee';
4
+ import { ReactNode, startTransition, useEffect, useState } from 'react';
5
+ import AvailableLanguages from './AvailableLanguages.tsx';
6
+
7
+ const Context = createLocaleContext({
8
+ availableLanguages: AvailableLanguages,
9
+ // Keep the server and first client render identical; restore preferences after hydration.
10
+ clientLocales: ['en-US'],
11
+ fallbackLocale: 'en-US',
12
+ loadLocale: async (locale) => {
13
+ if (locale !== 'en-US' && AvailableLanguages.has(locale)) {
14
+ return (await import(`../translations/${locale}.json`)).default[locale];
15
+ }
16
+ return {};
17
+ },
18
+ });
19
+
20
+ function RestoreLocale({ restored, onRestore }: { restored: boolean; onRestore: () => void }) {
21
+ const { locale, setLocale } = useLocaleContext();
22
+ useEffect(() => {
23
+ if (restored) return;
24
+ onRestore();
25
+ let savedLocale: string | null = null;
26
+ try {
27
+ savedLocale = localStorage.getItem('fbtee:locale');
28
+ } catch {
29
+ // Browser storage can be unavailable.
30
+ }
31
+ const preferred = [savedLocale, ...navigator.languages, navigator.language]
32
+ .filter((value): value is string => !!value)
33
+ .map((value) => value.replaceAll('_', '-'));
34
+ const nextLocale = preferred
35
+ .map((value) =>
36
+ AvailableLanguages.has(value)
37
+ ? value
38
+ : [...AvailableLanguages.keys()].find(
39
+ (available) => available.split('-')[0] === value.split('-')[0],
40
+ ),
41
+ )
42
+ .find(Boolean);
43
+ if (nextLocale && nextLocale !== locale) {
44
+ startTransition(() => setLocale(nextLocale));
45
+ }
46
+ }, [locale, setLocale, restored, onRestore]);
47
+ useEffect(() => {
48
+ document.documentElement.lang = locale;
49
+ }, [locale]);
50
+ return null;
51
+ }
52
+
53
+ export default function LocaleContext({ children }: { children: ReactNode }) {
54
+ const [restored, setRestored] = useState(false);
55
+ return (
56
+ <Context>
57
+ <RestoreLocale restored={restored} onRestore={() => setRestored(true)} />
58
+ {children}
59
+ </Context>
60
+ );
61
+ }
@@ -33,11 +33,23 @@ const CategoryPost = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
33
33
  {post.title}
34
34
  </span>
35
35
  </Link>
36
- <span className="text-xs text-muted-foreground">{post.likes} likes</span>
36
+ <span className="text-xs text-muted-foreground">
37
+ <fbt desc="Post like count">
38
+ <fbt:plural count={post.likes} many="likes" showCount="yes">
39
+ like
40
+ </fbt:plural>
41
+ </fbt>
42
+ </span>
37
43
  </Stack>
38
44
  <Stack alignCenter gap wrap>
39
45
  <span className="text-xs text-muted-foreground">
40
- {author?.name ? `by ${author.name}` : 'By an anonymous collaborator'}
46
+ {author?.name ? (
47
+ <fbt desc="Post author">
48
+ by <fbt:param name="author">{author.name}</fbt:param>
49
+ </fbt>
50
+ ) : (
51
+ <fbt desc="Anonymous post author">By an anonymous collaborator</fbt>
52
+ )}
41
53
  </span>
42
54
  {tags.length ? (
43
55
  <Stack gap wrap>
@@ -82,7 +94,11 @@ export default function CategoryCard({ category: categoryRef }: { category: View
82
94
  <p className="text-sm text-muted-foreground">{category.description}</p>
83
95
  </div>
84
96
  <Badge className="text-nowrap" variant="outline">
85
- {category.postCount} posts
97
+ <fbt desc="Category post count">
98
+ <fbt:plural count={category.postCount} many="posts" showCount="yes">
99
+ post
100
+ </fbt:plural>
101
+ </fbt>{' '}
86
102
  </Badge>
87
103
  </Stack>
88
104
  <VStack gap={12}>
@@ -92,7 +108,9 @@ export default function CategoryCard({ category: categoryRef }: { category: View
92
108
  </VStack>
93
109
  {category.posts?.pagination?.hasNext ? (
94
110
  <span className="text-sm text-muted-foreground">
95
- More posts available in this category...
111
+ <fbt desc="CategoryCard: More posts available in this category...">
112
+ More posts available in this category...
113
+ </fbt>{' '}
96
114
  </span>
97
115
  ) : null}
98
116
  </VStack>
@@ -1,4 +1,5 @@
1
1
  import Stack, { VStack } from '@nkzw/stack';
2
+ import { fbs } from 'fbtee';
2
3
  import { KeyboardEvent, startTransition, useActionState, useState } from 'react';
3
4
  import { useFateClient, useView, ViewRef } from 'react-fate';
4
5
  import { Button } from '../ui/Button.tsx';
@@ -64,13 +65,15 @@ export default function CreatePost({ user: userRef }: { user: ViewRef<'User'> |
64
65
  return (
65
66
  <Card>
66
67
  <VStack action={createPost} as="form" gap={16}>
67
- <H3>Create a Post</H3>
68
+ <H3>
69
+ <fbt desc="CreatePost: Create a Post">Create a Post</fbt>
70
+ </H3>
68
71
  <Input
69
72
  className="w-full"
70
73
  disabled={isPending}
71
74
  onChange={(event) => setTitleValue(event.target.value)}
72
75
  onKeyDown={maybeSubmitPost}
73
- placeholder="Post Title"
76
+ placeholder={fbs('Post Title', 'CreatePost: placeholder')}
74
77
  value={titleValue}
75
78
  />
76
79
  <textarea
@@ -78,18 +81,20 @@ export default function CreatePost({ user: userRef }: { user: ViewRef<'User'> |
78
81
  disabled={isPending}
79
82
  onChange={(event) => setContentValue(event.target.value)}
80
83
  onKeyDown={maybeSubmitPost}
81
- placeholder={'Share your thoughts about fate...'}
84
+ placeholder={fbs('Share your thoughts about fate...', 'CreatePost: placeholder')}
82
85
  value={contentValue}
83
86
  />
84
87
  <Stack alignCenter between className="text-sm" gap={16}>
85
- <span className="font-bold">Mutation Debug Options</span>
88
+ <span className="font-bold">
89
+ <fbt desc="CreatePost: Mutation Debug Options">Mutation Debug Options</fbt>
90
+ </span>
86
91
  <Stack alignCenter as="label" gap>
87
92
  <CheckBox
88
93
  checked={missingMutationSelection}
89
94
  disabled={isPending}
90
95
  onChange={(event) => setMissingMutationSelection(event.target.checked)}
91
96
  />
92
- Missing mutation selection
97
+ <fbt desc="CreatePost: Missing mutation selection">Missing mutation selection</fbt>{' '}
93
98
  </Stack>
94
99
  <Stack alignCenter as="label" gap>
95
100
  <CheckBox
@@ -97,12 +102,12 @@ export default function CreatePost({ user: userRef }: { user: ViewRef<'User'> |
97
102
  disabled={isPending}
98
103
  onChange={(event) => setMissingOptimisticContent(event.target.checked)}
99
104
  />
100
- Missing optimistic content
105
+ <fbt desc="CreatePost: Missing optimistic content">Missing optimistic content</fbt>{' '}
101
106
  </Stack>
102
107
  </Stack>
103
108
  <Stack alignCenter end gap={16}>
104
109
  <Button disabled={postingIsDisabled} size="sm" type="submit" variant="secondary">
105
- Post comment
110
+ <fbt desc="Post comment button">Post comment</fbt>{' '}
106
111
  </Button>
107
112
  </Stack>
108
113
  </VStack>
@@ -4,14 +4,16 @@ export default function Error({ error: initialError }: { error: unknown }) {
4
4
  const error = initialError instanceof Error ? (initialError as Error) : null;
5
5
  return (
6
6
  <VStack gap={12}>
7
- <h3 className="text-xl font-semibold text-red-700">Error</h3>
7
+ <h3 className="text-xl font-semibold text-red-700">
8
+ <fbt desc="Error: Error">Error</fbt>
9
+ </h3>
8
10
  <code>{error ? error.stack || `fate Error: ${error.message}` : String(initialError)}</code>
9
11
  <div>
10
12
  <a
11
13
  className="cursor-pointer underline decoration-gray-300 hover:no-underline"
12
14
  onClick={() => window.location.reload()}
13
15
  >
14
- Try again
16
+ <fbt desc="Try again button">Try again</fbt>{' '}
15
17
  </a>
16
18
  </div>
17
19
  </VStack>
@@ -1,4 +1,5 @@
1
1
  import Stack, { VStack } from '@nkzw/stack';
2
+ import { fbs } from 'fbtee';
2
3
  import { ArrowUpRight, CalendarDays, MapPin, Users } from 'lucide-react';
3
4
  import { useEffect, useState } from 'react';
4
5
  import { useView, view, ViewRef } from 'react-fate';
@@ -41,7 +42,7 @@ const EventAttendeeChip = ({ attendee: attendeeRef }: { attendee: ViewRef<'Event
41
42
 
42
43
  return (
43
44
  <Badge className="text-nowrap" key={attendee.id} variant="outline">
44
- {user?.name ?? 'Guest'} · {formatLabel(attendee.status)}
45
+ {user?.name ?? fbs('Guest', 'Unnamed event attendee')} · {formatLabel(attendee.status)}
45
46
  </Badge>
46
47
  );
47
48
  };
@@ -110,12 +111,22 @@ export default function EventCard({ event: eventRef }: { event: ViewRef<'Event'>
110
111
  <Stack alignCenter gap>
111
112
  <Users className="text-muted-foreground" size={14} />
112
113
  <span className="text-sm text-foreground/80">
113
- {event.attendingCount ?? attendees.length} attending · capacity {event.capacity}
114
+ <fbt desc="Event attendance and capacity">
115
+ <fbt:param name="attending">{event.attendingCount ?? attendees.length}</fbt:param>{' '}
116
+ attending · capacity <fbt:param name="capacity">{event.capacity}</fbt:param>
117
+ </fbt>
114
118
  </span>
115
119
  </Stack>
116
120
  <Stack alignCenter gap>
117
121
  <ArrowUpRight className="text-muted-foreground" size={14} />
118
- <span className="text-sm text-foreground/80">Hosted by {host?.name ?? 'Unknown'}</span>
122
+ <span className="text-sm text-foreground/80">
123
+ <fbt desc="Event host">
124
+ Hosted by{' '}
125
+ <fbt:param name="host">
126
+ {host?.name ?? fbs('Unknown', 'Unknown event host')}
127
+ </fbt:param>
128
+ </fbt>
129
+ </span>
119
130
  </Stack>
120
131
  {topics.length ? (
121
132
  <Stack gap wrap>
@@ -128,7 +139,9 @@ export default function EventCard({ event: eventRef }: { event: ViewRef<'Event'>
128
139
  ) : null}
129
140
  {attendees.length ? (
130
141
  <VStack gap>
131
- <span className="text-xs text-muted-foreground">Community RSVPs</span>
142
+ <span className="text-xs text-muted-foreground">
143
+ <fbt desc="EventCard: Community RSVPs">Community RSVPs</fbt>
144
+ </span>
132
145
  <Stack gap wrap>
133
146
  {attendees.slice(0, 4).map(({ node }) => (
134
147
  <EventAttendeeChip attendee={node} key={node.id} />
@@ -143,7 +156,7 @@ export default function EventCard({ event: eventRef }: { event: ViewRef<'Event'>
143
156
  rel="noreferrer"
144
157
  target="_blank"
145
158
  >
146
- Join livestream
159
+ <fbt desc="EventCard: Join livestream">Join livestream</fbt>{' '}
147
160
  </a>
148
161
  ) : null}
149
162
  </VStack>
@@ -5,6 +5,7 @@ import type { SharedData } from '../lib/shared.ts';
5
5
  import AuthClient from '../user/AuthClient.tsx';
6
6
  import { Button } from './Button.tsx';
7
7
  import Link from './Link.tsx';
8
+ import LocaleSwitcher from './LocaleSwitcher.tsx';
8
9
 
9
10
  export default function Header() {
10
11
  const router = useRouter();
@@ -51,16 +52,19 @@ export default function Header() {
51
52
  <span className="italic">fate</span>
52
53
  </span>
53
54
  <p className="hidden text-xs text-muted-foreground sm:block">
54
- A modern data client for React.
55
+ <fbt desc="fate library tagline">A modern data client for React.</fbt>{' '}
55
56
  </p>
56
57
  </div>
57
58
  </Stack>
58
59
  </Link>
59
60
  <Stack alignCenter>
61
+ <LocaleSwitcher />
60
62
  <Button asChild size="sm" variant="ghost">
61
63
  <Link href="/search">
62
64
  <Search className="h-4 w-4" />
63
- <span className="hidden sm:inline">Search</span>
65
+ <span className="hidden sm:inline">
66
+ <fbt desc="Search button">Search</fbt>
67
+ </span>
64
68
  </Link>
65
69
  </Button>
66
70
  <div className="flex w-9 justify-end sm:w-24">
@@ -78,13 +82,15 @@ export default function Header() {
78
82
  }}
79
83
  >
80
84
  <LogOut className="h-4 w-4" />
81
- <span className="hidden sm:inline">Logout</span>
85
+ <span className="hidden sm:inline">
86
+ <fbt desc="Logout button">Logout</fbt>
87
+ </span>
82
88
  </Stack>
83
89
  </Button>
84
90
  ) : (
85
91
  <Button asChild className="w-full" size="sm" variant="ghost">
86
92
  <Link href="/login">
87
- <LogIn className="h-4 w-4" /> Login
93
+ <LogIn className="h-4 w-4" /> <fbt desc="Login button">Login</fbt>{' '}
88
94
  </Link>
89
95
  </Button>
90
96
  )}
@@ -0,0 +1,34 @@
1
+ import { useLocaleContext } from 'fbtee';
2
+ import { startTransition } from 'react';
3
+ import AvailableLanguages from '../lib/AvailableLanguages.tsx';
4
+
5
+ export default function LocaleSwitcher() {
6
+ const { locale, localeChangeIsPending, setLocale } = useLocaleContext();
7
+ return (
8
+ <label className="inline-flex items-center gap-2 text-sm">
9
+ <span className="sr-only">
10
+ <fbt desc="Locale switcher button">Change Language</fbt>
11
+ </span>
12
+ <select
13
+ className="rounded-md border border-gray-300 bg-background px-2 py-1 text-foreground"
14
+ disabled={localeChangeIsPending}
15
+ onChange={(event) => {
16
+ const nextLocale = event.target.value;
17
+ try {
18
+ localStorage.setItem('fbtee:locale', nextLocale);
19
+ } catch {
20
+ // Changing the language still works without browser storage.
21
+ }
22
+ startTransition(() => setLocale(nextLocale));
23
+ }}
24
+ value={locale}
25
+ >
26
+ {[...AvailableLanguages].map(([value, name]) => (
27
+ <option key={value} value={value}>
28
+ {name}
29
+ </option>
30
+ ))}
31
+ </select>
32
+ </label>
33
+ );
34
+ }