@seip/blue-bird 0.4.5 → 0.4.6

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 (51) hide show
  1. package/.env_example +26 -25
  2. package/AGENTS.md +199 -199
  3. package/README.md +79 -79
  4. package/backend/index.js +13 -13
  5. package/backend/routes/frontend.js +41 -41
  6. package/backend/routes/seo.js +39 -39
  7. package/core/app.js +328 -325
  8. package/core/auth.js +114 -114
  9. package/core/cache.js +44 -44
  10. package/core/cli/component.js +42 -42
  11. package/core/cli/init.js +119 -118
  12. package/core/cli/react.js +435 -435
  13. package/core/cli/route.js +42 -42
  14. package/core/config.js +48 -47
  15. package/core/debug.js +248 -248
  16. package/core/logger.js +100 -100
  17. package/core/middleware.js +27 -27
  18. package/core/router.js +333 -333
  19. package/core/seo.js +95 -100
  20. package/core/template.js +472 -462
  21. package/core/upload.js +76 -76
  22. package/core/validate.js +380 -380
  23. package/frontend/index.html +26 -26
  24. package/frontend/landing.html +69 -69
  25. package/frontend/resources/css/tailwind.css +17 -17
  26. package/frontend/resources/js/App.jsx +70 -70
  27. package/frontend/resources/js/Main.jsx +18 -18
  28. package/frontend/resources/js/blue-bird/components/Button.jsx +67 -67
  29. package/frontend/resources/js/blue-bird/components/Card.jsx +18 -18
  30. package/frontend/resources/js/blue-bird/components/DataTable.jsx +126 -126
  31. package/frontend/resources/js/blue-bird/components/Input.jsx +21 -21
  32. package/frontend/resources/js/blue-bird/components/Label.jsx +12 -12
  33. package/frontend/resources/js/blue-bird/components/LanguageButton.jsx +23 -23
  34. package/frontend/resources/js/blue-bird/components/Link.jsx +15 -15
  35. package/frontend/resources/js/blue-bird/components/Modal.jsx +27 -27
  36. package/frontend/resources/js/blue-bird/components/Skeleton.jsx +44 -44
  37. package/frontend/resources/js/blue-bird/components/Translate.jsx +12 -12
  38. package/frontend/resources/js/blue-bird/components/Typography.jsx +69 -69
  39. package/frontend/resources/js/blue-bird/contexts/LanguageContext.jsx +41 -41
  40. package/frontend/resources/js/blue-bird/contexts/SPAContext.jsx +239 -237
  41. package/frontend/resources/js/blue-bird/contexts/SnackbarContext.jsx +38 -38
  42. package/frontend/resources/js/blue-bird/contexts/ThemeContext.jsx +49 -49
  43. package/frontend/resources/js/blue-bird/locales/en.json +47 -47
  44. package/frontend/resources/js/blue-bird/locales/es.json +47 -47
  45. package/frontend/resources/js/components/Header.jsx +55 -55
  46. package/frontend/resources/js/pages/About.jsx +31 -31
  47. package/frontend/resources/js/pages/Home.jsx +82 -82
  48. package/package.json +57 -57
  49. package/vite.config.js +22 -22
  50. package/frontend/public/robots.txt +0 -0
  51. package/frontend/public/sitemap.xml +0 -0
@@ -1,237 +1,239 @@
1
- import React, {
2
- createContext,
3
- useContext,
4
- useEffect,
5
- useState,
6
- useRef,
7
- useCallback,
8
- } from "react";
9
- import { useLocation, useNavigate } from "react-router-dom";
10
- import { useLanguage } from "./LanguageContext";
11
-
12
- const SPAContext = createContext({
13
- pageProps: {},
14
- pageMeta: {},
15
- loading: false,
16
- navigateToLang: () => { },
17
- });
18
-
19
- /**
20
- * SPAProvider — Bridges React Router navigation with backend SEO data.
21
- *
22
- * On every client-side route change, fetches meta/props from the backend
23
- * via ?source=frontend and updates document.title + meta tags.
24
- * Also syncs the language from URL prefixes (e.g., /es/about → lang="es").
25
- *
26
- * @param {Object} props
27
- * @param {React.ReactNode} props.children
28
- * @param {Array<string>} [props.languages=[]] - Supported language codes.
29
- * @param {string} [props.defaultLanguage="en"] - Default language.
30
- */
31
- export function SPAProvider({
32
- children,
33
- languages = [],
34
- defaultLanguage = "en",
35
- }) {
36
- const location = useLocation();
37
- const navigate = useNavigate();
38
- const { lang, setLang } = useLanguage();
39
- const [pageProps, setPageProps] = useState({});
40
- const [pageMeta, setPageMeta] = useState({});
41
- const [loading, setLoading] = useState(false);
42
- const isFirstRender = useRef(true);
43
- const abortRef = useRef(null);
44
-
45
- /**
46
- * Extracts language code from a URL path if it starts with a valid lang prefix.
47
- */
48
- const detectLangFromPath = useCallback(
49
- (pathname) => {
50
- if (!languages || languages.length === 0) return null;
51
- const parts = pathname.split("/").filter(Boolean);
52
- if (
53
- parts.length > 0 &&
54
- parts[0].length === 2 &&
55
- languages.includes(parts[0])
56
- ) {
57
- return parts[0];
58
- }
59
- return null;
60
- },
61
- [languages],
62
- );
63
-
64
- /**
65
- * Strips the language prefix from a path.
66
- */
67
- const stripLangPrefix = useCallback(
68
- (pathname) => {
69
- if (!languages || languages.length === 0) return pathname;
70
- const parts = pathname.split("/").filter(Boolean);
71
- if (
72
- parts.length > 0 &&
73
- parts[0].length === 2 &&
74
- languages.includes(parts[0])
75
- ) {
76
- const rest = parts.slice(1).join("/");
77
- return rest ? `/${rest}` : "/";
78
- }
79
- return pathname;
80
- },
81
- [languages],
82
- );
83
-
84
- /**
85
- * Localizes a path based on the current language.
86
- * If on /es/about, l("/") returns /es/
87
- */
88
- const l = useCallback(
89
- (path) => {
90
- if (!languages || languages.length === 0) return path;
91
- const currentLang = detectLangFromPath(location.pathname) || lang || defaultLanguage;
92
- if (!currentLang || currentLang === defaultLanguage) return path;
93
-
94
- const cleanPath = path.startsWith("/") ? path : `/${path}`;
95
- return `/${currentLang}${cleanPath === "/" ? "" : cleanPath}`;
96
- },
97
- [languages, location.pathname, lang, defaultLanguage, detectLangFromPath],
98
- );
99
-
100
- /**
101
- * Navigates to the same page but in a different language.
102
- */
103
- const navigateToLang = useCallback(
104
- (newLang) => {
105
- const pathWithoutLang = stripLangPrefix(location.pathname);
106
- const newPath = newLang === defaultLanguage
107
- ? pathWithoutLang
108
- : `/${newLang}${pathWithoutLang === "/" ? "" : pathWithoutLang}`;
109
-
110
- setLang(newLang);
111
- navigate(newPath);
112
- },
113
- [location.pathname, stripLangPrefix, setLang, navigate, defaultLanguage],
114
- );
115
-
116
- /**
117
- * Updates document meta tags from fetched data.
118
- */
119
- const updateMeta = useCallback((meta) => {
120
- if (!meta) return;
121
-
122
- if (meta.titleMeta) {
123
- document.title = meta.titleMeta;
124
- }
125
-
126
- const metaUpdates = {
127
- description: meta.descriptionMeta,
128
- keywords: meta.keywordsMeta,
129
- author: meta.authorMeta,
130
- };
131
-
132
- Object.entries(metaUpdates).forEach(([name, content]) => {
133
- let el = document.querySelector(`meta[name="${name}"]`);
134
- if (el) {
135
- el.setAttribute("content", content || "");
136
- }
137
- });
138
-
139
- // Update OG tags
140
- const ogUpdates = {
141
- "og:title": meta.titleMeta,
142
- "og:description": meta.descriptionMeta,
143
- "og:image": meta.ogImage,
144
- "og:type": meta.ogType,
145
- };
146
-
147
- Object.entries(ogUpdates).forEach(([property, content]) => {
148
- let el = document.querySelector(`meta[property="${property}"]`);
149
- if (el) {
150
- el.setAttribute("content", content || "");
151
- }
152
- });
153
- }, []);
154
-
155
- useEffect(() => {
156
- // Sync language from URL prefix if present
157
- const urlLang = detectLangFromPath(location.pathname);
158
- if (urlLang) {
159
- setLang(urlLang);
160
- } else if (languages.length > 0 && !isFirstRender.current) {
161
- // On subsequent navigations, if prefix is missing, revert to default
162
- setLang(defaultLanguage);
163
- }
164
-
165
- // Skip fetch on initial render
166
- if (isFirstRender.current) {
167
- isFirstRender.current = false;
168
- return;
169
- }
170
-
171
- if (abortRef.current) {
172
- abortRef.current.abort();
173
- }
174
-
175
- const controller = new AbortController();
176
- abortRef.current = controller;
177
-
178
- const fetchMeta = async () => {
179
- setLoading(true);
180
- try {
181
- const currentActiveLang = urlLang || lang || defaultLanguage;
182
- const separator = location.pathname.includes("?") ? "&" : "?";
183
- const url = `${location.pathname}${separator}source=frontend&lang=${currentActiveLang}`;
184
-
185
- const response = await fetch(url, {
186
- signal: controller.signal,
187
- headers: {
188
- "X-Blue-Bird-SPA": "true",
189
- Accept: "application/json",
190
- },
191
- });
192
-
193
- if (!response.ok) {
194
- setLoading(false);
195
- return;
196
- }
197
-
198
- const data = await response.json();
199
-
200
- if (data.meta) {
201
- updateMeta(data.meta);
202
- setPageMeta(data.meta);
203
- }
204
-
205
- if (data.props) {
206
- setPageProps(data.props);
207
- }
208
- } catch (err) {
209
- if (err.name !== "AbortError") {
210
- console.warn("SPA meta fetch failed:", err.message);
211
- }
212
- } finally {
213
- setLoading(false);
214
- }
215
- };
216
-
217
- fetchMeta();
218
-
219
- return () => {
220
- controller.abort();
221
- };
222
- }, [location.pathname, languages, defaultLanguage, setLang, updateMeta]);
223
-
224
- return (
225
- <SPAContext.Provider
226
- value={{ pageProps, pageMeta, loading, navigateToLang, l, currentLang: lang }}
227
- >
228
- {children}
229
- </SPAContext.Provider>
230
- );
231
- }
232
-
233
- /**
234
- * Hook to access SPA navigation context.
235
- * @returns {{ pageProps: Object, pageMeta: Object, loading: boolean, navigateToLang: Function }}
236
- */
237
- export const useSPA = () => useContext(SPAContext);
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ useRef,
7
+ useCallback,
8
+ } from "react";
9
+ import { useLocation, useNavigate } from "react-router-dom";
10
+ import { useLanguage } from "./LanguageContext";
11
+
12
+ const SPAContext = createContext({
13
+ pageProps: {},
14
+ pageMeta: {},
15
+ loading: false,
16
+ navigateToLang: () => { },
17
+ });
18
+
19
+ /**
20
+ * SPAProvider — Bridges React Router navigation with backend SEO data.
21
+ *
22
+ * On every client-side route change, fetches meta/props from the backend
23
+ * via ?source=frontend and updates document.title + meta tags.
24
+ * Also syncs the language from URL prefixes (e.g., /es/about → lang="es").
25
+ *
26
+ * @param {Object} props
27
+ * @param {React.ReactNode} props.children
28
+ * @param {Array<string>} [props.languages=[]] - Supported language codes.
29
+ * @param {string} [props.defaultLanguage="en"] - Default language.
30
+ */
31
+ export function SPAProvider({
32
+ children,
33
+ languages = [],
34
+ defaultLanguage = "en",
35
+ }) {
36
+ const location = useLocation();
37
+ const navigate = useNavigate();
38
+ const { lang, setLang } = useLanguage();
39
+ const [pageProps, setPageProps] = useState({});
40
+ const [pageMeta, setPageMeta] = useState({});
41
+ const [loading, setLoading] = useState(false);
42
+ const isFirstRender = useRef(true);
43
+ const abortRef = useRef(null);
44
+
45
+ /**
46
+ * Extracts language code from a URL path if it starts with a valid lang prefix.
47
+ */
48
+ const detectLangFromPath = useCallback(
49
+ (pathname) => {
50
+ if (!languages || languages.length === 0) return null;
51
+ const parts = pathname.split("/").filter(Boolean);
52
+ if (
53
+ parts.length > 0 &&
54
+ parts[0].length === 2 &&
55
+ languages.includes(parts[0])
56
+ ) {
57
+ return parts[0];
58
+ }
59
+ return null;
60
+ },
61
+ [languages],
62
+ );
63
+
64
+ /**
65
+ * Strips the language prefix from a path.
66
+ */
67
+ const stripLangPrefix = useCallback(
68
+ (pathname) => {
69
+ if (!languages || languages.length === 0) return pathname;
70
+ const parts = pathname.split("/").filter(Boolean);
71
+ if (
72
+ parts.length > 0 &&
73
+ parts[0].length === 2 &&
74
+ languages.includes(parts[0])
75
+ ) {
76
+ const rest = parts.slice(1).join("/");
77
+ return rest ? `/${rest}` : "/";
78
+ }
79
+ return pathname;
80
+ },
81
+ [languages],
82
+ );
83
+
84
+ /**
85
+ * Localizes a path based on the current language.
86
+ * If on /es/about, l("/") returns /es/
87
+ */
88
+ const l = useCallback(
89
+ (path) => {
90
+ if (!languages || languages.length === 0) return path;
91
+ const currentLang = detectLangFromPath(location.pathname) || lang || defaultLanguage;
92
+ if (!currentLang || currentLang === defaultLanguage) return path;
93
+
94
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
95
+ return `/${currentLang}${cleanPath === "/" ? "" : cleanPath}`;
96
+ },
97
+ [languages, location.pathname, lang, defaultLanguage, detectLangFromPath],
98
+ );
99
+
100
+ /**
101
+ * Navigates to the same page but in a different language.
102
+ */
103
+ const navigateToLang = useCallback(
104
+ (newLang) => {
105
+ const pathWithoutLang = stripLangPrefix(location.pathname);
106
+ const newPath = newLang === defaultLanguage
107
+ ? pathWithoutLang
108
+ : `/${newLang}${pathWithoutLang === "/" ? "" : pathWithoutLang}`;
109
+
110
+ setLang(newLang);
111
+ navigate(newPath);
112
+ },
113
+ [location.pathname, stripLangPrefix, setLang, navigate, defaultLanguage],
114
+ );
115
+
116
+ /**
117
+ * Updates document meta tags from fetched data.
118
+ */
119
+ const updateMeta = useCallback((meta) => {
120
+ if (!meta) return;
121
+
122
+ if (meta.titleMeta) {
123
+ document.title = meta.titleMeta;
124
+ }
125
+
126
+ const metaUpdates = {
127
+ description: meta.descriptionMeta,
128
+ keywords: meta.keywordsMeta,
129
+ author: meta.authorMeta,
130
+ };
131
+
132
+ Object.entries(metaUpdates).forEach(([name, content]) => {
133
+ let el = document.querySelector(`meta[name="${name}"]`);
134
+ if (el) {
135
+ el.setAttribute("content", content || "");
136
+ }
137
+ });
138
+
139
+ // Update OG tags
140
+ const ogUpdates = {
141
+ "og:title": meta.titleMeta,
142
+ "og:description": meta.descriptionMeta,
143
+ "og:image": meta.ogImage,
144
+ "og:type": meta.ogType,
145
+ };
146
+
147
+ Object.entries(ogUpdates).forEach(([property, content]) => {
148
+ let el = document.querySelector(`meta[property="${property}"]`);
149
+ if (el) {
150
+ el.setAttribute("content", content || "");
151
+ }
152
+ });
153
+ }, []);
154
+
155
+ useEffect(() => {
156
+ // Sync language from URL prefix if present
157
+ const urlLang = detectLangFromPath(location.pathname);
158
+ if (urlLang) {
159
+ setLang(urlLang);
160
+ } else if (languages.length > 0 && !isFirstRender.current) {
161
+ // On subsequent navigations, if prefix is missing, revert to default
162
+ setLang(defaultLanguage);
163
+ }
164
+
165
+ // Skip fetch on initial render
166
+ if (isFirstRender.current) {
167
+ isFirstRender.current = false;
168
+ return;
169
+ }
170
+
171
+ if (abortRef.current) {
172
+ abortRef.current.abort();
173
+ }
174
+
175
+ const controller = new AbortController();
176
+ abortRef.current = controller;
177
+
178
+ const fetchMeta = async () => {
179
+ setLoading(true);
180
+ try {
181
+ const currentActiveLang = urlLang || lang || defaultLanguage;
182
+ const separator = location.pathname.includes("?") ? "&" : "?";
183
+ const url = `${location.pathname}${separator}source=frontend&lang=${currentActiveLang}`;
184
+
185
+ const response = await fetch(url, {
186
+ signal: controller.signal,
187
+ headers: {
188
+ "X-Blue-Bird-SPA": "true",
189
+ Accept: "application/json",
190
+ },
191
+ });
192
+
193
+ if (!response.ok) {
194
+ setLoading(false);
195
+ return;
196
+ }
197
+
198
+ const data = await response.json();
199
+ if (data.lang) {
200
+ document.documentElement.setAttribute("lang", data.lang);
201
+ }
202
+ if (data.meta) {
203
+ updateMeta(data.meta);
204
+ setPageMeta(data.meta);
205
+ }
206
+
207
+ if (data.props) {
208
+ setPageProps(data.props);
209
+ }
210
+ } catch (err) {
211
+ if (err.name !== "AbortError") {
212
+ console.warn("SPA meta fetch failed:", err.message);
213
+ }
214
+ } finally {
215
+ setLoading(false);
216
+ }
217
+ };
218
+
219
+ fetchMeta();
220
+
221
+ return () => {
222
+ controller.abort();
223
+ };
224
+ }, [location.pathname, languages, defaultLanguage, setLang, updateMeta]);
225
+
226
+ return (
227
+ <SPAContext.Provider
228
+ value={{ pageProps, pageMeta, loading, navigateToLang, l, currentLang: lang }}
229
+ >
230
+ {children}
231
+ </SPAContext.Provider>
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Hook to access SPA navigation context.
237
+ * @returns {{ pageProps: Object, pageMeta: Object, loading: boolean, navigateToLang: Function }}
238
+ */
239
+ export const useSPA = () => useContext(SPAContext);
@@ -1,38 +1,38 @@
1
- import React, { createContext, useState, useContext, useCallback } from 'react';
2
-
3
- export const SnackbarContext = createContext();
4
-
5
- export const SnackbarProvider = ({ children }) => {
6
- const [snackbars, setSnackbars] = useState([]);
7
-
8
- const showSnackbar = useCallback((message, type = 'info', duration = 3000) => {
9
- const id = Date.now();
10
- setSnackbars((prev) => [...prev, { id, message, type }]);
11
- setTimeout(() => {
12
- setSnackbars((prev) => prev.filter((s) => s.id !== id));
13
- }, duration);
14
- }, []);
15
-
16
- return (
17
- <SnackbarContext.Provider value={{ showSnackbar }}>
18
- {children}
19
- <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
20
- {snackbars.map((s) => (
21
- <div
22
- key={s.id}
23
- className={`px-4 py-3 rounded shadow-lg text-white transition-all transform pointer-events-auto ${s.type === 'success' ? 'bg-green-600' :
24
- s.type === 'error' ? 'bg-red-600' :
25
- s.type === 'warning' ? 'bg-yellow-600' :
26
- 'bg-blue-600'
27
- }`}
28
- role="alert"
29
- >
30
- {s.message}
31
- </div>
32
- ))}
33
- </div>
34
- </SnackbarContext.Provider>
35
- );
36
- };
37
-
38
- export const useSnackbar = () => useContext(SnackbarContext);
1
+ import React, { createContext, useState, useContext, useCallback } from 'react';
2
+
3
+ export const SnackbarContext = createContext();
4
+
5
+ export const SnackbarProvider = ({ children }) => {
6
+ const [snackbars, setSnackbars] = useState([]);
7
+
8
+ const showSnackbar = useCallback((message, type = 'info', duration = 3000) => {
9
+ const id = Date.now();
10
+ setSnackbars((prev) => [...prev, { id, message, type }]);
11
+ setTimeout(() => {
12
+ setSnackbars((prev) => prev.filter((s) => s.id !== id));
13
+ }, duration);
14
+ }, []);
15
+
16
+ return (
17
+ <SnackbarContext.Provider value={{ showSnackbar }}>
18
+ {children}
19
+ <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
20
+ {snackbars.map((s) => (
21
+ <div
22
+ key={s.id}
23
+ className={`px-4 py-3 rounded shadow-lg text-white transition-all transform pointer-events-auto ${s.type === 'success' ? 'bg-green-600' :
24
+ s.type === 'error' ? 'bg-red-600' :
25
+ s.type === 'warning' ? 'bg-yellow-600' :
26
+ 'bg-blue-600'
27
+ }`}
28
+ role="alert"
29
+ >
30
+ {s.message}
31
+ </div>
32
+ ))}
33
+ </div>
34
+ </SnackbarContext.Provider>
35
+ );
36
+ };
37
+
38
+ export const useSnackbar = () => useContext(SnackbarContext);
@@ -1,49 +1,49 @@
1
- import React, { createContext, useContext, useEffect, useState } from 'react';
2
-
3
- const ThemeContext = createContext();
4
-
5
- export function ThemeProvider({ children }) {
6
- const [theme, setTheme] = useState(() => {
7
- return localStorage.getItem('theme') || 'system';
8
- });
9
-
10
- useEffect(() => {
11
- const root = window.document.documentElement;
12
- const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
13
-
14
- const applyTheme = (currentTheme) => {
15
- root.classList.remove('light', 'dark');
16
-
17
- if (currentTheme === 'system') {
18
- const systemTheme = mediaQuery.matches ? 'dark' : 'light';
19
- root.classList.add(systemTheme);
20
- } else {
21
- root.classList.add(currentTheme);
22
- }
23
- };
24
-
25
- applyTheme(theme);
26
-
27
- const handleChange = () => {
28
- if (theme === 'system') {
29
- applyTheme('system');
30
- }
31
- };
32
-
33
- mediaQuery.addEventListener('change', handleChange);
34
- return () => mediaQuery.removeEventListener('change', handleChange);
35
- }, [theme]);
36
-
37
- const changeTheme = (newTheme) => {
38
- setTheme(newTheme);
39
- localStorage.setItem('theme', newTheme);
40
- };
41
-
42
- return (
43
- <ThemeContext.Provider value={{ theme, changeTheme }}>
44
- {children}
45
- </ThemeContext.Provider>
46
- );
47
- }
48
-
49
- export const useTheme = () => useContext(ThemeContext);
1
+ import React, { createContext, useContext, useEffect, useState } from 'react';
2
+
3
+ const ThemeContext = createContext();
4
+
5
+ export function ThemeProvider({ children }) {
6
+ const [theme, setTheme] = useState(() => {
7
+ return localStorage.getItem('theme') || 'system';
8
+ });
9
+
10
+ useEffect(() => {
11
+ const root = window.document.documentElement;
12
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
13
+
14
+ const applyTheme = (currentTheme) => {
15
+ root.classList.remove('light', 'dark');
16
+
17
+ if (currentTheme === 'system') {
18
+ const systemTheme = mediaQuery.matches ? 'dark' : 'light';
19
+ root.classList.add(systemTheme);
20
+ } else {
21
+ root.classList.add(currentTheme);
22
+ }
23
+ };
24
+
25
+ applyTheme(theme);
26
+
27
+ const handleChange = () => {
28
+ if (theme === 'system') {
29
+ applyTheme('system');
30
+ }
31
+ };
32
+
33
+ mediaQuery.addEventListener('change', handleChange);
34
+ return () => mediaQuery.removeEventListener('change', handleChange);
35
+ }, [theme]);
36
+
37
+ const changeTheme = (newTheme) => {
38
+ setTheme(newTheme);
39
+ localStorage.setItem('theme', newTheme);
40
+ };
41
+
42
+ return (
43
+ <ThemeContext.Provider value={{ theme, changeTheme }}>
44
+ {children}
45
+ </ThemeContext.Provider>
46
+ );
47
+ }
48
+
49
+ export const useTheme = () => useContext(ThemeContext);