@vibe80/vibe80 0.1.1

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 (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -0
  3. package/bin/vibe80.js +176 -0
  4. package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
  5. package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
  6. package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
  7. package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
  8. package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
  9. package/client/dist/assets/browser-e3WgtMs-.js +8 -0
  10. package/client/dist/assets/index-CgqGyssr.css +32 -0
  11. package/client/dist/assets/index-DnwKjoj7.js +706 -0
  12. package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
  13. package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
  14. package/client/dist/favicon.ico +0 -0
  15. package/client/dist/favicon.png +0 -0
  16. package/client/dist/favicon.svg +35 -0
  17. package/client/dist/index.html +14 -0
  18. package/client/index.html +16 -0
  19. package/client/package.json +34 -0
  20. package/client/public/favicon.ico +0 -0
  21. package/client/public/favicon.png +0 -0
  22. package/client/public/favicon.svg +35 -0
  23. package/client/public/pwa-192x192.png +0 -0
  24. package/client/public/pwa-512x512.png +0 -0
  25. package/client/src/App.jsx +3131 -0
  26. package/client/src/assets/logo_small.png +0 -0
  27. package/client/src/assets/vibe80_dark.svg +51 -0
  28. package/client/src/assets/vibe80_light.svg +50 -0
  29. package/client/src/components/Chat/ChatComposer.jsx +228 -0
  30. package/client/src/components/Chat/ChatMessages.jsx +811 -0
  31. package/client/src/components/Chat/ChatToolbar.jsx +109 -0
  32. package/client/src/components/Chat/useChatComposer.js +462 -0
  33. package/client/src/components/Diff/DiffPanel.jsx +129 -0
  34. package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
  35. package/client/src/components/Logs/LogsPanel.jsx +80 -0
  36. package/client/src/components/SessionGate/SessionGate.jsx +874 -0
  37. package/client/src/components/Settings/SettingsPanel.jsx +212 -0
  38. package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
  39. package/client/src/components/Topbar/Topbar.jsx +101 -0
  40. package/client/src/components/WorktreeTabs.css +419 -0
  41. package/client/src/components/WorktreeTabs.jsx +604 -0
  42. package/client/src/hooks/useAttachments.jsx +125 -0
  43. package/client/src/hooks/useBacklog.js +254 -0
  44. package/client/src/hooks/useChatClear.js +90 -0
  45. package/client/src/hooks/useChatCollapse.js +42 -0
  46. package/client/src/hooks/useChatCommands.js +294 -0
  47. package/client/src/hooks/useChatExport.js +144 -0
  48. package/client/src/hooks/useChatMessagesState.js +69 -0
  49. package/client/src/hooks/useChatSend.js +158 -0
  50. package/client/src/hooks/useChatSocket.js +1239 -0
  51. package/client/src/hooks/useDiffNavigation.js +19 -0
  52. package/client/src/hooks/useExplorerActions.js +1184 -0
  53. package/client/src/hooks/useGitIdentity.js +114 -0
  54. package/client/src/hooks/useLayoutMode.js +31 -0
  55. package/client/src/hooks/useLocalPreferences.js +131 -0
  56. package/client/src/hooks/useMessageSync.js +30 -0
  57. package/client/src/hooks/useNotifications.js +132 -0
  58. package/client/src/hooks/usePaneNavigation.js +67 -0
  59. package/client/src/hooks/usePanelState.js +13 -0
  60. package/client/src/hooks/useProviderSelection.js +70 -0
  61. package/client/src/hooks/useRepoBranchesModels.js +218 -0
  62. package/client/src/hooks/useRepoStatus.js +350 -0
  63. package/client/src/hooks/useRpcLogActions.js +19 -0
  64. package/client/src/hooks/useRpcLogView.js +58 -0
  65. package/client/src/hooks/useSessionHandoff.js +97 -0
  66. package/client/src/hooks/useSessionLifecycle.js +287 -0
  67. package/client/src/hooks/useSessionReset.js +63 -0
  68. package/client/src/hooks/useSessionResync.js +77 -0
  69. package/client/src/hooks/useTerminalSession.js +328 -0
  70. package/client/src/hooks/useToolbarExport.js +27 -0
  71. package/client/src/hooks/useTurnInterrupt.js +43 -0
  72. package/client/src/hooks/useVibe80Forms.js +128 -0
  73. package/client/src/hooks/useWorkspaceAuth.js +932 -0
  74. package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
  75. package/client/src/hooks/useWorktrees.js +396 -0
  76. package/client/src/i18n.jsx +87 -0
  77. package/client/src/index.css +5147 -0
  78. package/client/src/locales/en.json +37 -0
  79. package/client/src/locales/fr.json +321 -0
  80. package/client/src/main.jsx +16 -0
  81. package/client/vite.config.js +62 -0
  82. package/docs/api/asyncapi.json +1511 -0
  83. package/docs/api/openapi.json +3242 -0
  84. package/git_hooks/prepare-commit-msg +35 -0
  85. package/package.json +36 -0
  86. package/server/package.json +29 -0
  87. package/server/scripts/rotate-workspace-secret.js +101 -0
  88. package/server/src/claudeClient.js +454 -0
  89. package/server/src/clientEvents.js +594 -0
  90. package/server/src/clientFactory.js +164 -0
  91. package/server/src/codexClient.js +468 -0
  92. package/server/src/config.js +27 -0
  93. package/server/src/helpers.js +138 -0
  94. package/server/src/index.js +1641 -0
  95. package/server/src/middleware/auth.js +93 -0
  96. package/server/src/middleware/debug.js +89 -0
  97. package/server/src/middleware/errorTypes.js +60 -0
  98. package/server/src/providerLogger.js +60 -0
  99. package/server/src/routes/files.js +114 -0
  100. package/server/src/routes/git.js +183 -0
  101. package/server/src/routes/health.js +13 -0
  102. package/server/src/routes/sessions.js +407 -0
  103. package/server/src/routes/workspaces.js +296 -0
  104. package/server/src/routes/worktrees.js +993 -0
  105. package/server/src/runAs.js +458 -0
  106. package/server/src/runtimeStore.js +32 -0
  107. package/server/src/services/auth.js +157 -0
  108. package/server/src/services/claudeThreadDirectory.js +33 -0
  109. package/server/src/services/session.js +918 -0
  110. package/server/src/services/workspace.js +858 -0
  111. package/server/src/storage/index.js +17 -0
  112. package/server/src/storage/redis.js +412 -0
  113. package/server/src/storage/sqlite.js +649 -0
  114. package/server/src/worktreeManager.js +717 -0
  115. package/server/tests/README.md +13 -0
  116. package/server/tests/factories/workspaceFactory.js +13 -0
  117. package/server/tests/fixtures/workspaceCredentials.json +4 -0
  118. package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
  119. package/server/tests/setup/env.js +9 -0
  120. package/server/tests/unit/helpers.test.js +95 -0
  121. package/server/tests/unit/services/auth.test.js +181 -0
  122. package/server/tests/unit/services/workspace.test.js +115 -0
  123. package/server/vitest.config.js +23 -0
@@ -0,0 +1,46 @@
1
+ import { useCallback } from "react";
2
+
3
+ export default function useWorktreeCloseConfirm({
4
+ closeConfirm,
5
+ setCloseConfirm,
6
+ setActiveWorktreeId,
7
+ activeWorktreeIdRef,
8
+ closeWorktree,
9
+ }) {
10
+ const openCloseConfirm = useCallback(
11
+ (worktreeId) => {
12
+ if (!worktreeId || worktreeId === "main") {
13
+ return;
14
+ }
15
+ setCloseConfirm({ worktreeId });
16
+ },
17
+ [setCloseConfirm]
18
+ );
19
+
20
+ const closeCloseConfirm = useCallback(() => {
21
+ setCloseConfirm(null);
22
+ }, [setCloseConfirm]);
23
+
24
+ const handleConfirmDelete = useCallback(async () => {
25
+ if (!closeConfirm?.worktreeId) {
26
+ return;
27
+ }
28
+ if (activeWorktreeIdRef.current === closeConfirm.worktreeId) {
29
+ setActiveWorktreeId("main");
30
+ }
31
+ await closeWorktree(closeConfirm.worktreeId);
32
+ setCloseConfirm(null);
33
+ }, [
34
+ activeWorktreeIdRef,
35
+ closeConfirm,
36
+ closeWorktree,
37
+ setActiveWorktreeId,
38
+ setCloseConfirm,
39
+ ]);
40
+
41
+ return {
42
+ openCloseConfirm,
43
+ closeCloseConfirm,
44
+ handleConfirmDelete,
45
+ };
46
+ }
@@ -0,0 +1,396 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export default function useWorktrees({
4
+ apiFetch,
5
+ attachmentSessionId,
6
+ availableProviders,
7
+ llmProvider,
8
+ messagesRef,
9
+ normalizeAttachments,
10
+ applyMessages,
11
+ socketRef,
12
+ setPaneByTab,
13
+ setLogFilterByTab,
14
+ showToast,
15
+ t,
16
+ }) {
17
+ const [worktrees, setWorktrees] = useState(new Map());
18
+ const worktreesRef = useRef(new Map());
19
+ const [activeWorktreeId, setActiveWorktreeId] = useState("main");
20
+ const activeWorktreeIdRef = useRef("main");
21
+
22
+ useEffect(() => {
23
+ worktreesRef.current = worktrees;
24
+ }, [worktrees]);
25
+
26
+ useEffect(() => {
27
+ activeWorktreeIdRef.current = activeWorktreeId;
28
+ }, [activeWorktreeId]);
29
+
30
+ const getLastSeenMessageId = useCallback((items) => {
31
+ if (!Array.isArray(items)) {
32
+ return null;
33
+ }
34
+ for (let i = items.length - 1; i >= 0; i -= 1) {
35
+ if (items[i]?.id) {
36
+ return items[i].id;
37
+ }
38
+ }
39
+ return null;
40
+ }, []);
41
+
42
+ const loadMainWorktreeSnapshot = useCallback(async () => {
43
+ if (!attachmentSessionId) {
44
+ return;
45
+ }
46
+ try {
47
+ const response = await apiFetch(
48
+ `/api/v1/sessions/${encodeURIComponent(
49
+ attachmentSessionId
50
+ )}/worktrees/main/messages`
51
+ );
52
+ if (!response.ok) {
53
+ return;
54
+ }
55
+ const payload = await response.json().catch(() => ({}));
56
+ if (Array.isArray(payload?.messages)) {
57
+ const normalized = payload.messages.map((message, index) => ({
58
+ ...message,
59
+ id: message?.id || `history-${index}`,
60
+ attachments: normalizeAttachments(message?.attachments || []),
61
+ toolResult: message?.toolResult,
62
+ }));
63
+ applyMessages(normalized);
64
+ }
65
+ } catch {
66
+ // Ignore snapshot failures; WS sync will retry.
67
+ }
68
+ }, [attachmentSessionId, apiFetch, applyMessages, normalizeAttachments]);
69
+
70
+ const loadWorktreeSnapshot = useCallback(
71
+ async (worktreeId) => {
72
+ if (!attachmentSessionId || !worktreeId) {
73
+ return;
74
+ }
75
+ if (worktreeId === "main") {
76
+ await loadMainWorktreeSnapshot();
77
+ return;
78
+ }
79
+ try {
80
+ const response = await apiFetch(
81
+ `/api/v1/sessions/${encodeURIComponent(
82
+ attachmentSessionId
83
+ )}/worktrees/${encodeURIComponent(worktreeId)}`
84
+ );
85
+ if (!response.ok) {
86
+ return;
87
+ }
88
+ const payload = await response.json().catch(() => ({}));
89
+ const messagesResponse = await apiFetch(
90
+ `/api/v1/sessions/${encodeURIComponent(
91
+ attachmentSessionId
92
+ )}/worktrees/${encodeURIComponent(worktreeId)}/messages`
93
+ );
94
+ if (!messagesResponse.ok) {
95
+ return;
96
+ }
97
+ const messagesPayload = await messagesResponse.json().catch(() => ({}));
98
+ if (!Array.isArray(messagesPayload?.messages)) {
99
+ return;
100
+ }
101
+ const normalizedMessages = messagesPayload.messages.map(
102
+ (message, index) => ({
103
+ ...message,
104
+ id: message?.id || `history-${index}`,
105
+ attachments: normalizeAttachments(message?.attachments || []),
106
+ toolResult: message?.toolResult,
107
+ })
108
+ );
109
+ setWorktrees((current) => {
110
+ const next = new Map(current);
111
+ const wt = next.get(worktreeId);
112
+ if (wt) {
113
+ next.set(worktreeId, {
114
+ ...wt,
115
+ messages: normalizedMessages,
116
+ status: payload.status || wt.status,
117
+ });
118
+ }
119
+ return next;
120
+ });
121
+ } catch {
122
+ // Ignore snapshot failures; WS sync will retry.
123
+ }
124
+ },
125
+ [
126
+ attachmentSessionId,
127
+ apiFetch,
128
+ loadMainWorktreeSnapshot,
129
+ normalizeAttachments,
130
+ ]
131
+ );
132
+
133
+ const requestWorktreeMessages = useCallback(
134
+ (worktreeId) => {
135
+ const socket = socketRef?.current;
136
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
137
+ return;
138
+ }
139
+ if (!worktreeId) {
140
+ return;
141
+ }
142
+ const worktreesCurrent = worktreesRef.current;
143
+ const lastSeenMessageId =
144
+ worktreeId === "main"
145
+ ? getLastSeenMessageId(messagesRef.current)
146
+ : getLastSeenMessageId(worktreesCurrent.get(worktreeId)?.messages);
147
+ socket.send(
148
+ JSON.stringify({
149
+ type: "worktree_messages_sync",
150
+ worktreeId,
151
+ lastSeenMessageId,
152
+ })
153
+ );
154
+ },
155
+ [getLastSeenMessageId, messagesRef, socketRef]
156
+ );
157
+
158
+ const applyWorktreesList = useCallback(
159
+ (worktreesList) => {
160
+ if (!Array.isArray(worktreesList)) {
161
+ return;
162
+ }
163
+ const nextMap = new Map();
164
+ worktreesList.forEach((wt) => {
165
+ nextMap.set(wt.id, {
166
+ ...wt,
167
+ status: wt?.status || "processing",
168
+ messages: [],
169
+ models: [],
170
+ modelLoading: false,
171
+ modelError: "",
172
+ activity: "",
173
+ currentTurnId: null,
174
+ });
175
+ });
176
+ setWorktrees(nextMap);
177
+ setPaneByTab((current) => {
178
+ const next = { ...current };
179
+ worktreesList.forEach((wt) => {
180
+ if (!next[wt.id]) {
181
+ next[wt.id] = "chat";
182
+ }
183
+ });
184
+ return next;
185
+ });
186
+ setLogFilterByTab((current) => {
187
+ const next = { ...current };
188
+ worktreesList.forEach((wt) => {
189
+ if (!next[wt.id]) {
190
+ next[wt.id] = "all";
191
+ }
192
+ });
193
+ return next;
194
+ });
195
+ if (
196
+ activeWorktreeIdRef.current !== "main" &&
197
+ !worktreesList.some((wt) => wt.id === activeWorktreeIdRef.current)
198
+ ) {
199
+ setActiveWorktreeId("main");
200
+ }
201
+ },
202
+ [setLogFilterByTab, setPaneByTab]
203
+ );
204
+
205
+ const requestWorktreesList = useCallback(async () => {
206
+ if (!attachmentSessionId) {
207
+ return;
208
+ }
209
+ try {
210
+ const response = await apiFetch(
211
+ `/api/v1/sessions/${encodeURIComponent(attachmentSessionId)}/worktrees`
212
+ );
213
+ if (!response.ok) {
214
+ return;
215
+ }
216
+ const payload = await response.json();
217
+ applyWorktreesList(payload?.worktrees);
218
+ } catch {
219
+ // Ignore worktree list failures (retry on next reconnect).
220
+ }
221
+ }, [attachmentSessionId, apiFetch, applyWorktreesList]);
222
+
223
+ const handleSelectWorktree = useCallback(
224
+ (worktreeId) => {
225
+ if (!worktreeId) {
226
+ return;
227
+ }
228
+ setActiveWorktreeId(worktreeId);
229
+ const socket = socketRef?.current;
230
+ if (socket && socket.readyState === WebSocket.OPEN) {
231
+ socket.send(JSON.stringify({ type: "wake_up", worktreeId }));
232
+ }
233
+ void loadWorktreeSnapshot(worktreeId);
234
+ },
235
+ [loadWorktreeSnapshot, socketRef]
236
+ );
237
+
238
+ const createWorktree = useCallback(
239
+ async ({
240
+ context,
241
+ name,
242
+ provider: wtProvider,
243
+ sourceWorktree,
244
+ startingBranch,
245
+ model,
246
+ reasoningEffort,
247
+ internetAccess,
248
+ denyGitCredentialsAccess,
249
+ }) => {
250
+ if (!attachmentSessionId) {
251
+ showToast?.(t("Session not found."), "error");
252
+ return;
253
+ }
254
+ try {
255
+ const response = await apiFetch(
256
+ `/api/v1/sessions/${encodeURIComponent(attachmentSessionId)}/worktrees`,
257
+ {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({
261
+ context: context === "fork" ? "fork" : "new",
262
+ provider:
263
+ context === "new"
264
+ ? (availableProviders.includes(wtProvider)
265
+ ? wtProvider
266
+ : llmProvider)
267
+ : undefined,
268
+ sourceWorktree: context === "fork" ? sourceWorktree || null : undefined,
269
+ name: name || null,
270
+ startingBranch: startingBranch || null,
271
+ model: context === "new" ? model || null : undefined,
272
+ reasoningEffort: context === "new" ? reasoningEffort ?? null : undefined,
273
+ internetAccess: Boolean(internetAccess),
274
+ denyGitCredentialsAccess: Boolean(denyGitCredentialsAccess),
275
+ }),
276
+ }
277
+ );
278
+ if (!response.ok) {
279
+ const payload = await response.json().catch(() => null);
280
+ throw new Error(
281
+ payload?.error || t("Failed to create parallel request.")
282
+ );
283
+ }
284
+ const payload = await response.json();
285
+ setWorktrees((current) => {
286
+ const next = new Map(current);
287
+ next.set(payload.worktreeId, {
288
+ id: payload.worktreeId,
289
+ name: payload.name,
290
+ branchName: payload.branchName,
291
+ provider: payload.provider,
292
+ model: payload.model || null,
293
+ reasoningEffort: payload.reasoningEffort || null,
294
+ context: payload.context || "new",
295
+ sourceWorktreeId: payload.sourceWorktreeId || null,
296
+ internetAccess: Boolean(payload.internetAccess),
297
+ denyGitCredentialsAccess: Boolean(payload.denyGitCredentialsAccess),
298
+ status: payload.status || "creating",
299
+ color: payload.color,
300
+ messages: [],
301
+ activity: "",
302
+ currentTurnId: null,
303
+ });
304
+ return next;
305
+ });
306
+ setPaneByTab((current) => ({
307
+ ...current,
308
+ [payload.worktreeId]: current[payload.worktreeId] || "chat",
309
+ }));
310
+ setLogFilterByTab((current) => ({
311
+ ...current,
312
+ [payload.worktreeId]: current[payload.worktreeId] || "all",
313
+ }));
314
+ setActiveWorktreeId(payload.worktreeId);
315
+ void requestWorktreesList();
316
+ } catch (error) {
317
+ showToast?.(
318
+ error.message || t("Failed to create parallel request."),
319
+ "error"
320
+ );
321
+ }
322
+ },
323
+ [
324
+ apiFetch,
325
+ attachmentSessionId,
326
+ availableProviders,
327
+ llmProvider,
328
+ requestWorktreesList,
329
+ setLogFilterByTab,
330
+ setPaneByTab,
331
+ showToast,
332
+ t,
333
+ ]
334
+ );
335
+
336
+ const closeWorktree = useCallback(
337
+ async (worktreeId) => {
338
+ if (!attachmentSessionId) return;
339
+ try {
340
+ const response = await apiFetch(
341
+ `/api/v1/sessions/${encodeURIComponent(
342
+ attachmentSessionId
343
+ )}/worktrees/${encodeURIComponent(worktreeId)}`,
344
+ { method: "DELETE" }
345
+ );
346
+ if (!response.ok) {
347
+ console.error("Failed to close worktree");
348
+ }
349
+ } catch (error) {
350
+ console.error("Error closing worktree:", error);
351
+ }
352
+ },
353
+ [attachmentSessionId, apiFetch]
354
+ );
355
+
356
+ const renameWorktreeHandler = useCallback(
357
+ async (worktreeId, newName) => {
358
+ if (!attachmentSessionId) return;
359
+ try {
360
+ const response = await apiFetch(
361
+ `/api/v1/sessions/${encodeURIComponent(
362
+ attachmentSessionId
363
+ )}/worktrees/${encodeURIComponent(worktreeId)}`,
364
+ {
365
+ method: "PATCH",
366
+ headers: { "Content-Type": "application/json" },
367
+ body: JSON.stringify({ name: newName }),
368
+ }
369
+ );
370
+ if (!response.ok) {
371
+ console.error("Failed to rename worktree");
372
+ }
373
+ } catch (error) {
374
+ console.error("Error renaming worktree:", error);
375
+ }
376
+ },
377
+ [attachmentSessionId, apiFetch]
378
+ );
379
+
380
+ return {
381
+ activeWorktreeId,
382
+ activeWorktreeIdRef,
383
+ applyWorktreesList,
384
+ closeWorktree,
385
+ createWorktree,
386
+ handleSelectWorktree,
387
+ loadMainWorktreeSnapshot,
388
+ loadWorktreeSnapshot,
389
+ requestWorktreeMessages,
390
+ requestWorktreesList,
391
+ renameWorktreeHandler,
392
+ setActiveWorktreeId,
393
+ setWorktrees,
394
+ worktrees,
395
+ };
396
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+ import enTranslations from "./locales/en.json";
10
+ import frTranslations from "./locales/fr.json";
11
+
12
+ const LANGUAGE_STORAGE_KEY = "uiLanguage";
13
+
14
+ const translations = {
15
+ en: enTranslations || {},
16
+ fr: frTranslations || {},
17
+ };
18
+
19
+ const getInitialLanguage = () => {
20
+ try {
21
+ const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
22
+ if (stored === "fr" || stored === "en") {
23
+ return stored;
24
+ }
25
+ } catch {
26
+ // ignore
27
+ }
28
+ return "en";
29
+ };
30
+
31
+ const interpolate = (template, vars) => {
32
+ if (!vars) {
33
+ return template;
34
+ }
35
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
36
+ if (Object.prototype.hasOwnProperty.call(vars, key)) {
37
+ return String(vars[key]);
38
+ }
39
+ return match;
40
+ });
41
+ };
42
+
43
+ const translate = (language, key, vars) => {
44
+ const map = translations[language] || {};
45
+ const template = map[key] || key;
46
+ return interpolate(template, vars);
47
+ };
48
+
49
+ const I18nContext = createContext({
50
+ language: "en",
51
+ setLanguage: () => {},
52
+ t: (key, vars) => translate("en", key, vars),
53
+ locale: "en-US",
54
+ });
55
+
56
+ export const I18nProvider = ({ children }) => {
57
+ const [language, setLanguage] = useState(getInitialLanguage);
58
+
59
+ useEffect(() => {
60
+ try {
61
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
62
+ } catch {
63
+ // ignore
64
+ }
65
+ }, [language]);
66
+
67
+ const locale = language === "fr" ? "fr-FR" : "en-US";
68
+
69
+ const t = useCallback(
70
+ (key, vars) => translate(language, key, vars),
71
+ [language]
72
+ );
73
+
74
+ const value = useMemo(
75
+ () => ({
76
+ language,
77
+ setLanguage,
78
+ t,
79
+ locale,
80
+ }),
81
+ [language, t, locale]
82
+ );
83
+
84
+ return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
85
+ };
86
+
87
+ export const useI18n = () => useContext(I18nContext);