@seip/blue-bird 0.4.4 → 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.
- package/.env_example +26 -25
- package/AGENTS.md +199 -199
- package/LICENSE +21 -0
- package/README.md +79 -79
- package/backend/index.js +13 -13
- package/backend/routes/api.js +31 -31
- package/backend/routes/frontend.js +41 -41
- package/backend/routes/seo.js +39 -39
- package/core/app.js +328 -325
- package/core/auth.js +114 -83
- package/core/cache.js +44 -44
- package/core/cli/component.js +42 -42
- package/core/cli/init.js +119 -118
- package/core/cli/react.js +435 -435
- package/core/cli/route.js +42 -42
- package/core/cli/scaffolding-auth.js +1037 -0
- package/core/config.js +48 -47
- package/core/debug.js +248 -248
- package/core/logger.js +100 -100
- package/core/middleware.js +27 -27
- package/core/router.js +333 -333
- package/core/seo.js +95 -100
- package/core/swagger.js +40 -25
- package/core/template.js +472 -462
- package/core/upload.js +76 -76
- package/core/validate.js +380 -380
- package/frontend/index.html +26 -26
- package/frontend/landing.html +69 -69
- package/frontend/resources/css/tailwind.css +17 -17
- package/frontend/resources/js/App.jsx +70 -70
- package/frontend/resources/js/Main.jsx +18 -18
- package/frontend/resources/js/blue-bird/components/Button.jsx +67 -67
- package/frontend/resources/js/blue-bird/components/Card.jsx +18 -18
- package/frontend/resources/js/blue-bird/components/DataTable.jsx +126 -126
- package/frontend/resources/js/blue-bird/components/Input.jsx +21 -21
- package/frontend/resources/js/blue-bird/components/Label.jsx +12 -12
- package/frontend/resources/js/blue-bird/components/LanguageButton.jsx +23 -23
- package/frontend/resources/js/blue-bird/components/Link.jsx +15 -15
- package/frontend/resources/js/blue-bird/components/Modal.jsx +27 -27
- package/frontend/resources/js/blue-bird/components/Skeleton.jsx +44 -44
- package/frontend/resources/js/blue-bird/components/Translate.jsx +12 -12
- package/frontend/resources/js/blue-bird/components/Typography.jsx +69 -69
- package/frontend/resources/js/blue-bird/contexts/LanguageContext.jsx +41 -41
- package/frontend/resources/js/blue-bird/contexts/SPAContext.jsx +239 -237
- package/frontend/resources/js/blue-bird/contexts/SnackbarContext.jsx +38 -38
- package/frontend/resources/js/blue-bird/contexts/ThemeContext.jsx +49 -49
- package/frontend/resources/js/blue-bird/locales/en.json +47 -47
- package/frontend/resources/js/blue-bird/locales/es.json +47 -47
- package/frontend/resources/js/components/Header.jsx +55 -55
- package/frontend/resources/js/pages/About.jsx +31 -31
- package/frontend/resources/js/pages/Home.jsx +82 -82
- package/package.json +57 -57
- package/vite.config.js +22 -22
|
@@ -1,126 +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
|
-
«
|
|
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
|
-
»
|
|
121
|
-
</Button>
|
|
122
|
-
</div>
|
|
123
|
-
)}
|
|
124
|
-
</div>
|
|
125
|
-
);
|
|
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
|
+
«
|
|
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
|
+
»
|
|
121
|
+
</Button>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -1,21 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,12 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,23 +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;
|
|
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;
|
|
@@ -1,16 +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
|
-
|
|
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
16
|
export default Link;
|
|
@@ -1,27 +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
|
-
×
|
|
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
|
-
}
|
|
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
|
+
×
|
|
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
|
+
}
|
|
@@ -1,45 +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
|
-
)
|
|
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
45
|
}
|
|
@@ -1,12 +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
|
-
}
|
|
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
|
+
}
|