@seip/blue-bird 0.4.3

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 (53) hide show
  1. package/.env_example +26 -0
  2. package/AGENTS.md +199 -0
  3. package/README.md +79 -0
  4. package/backend/index.js +13 -0
  5. package/backend/routes/api.js +31 -0
  6. package/backend/routes/frontend.js +41 -0
  7. package/backend/routes/seo.js +39 -0
  8. package/core/app.js +325 -0
  9. package/core/auth.js +83 -0
  10. package/core/cache.js +45 -0
  11. package/core/cli/component.js +42 -0
  12. package/core/cli/init.js +118 -0
  13. package/core/cli/react.js +435 -0
  14. package/core/cli/route.js +43 -0
  15. package/core/cli/swagger.js +40 -0
  16. package/core/config.js +47 -0
  17. package/core/debug.js +249 -0
  18. package/core/logger.js +100 -0
  19. package/core/middleware.js +27 -0
  20. package/core/router.js +333 -0
  21. package/core/seo.js +100 -0
  22. package/core/swagger.js +25 -0
  23. package/core/template.js +462 -0
  24. package/core/upload.js +76 -0
  25. package/core/validate.js +380 -0
  26. package/frontend/index.html +27 -0
  27. package/frontend/landing.html +70 -0
  28. package/frontend/public/favicon.ico +0 -0
  29. package/frontend/resources/css/tailwind.css +18 -0
  30. package/frontend/resources/js/App.jsx +70 -0
  31. package/frontend/resources/js/Main.jsx +19 -0
  32. package/frontend/resources/js/blue-bird/components/Button.jsx +67 -0
  33. package/frontend/resources/js/blue-bird/components/Card.jsx +18 -0
  34. package/frontend/resources/js/blue-bird/components/DataTable.jsx +126 -0
  35. package/frontend/resources/js/blue-bird/components/Input.jsx +21 -0
  36. package/frontend/resources/js/blue-bird/components/Label.jsx +12 -0
  37. package/frontend/resources/js/blue-bird/components/LanguageButton.jsx +23 -0
  38. package/frontend/resources/js/blue-bird/components/Link.jsx +16 -0
  39. package/frontend/resources/js/blue-bird/components/Modal.jsx +27 -0
  40. package/frontend/resources/js/blue-bird/components/Skeleton.jsx +45 -0
  41. package/frontend/resources/js/blue-bird/components/Translate.jsx +12 -0
  42. package/frontend/resources/js/blue-bird/components/Typography.jsx +69 -0
  43. package/frontend/resources/js/blue-bird/contexts/LanguageContext.jsx +41 -0
  44. package/frontend/resources/js/blue-bird/contexts/SPAContext.jsx +237 -0
  45. package/frontend/resources/js/blue-bird/contexts/SnackbarContext.jsx +38 -0
  46. package/frontend/resources/js/blue-bird/contexts/ThemeContext.jsx +49 -0
  47. package/frontend/resources/js/blue-bird/locales/en.json +48 -0
  48. package/frontend/resources/js/blue-bird/locales/es.json +48 -0
  49. package/frontend/resources/js/components/Header.jsx +56 -0
  50. package/frontend/resources/js/pages/About.jsx +32 -0
  51. package/frontend/resources/js/pages/Home.jsx +82 -0
  52. package/package.json +58 -0
  53. package/vite.config.js +23 -0
@@ -0,0 +1,126 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { useLanguage } from '../contexts/LanguageContext.jsx';
3
+ import Button from './Button.jsx';
4
+ import Input from './Input.jsx';
5
+
6
+ export default function DataTable({
7
+ data = [],
8
+ columns = [],
9
+ rowsPerPage = 10,
10
+ searchable = true,
11
+ pagination = true,
12
+ onEdit,
13
+ onDelete
14
+ }) {
15
+ const { t } = useLanguage();
16
+ const [currentPage, setCurrentPage] = useState(1);
17
+ const [searchTerm, setSearchTerm] = useState('');
18
+
19
+ const filteredData = useMemo(() => {
20
+ if (!searchTerm) return data;
21
+ const lowerTerm = searchTerm.toLowerCase();
22
+ return data.filter(item =>
23
+ columns.some(col => String(item[col.key] || '').toLowerCase().includes(lowerTerm))
24
+ );
25
+ }, [data, columns, searchTerm]);
26
+
27
+ const pageCount = Math.ceil(filteredData.length / rowsPerPage);
28
+ const paginatedData = useMemo(() => {
29
+ if (!pagination) return filteredData;
30
+ const start = (currentPage - 1) * rowsPerPage;
31
+ return filteredData.slice(start, start + rowsPerPage);
32
+ }, [filteredData, currentPage, rowsPerPage, pagination]);
33
+
34
+ return (
35
+ <div className="flex flex-col w-full gap-4">
36
+ {searchable && (
37
+ <div className="flex justify-end">
38
+ <Input
39
+ placeholder={t('search')}
40
+ value={searchTerm}
41
+ onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
42
+ className="w-full md:w-64"
43
+ />
44
+ </div>
45
+ )}
46
+
47
+ <div className="overflow-x-auto bg-white dark:bg-slate-900 rounded-lg shadow border border-gray-200 dark:border-slate-800">
48
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-slate-800">
49
+ <thead className="bg-gray-50 dark:bg-slate-800/50">
50
+ <tr>
51
+ {columns.map(col => (
52
+ <th key={col.key} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
53
+ {col.title || col.key}
54
+ </th>
55
+ ))}
56
+ {(onEdit || onDelete) && (
57
+ <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
58
+ {t('actions')}
59
+ </th>
60
+ )}
61
+ </tr>
62
+ </thead>
63
+ <tbody className="bg-white dark:bg-slate-900 divide-y divide-gray-200 dark:divide-slate-800">
64
+ {paginatedData.length > 0 ? paginatedData.map((row, idx) => (
65
+ <tr key={row.id || idx} className="hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors">
66
+ {columns.map(col => (
67
+ <td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
68
+ {row[col.key] || '-'}
69
+ </td>
70
+ ))}
71
+ {(onEdit || onDelete) && (
72
+ <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
73
+ {onEdit && (
74
+ <Button variant="outline" className="px-2 py-1 text-xs" onClick={() => onEdit(row)}>
75
+ {t('edit')}
76
+ </Button>
77
+ )}
78
+ {onDelete && (
79
+ <Button variant="danger" className="px-2 py-1 text-xs" onClick={() => onDelete(row)}>
80
+ {t('delete')}
81
+ </Button>
82
+ )}
83
+ </td>
84
+ )}
85
+ </tr>
86
+ )) : (
87
+ <tr>
88
+ <td colSpan={columns.length + (onEdit || onDelete ? 1 : 0)} className="px-6 py-4 text-center text-sm text-gray-500 dark:text-slate-400">
89
+ No data available.
90
+ </td>
91
+ </tr>
92
+ )}
93
+ </tbody>
94
+ </table>
95
+ </div>
96
+
97
+ {pagination && pageCount > 1 && (
98
+ <div className="flex justify-end mt-4 gap-1">
99
+ <Button
100
+ variant="secondary"
101
+ disabled={currentPage === 1}
102
+ onClick={() => setCurrentPage(p => p - 1)}
103
+ >
104
+ &laquo;
105
+ </Button>
106
+ {Array.from({ length: pageCount }).map((_, i) => (
107
+ <Button
108
+ key={i}
109
+ variant={currentPage === i + 1 ? 'primary' : 'ghost'}
110
+ onClick={() => setCurrentPage(i + 1)}
111
+ >
112
+ {i + 1}
113
+ </Button>
114
+ ))}
115
+ <Button
116
+ variant="secondary"
117
+ disabled={currentPage === pageCount}
118
+ onClick={() => setCurrentPage(p => p + 1)}
119
+ >
120
+ &raquo;
121
+ </Button>
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import Label from './Label.jsx';
3
+
4
+ export default function Input({ label, error, variant = "default", className = '', ...props }) {
5
+ const variants = {
6
+ default: "flex h-10 w-full rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm ring-offset-white dark:ring-offset-slate-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 dark:placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 dark:focus-visible:ring-slate-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
7
+ error: "flex h-10 w-full rounded-md border border-red-500 dark:border-red-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm ring-offset-white dark:ring-offset-slate-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 dark:placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
8
+ fill: "flex h-10 w-full rounded-md border border-gray-100 dark:border-slate-800 bg-gray-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm ring-offset-white dark:ring-offset-slate-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 dark:placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 dark:focus-visible:ring-slate-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 w-full"
9
+ }
10
+ const style = variants[variant] || variants.default;
11
+ return (
12
+ <div className={`flex flex-col gap-1.5 ${className}`}>
13
+ {label && <Label>{label}</Label>}
14
+ <input
15
+ className={`${style} ${error ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
16
+ {...props}
17
+ />
18
+ {error && <span className="text-xs font-medium text-red-500">{error}</span>}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+
3
+ export default function Label({ children, className = '', ...props }) {
4
+ return (
5
+ <label
6
+ className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
7
+ {...props}
8
+ >
9
+ {children}
10
+ </label>
11
+ );
12
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { useSPA } from '../contexts/SPAContext.jsx';
3
+ import Button from './Button.jsx';
4
+
5
+ /**
6
+ * LanguageButton — A wrapper around the Button component that
7
+ * automatically handles language switching via navigateToLang.
8
+ *
9
+ * @param {Object} props
10
+ * @param {string} props.lang - The language code to switch to (e.g., "en", "es").
11
+ * @param {React.ReactNode} props.children - Button content.
12
+ */
13
+ function LanguageButton({ lang, children, ...props }) {
14
+ const { navigateToLang } = useSPA();
15
+
16
+ return (
17
+ <Button onClick={() => navigateToLang(lang)} {...props}>
18
+ {children}
19
+ </Button>
20
+ );
21
+ }
22
+
23
+ export default LanguageButton;
@@ -0,0 +1,16 @@
1
+ import { useSPA } from "../contexts/SPAContext.jsx";
2
+ import { Link as RouterLink } from "react-router-dom";
3
+
4
+ function Link({ to, children, className = 'text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 transition-colors', ...props }) {
5
+ const { l } = useSPA();
6
+
7
+ const localizedTo = l(to);
8
+
9
+ return (
10
+ <RouterLink to={localizedTo} {...props} className={className}>
11
+ {children}
12
+ </RouterLink>
13
+ );
14
+ }
15
+
16
+ export default Link;
@@ -0,0 +1,27 @@
1
+ import React, { useEffect } from 'react';
2
+
3
+ export default function Modal({ isOpen, onClose, title, children }) {
4
+ useEffect(() => {
5
+ if (isOpen) document.body.style.overflow = 'hidden';
6
+ else document.body.style.overflow = 'unset';
7
+ return () => { document.body.style.overflow = 'unset'; };
8
+ }, [isOpen]);
9
+
10
+ if (!isOpen) return null;
11
+
12
+ return (
13
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 transition-opacity">
14
+ <div className="bg-white dark:bg-slate-900 rounded-lg shadow-xl w-full max-w-lg mx-4 overflow-hidden transform transition-all border dark:border-slate-800">
15
+ <div className="flex justify-between items-center p-4 border-b dark:border-slate-800">
16
+ <h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
17
+ <button onClick={onClose} className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-2xl leading-none">
18
+ &times;
19
+ </button>
20
+ </div>
21
+ <div className="p-4 text-slate-900 dark:text-slate-100">
22
+ {children}
23
+ </div>
24
+ </div>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,45 @@
1
+ export default function Skeleton() {
2
+ return (
3
+ <div className="min-h-screen w-full bg-gray-50 p-4 md:p-8">
4
+ <div className="animate-pulse flex flex-col gap-6">
5
+
6
+ <div className="flex items-center justify-between w-full mb-4">
7
+ <div className="h-10 w-32 bg-gray-300 rounded-lg"></div>
8
+ <div className="flex space-x-4">
9
+ <div className="h-10 w-10 bg-gray-300 rounded-full"></div>
10
+ <div className="h-10 w-24 bg-gray-300 rounded-lg"></div>
11
+ </div>
12
+ </div>
13
+
14
+ <div className="h-48 md:h-64 w-full bg-gray-300 rounded-2xl"></div>
15
+
16
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
17
+ <div className="space-y-3">
18
+ <div className="h-40 w-full bg-gray-300 rounded-xl"></div>
19
+ <div className="h-4 w-3/4 bg-gray-300 rounded"></div>
20
+ <div className="h-4 w-1/2 bg-gray-300 rounded"></div>
21
+ </div>
22
+
23
+ <div className="space-y-3">
24
+ <div className="h-40 w-full bg-gray-300 rounded-xl"></div>
25
+ <div className="h-4 w-3/4 bg-gray-300 rounded"></div>
26
+ <div className="h-4 w-1/2 bg-gray-300 rounded"></div>
27
+ </div>
28
+
29
+ <div className="space-y-3">
30
+ <div className="h-40 w-full bg-gray-300 rounded-xl"></div>
31
+ <div className="h-4 w-3/4 bg-gray-300 rounded"></div>
32
+ <div className="h-4 w-1/2 bg-gray-300 rounded"></div>
33
+ </div>
34
+ </div>
35
+
36
+ <div className="space-y-2 mt-4">
37
+ <div className="h-4 w-full bg-gray-200 rounded"></div>
38
+ <div className="h-4 w-full bg-gray-200 rounded"></div>
39
+ <div className="h-4 w-2/3 bg-gray-200 rounded"></div>
40
+ </div>
41
+
42
+ </div>
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { useLanguage } from '../contexts/LanguageContext.jsx';
3
+
4
+ /**
5
+ * Renders translated text string given a key.
6
+ * @param {Object} props
7
+ * @param {string} props.k - The translation key.
8
+ */
9
+ export default function Translate({ k }) {
10
+ const { t } = useLanguage();
11
+ return <>{t(k)}</>;
12
+ }
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+
3
+ export default function Typography({ variant = 'p', children, className = '', gradient = false, ...props }) {
4
+ const variants = {
5
+ h1: "scroll-m-20 font-extrabold tracking-tight",
6
+ h2: "scroll-m-20 border-b pb-2 font-semibold tracking-tight first:mt-0",
7
+ h3: "scroll-m-20 font-semibold tracking-tight",
8
+ h4: "scroll-m-20 font-semibold tracking-tight",
9
+ p: "leading-7 [&:not(:first-child)]:mt-6",
10
+ blockquote: "mt-6 border-l-2 pl-6 italic",
11
+ lead: "text-xl text-slate-700 dark:text-slate-300",
12
+ large: "text-lg font-semibold",
13
+ small: "text-sm font-medium leading-none",
14
+ muted: "text-sm text-slate-500 dark:text-slate-400",
15
+ };
16
+
17
+ const fromColors = {
18
+ blue: 'from-blue-500',
19
+ sky: 'from-sky-500',
20
+ indigo: 'from-indigo-500',
21
+ violet: 'from-violet-500',
22
+ purple: 'from-purple-500',
23
+ fuchsia: 'from-fuchsia-500',
24
+ pink: 'from-pink-500',
25
+ rose: 'from-rose-500',
26
+ red: 'from-red-500',
27
+ orange: 'from-orange-500',
28
+ amber: 'from-amber-500',
29
+ yellow: 'from-yellow-500',
30
+ lime: 'from-lime-500',
31
+ green: 'from-green-500',
32
+ emerald: 'from-emerald-500',
33
+ teal: 'from-teal-500',
34
+ cyan: 'from-cyan-500',
35
+ };
36
+
37
+ const toColors = {
38
+ blue: 'to-blue-600',
39
+ sky: 'to-sky-600',
40
+ indigo: 'to-indigo-600',
41
+ violet: 'to-violet-600',
42
+ purple: 'to-purple-600',
43
+ fuchsia: 'to-fuchsia-600',
44
+ pink: 'to-pink-600',
45
+ rose: 'to-rose-600',
46
+ red: 'to-red-600',
47
+ orange: 'to-orange-600',
48
+ amber: 'to-amber-600',
49
+ yellow: 'to-yellow-600',
50
+ lime: 'to-lime-600',
51
+ green: 'to-green-600',
52
+ emerald: 'to-emerald-600',
53
+ teal: 'to-teal-600',
54
+ cyan: 'to-cyan-600',
55
+ };
56
+
57
+ const fromClass = gradient ? (fromColors[gradient.from] || `from-${gradient.from}-400`) : '';
58
+ const toClass = gradient ? (toColors[gradient.to] || `to-${gradient.to}-600`) : '';
59
+ const gradientText = gradient ? `bg-gradient-to-r ${fromClass} ${toClass} bg-clip-text text-transparent` : '';
60
+
61
+ const Component = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p'].includes(variant) ? variant : 'p';
62
+ const style = `${variants[variant]} ${className} ${gradientText}`;
63
+
64
+ return (
65
+ <Component className={style} {...props}>
66
+ {children}
67
+ </Component>
68
+ );
69
+ }
@@ -0,0 +1,41 @@
1
+ import React, { createContext, useState, useEffect, useContext } from 'react';
2
+ import en from '../locales/en.json';
3
+ import es from '../locales/es.json';
4
+
5
+ const translations = { en, es };
6
+ export const LanguageContext = createContext();
7
+
8
+ export const LanguageProvider = ({ children, initialLang }) => {
9
+
10
+ const [lang, setLang] = useState(() => localStorage.getItem('blue_bird_lang') || initialLang || 'en');
11
+
12
+ useEffect(() => {
13
+ localStorage.setItem('blue_bird_lang', lang);
14
+ }, [lang]);
15
+
16
+ /**
17
+ * Translate key into configured language text
18
+ * @param {string} key - the string config key
19
+ * @returns {string} The translated text
20
+ */
21
+ const t = (key) => {
22
+ const keys = key.split('.');
23
+ let value = translations[lang];
24
+ for (const k of keys) {
25
+ if (value && typeof value === 'object' && value !== null && k in value) {
26
+ value = value[k];
27
+ } else {
28
+ return key;
29
+ }
30
+ }
31
+ return value !== undefined ? value : key;
32
+ };
33
+
34
+ return (
35
+ <LanguageContext.Provider value={{ lang, setLang, t }}>
36
+ {children}
37
+ </LanguageContext.Provider>
38
+ );
39
+ };
40
+
41
+ export const useLanguage = () => useContext(LanguageContext);
@@ -0,0 +1,237 @@
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);
@@ -0,0 +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);