@dashadmin/dash-admin 1.3.42 → 1.3.45

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.
@@ -0,0 +1,129 @@
1
+ import React, { useState } from "react";
2
+ import Alert from "@mui/material/Alert";
3
+ import Box from "@mui/material/Box";
4
+ import IconButton from "@mui/material/IconButton";
5
+ import Tooltip from "@mui/material/Tooltip";
6
+ import Typography from "@mui/material/Typography";
7
+ import ContentCopyIcon from "@mui/icons-material/ContentCopy";
8
+ import CheckIcon from "@mui/icons-material/Check";
9
+ import { useI18nBridge } from "../../contexts/I18nBridgeContext";
10
+
11
+ export interface IApiKeyRevealContentProps {
12
+ /** The plaintext token, shown exactly once. */
13
+ token: string;
14
+ /** Name of the key, for context. */
15
+ name?: string | null;
16
+ }
17
+
18
+ /**
19
+ * One-time reveal of a newly issued API key.
20
+ *
21
+ * The plaintext credential is never persisted in recoverable form - only a
22
+ * short display prefix is stored - so this is the single moment the operator
23
+ * can copy it.
24
+ *
25
+ * Designed to be passed as the `content` of the framework dialog via a
26
+ * resource's `createSuccessDialog`, so it inherits the app's dialog chrome
27
+ * rather than introducing a second dialog system:
28
+ *
29
+ * createSuccessDialog: (data) => data?.plain_text_token
30
+ * ? { title: '...', content: <ApiKeyRevealContent token={data.plain_text_token} name={data.name} /> }
31
+ * : null,
32
+ */
33
+ export const ApiKeyRevealContent: React.FC<IApiKeyRevealContentProps> = ({ token, name }) => {
34
+ // DialogServiceProvider is mounted ABOVE <Admin> in DASHAppProviders, so
35
+ // dialog content renders outside React Admin's i18n context and
36
+ // useTranslate() would silently fall through to the English defaults.
37
+ // The bridge is the supported way to translate from out there - same
38
+ // pattern as AppMaterialMenu.
39
+ const { i18nProvider, locale } = useI18nBridge();
40
+ const [copied, setCopied] = useState(false);
41
+
42
+ const translate = React.useCallback(
43
+ (key: string, options?: any) => {
44
+ if (i18nProvider?.translate) {
45
+ try {
46
+ return i18nProvider.translate(key, options);
47
+ } catch {
48
+ return options?._ ?? key;
49
+ }
50
+ }
51
+ return options?._ ?? key;
52
+ },
53
+ // locale is a dependency so the text re-renders on a language switch.
54
+ [i18nProvider, locale]
55
+ );
56
+
57
+ const handleCopy = async () => {
58
+ try {
59
+ await navigator.clipboard.writeText(token);
60
+ setCopied(true);
61
+ setTimeout(() => setCopied(false), 2000);
62
+ } catch {
63
+ // Clipboard API unavailable (insecure context or denied permission).
64
+ // The value stays selectable so the operator can still copy it by
65
+ // hand - never leave them without a path to the secret.
66
+ setCopied(false);
67
+ }
68
+ };
69
+
70
+ return (
71
+ <Box>
72
+ <Alert severity="warning" sx={{ mb: 2 }}>
73
+ {translate("serviceAccount.reveal.warning", {
74
+ _: "This is the only time this key will be shown. It cannot be recovered later — if you lose it, you will have to issue a new one.",
75
+ })}
76
+ </Alert>
77
+
78
+ {name ? (
79
+ <Typography variant="body2" sx={{ mb: 1 }}>
80
+ {translate("serviceAccount.reveal.forKey", { _: "Key" })}: <strong>{name}</strong>
81
+ </Typography>
82
+ ) : null}
83
+
84
+ <Box
85
+ sx={{
86
+ display: "flex",
87
+ alignItems: "center",
88
+ gap: 1,
89
+ p: 1.5,
90
+ borderRadius: 1,
91
+ border: (theme) => `1px solid ${theme.palette.divider}`,
92
+ bgcolor: (theme) =>
93
+ theme.palette.mode === "dark" ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)",
94
+ }}
95
+ >
96
+ <Box
97
+ component="code"
98
+ sx={{
99
+ flex: 1,
100
+ fontFamily: "monospace",
101
+ fontSize: "0.875rem",
102
+ wordBreak: "break-all",
103
+ userSelect: "all",
104
+ }}
105
+ >
106
+ {token}
107
+ </Box>
108
+
109
+ <Tooltip
110
+ title={
111
+ copied
112
+ ? translate("serviceAccount.reveal.copied", { _: "Copied" })
113
+ : translate("serviceAccount.reveal.copy", { _: "Copy" })
114
+ }
115
+ >
116
+ <IconButton onClick={handleCopy} size="small" aria-label="copy api key">
117
+ {copied ? (
118
+ <CheckIcon color="success" fontSize="small" />
119
+ ) : (
120
+ <ContentCopyIcon fontSize="small" />
121
+ )}
122
+ </IconButton>
123
+ </Tooltip>
124
+ </Box>
125
+ </Box>
126
+ );
127
+ };
128
+
129
+ export default ApiKeyRevealContent;
@@ -0,0 +1,99 @@
1
+ import React, { useState } from "react";
2
+ import { useRecordContext, useNotify, useRefresh } from "react-admin";
3
+ import { useAxios } from "dash-axios-hook";
4
+ import Box from "@mui/material/Box";
5
+ import Switch from "@mui/material/Switch";
6
+ import FormControlLabel from "@mui/material/FormControlLabel";
7
+ import CircularProgress from "@mui/material/CircularProgress";
8
+ import { IDashAutoAdminCustomFieldComponent } from "dash-auto-admin";
9
+ import { useI18nBridge } from "../../contexts/I18nBridgeContext";
10
+
11
+ /**
12
+ * Enable/disable switch for a service account API key.
13
+ *
14
+ * Deliberately NOT a form field. Status is changed through the dedicated
15
+ * enable/disable endpoints rather than the update payload, for two reasons:
16
+ *
17
+ * - The update contract rejects everything except role_ids, so submitting a
18
+ * status would 422.
19
+ * - Disabling must not rotate the credential. The endpoints flip a flag and
20
+ * leave the token untouched, so re-enabling restores the same key rather
21
+ * than forcing every integration to redeploy a new secret.
22
+ *
23
+ * The switch therefore acts immediately and refreshes, instead of waiting for
24
+ * a form save that would never carry it.
25
+ */
26
+ const ServiceAccountStatusSwitch: React.FC<IDashAutoAdminCustomFieldComponent> = ({
27
+ resourceConfig,
28
+ }) => {
29
+ const record = useRecordContext();
30
+ const notify = useNotify();
31
+ const refresh = useRefresh();
32
+ const axios = useAxios();
33
+ const { i18nProvider } = useI18nBridge();
34
+ const [loading, setLoading] = useState(false);
35
+
36
+ const translate = React.useCallback(
37
+ (key: string, fallback: string) => {
38
+ try {
39
+ return i18nProvider?.translate(key, { _: fallback }) ?? fallback;
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ },
44
+ [i18nProvider]
45
+ );
46
+
47
+ if (!record) return null;
48
+
49
+ const isActive = record.status === "active";
50
+
51
+ const handleToggle = async () => {
52
+ setLoading(true);
53
+ try {
54
+ const action = isActive ? "disable" : "enable";
55
+
56
+ await axios.post(`${resourceConfig.model}/${record.id}/${action}`);
57
+
58
+ notify(
59
+ isActive
60
+ ? translate("serviceAccount.notify.disabled", "API key disabled")
61
+ : translate("serviceAccount.notify.enabled", "API key enabled"),
62
+ { type: "success" }
63
+ );
64
+
65
+ refresh();
66
+ } catch (error) {
67
+ console.error("ServiceAccountStatusSwitch: toggle failed", error);
68
+ notify(
69
+ translate("serviceAccount.notify.toggle_error", "Could not change the key status"),
70
+ { type: "error" }
71
+ );
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ };
76
+
77
+ return (
78
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1, my: 1 }}>
79
+ <FormControlLabel
80
+ control={
81
+ <Switch
82
+ checked={isActive}
83
+ onChange={handleToggle}
84
+ disabled={loading}
85
+ color="success"
86
+ />
87
+ }
88
+ label={
89
+ isActive
90
+ ? translate("serviceAccount.status.active", "Active")
91
+ : translate("serviceAccount.status.disabled", "Disabled")
92
+ }
93
+ />
94
+ {loading ? <CircularProgress size={16} /> : null}
95
+ </Box>
96
+ );
97
+ };
98
+
99
+ export default ServiceAccountStatusSwitch;
@@ -390,6 +390,7 @@ const AppMaterialMenu: React.FC<IAppMenuExtended> = props => {
390
390
  const isHorizontal = sidebarPosition === "top" || sidebarPosition === "bottom";
391
391
 
392
392
  return <>
393
+
393
394
  <Box className='sidebar-header'
394
395
 
395
396
  sx={{
@@ -227,7 +227,11 @@ const CollapsableSidebarMenu = ({
227
227
  />
228
228
  )}
229
229
  {navExpanded && (
230
- <div onClick={handleExpandClick}>
230
+ // "expand-icon" is ALSO on the wrapper (not just the inner svg): the row's
231
+ // onClick above does closest('.expand-icon') to tell "clicked the arrow"
232
+ // from "clicked the row" — if only the svg had it, clicking the circle's
233
+ // padding (outside the svg's own box) would miss the check and navigate.
234
+ <div className="expand-icon expand-icon-wrapper" onClick={handleExpandClick}>
231
235
  {/* For bottom position, invert icons since submenu opens upward */}
232
236
  {sidebarPosition === "bottom" ? (
233
237
  localOpen ?
@@ -20,9 +20,14 @@ export const getSubmenuBaseStyle = (
20
20
  overflowY: 'auto',
21
21
  width: sidebarPosition === "top" || sidebarPosition === "bottom" ? 'auto' : '260px',
22
22
  minWidth: '200px',
23
- background: 'var(--module-bg, #252526)',
24
- boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
25
- borderRadius: 8,
23
+ // The wrapper is the SINGLE owner of the panel visuals (surface, border,
24
+ // shadow, radius, padding). The inner ul.dropdown must stay transparent —
25
+ // if both layers draw a border/background, their edges show as a seam line.
26
+ background: 'var(--primary-contrast, #252526)',
27
+ border: '1px solid rgba(255,255,255,0.08)',
28
+ boxShadow: '0 10px 15px -3px rgba(0,0,0,0.25), 0 4px 6px -4px rgba(0,0,0,0.4)',
29
+ borderRadius: 12,
30
+ padding: 6,
26
31
  });
27
32
 
28
33
  /**
@@ -305,7 +305,7 @@ const AppSidebarMaterial: React.FC<AppSidebarMaterialProps> = (props) => {
305
305
 
306
306
  return (
307
307
  <Box sx={{ display: 'flex' }}>
308
-
308
+
309
309
  <MuiDrawer
310
310
  variant={localNavSize === "small" ? 'temporary' : 'permanent'}
311
311
  anchor={sidebarPosition}
@@ -2,6 +2,23 @@ import axios from 'axios';
2
2
  import constants from 'dash-constants/src/DASHAdminSystemConstants';
3
3
  import { dashStorage } from 'dash-utils';
4
4
 
5
+ // Track refresh state to prevent multiple simultaneous refresh attempts
6
+ let isRefreshing = false;
7
+ let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: any) => void }> = [];
8
+
9
+ const processQueue = (error: any, token?: string) => {
10
+ failedQueue.forEach(prom => {
11
+ if (error) {
12
+ prom.reject(error);
13
+ } else {
14
+ prom.resolve(token!);
15
+ }
16
+ });
17
+
18
+ isRefreshing = false;
19
+ failedQueue = [];
20
+ };
21
+
5
22
  export const initAxios = () => {
6
23
  let CSRFAuth = false;
7
24
 
@@ -36,8 +53,74 @@ export const initAxios = () => {
36
53
  //console.log("Axios Success", response);
37
54
  return response;
38
55
  },
39
- (error) => {
40
- window.dispatchEvent(new MessageEvent('GlobalError', { data: {error:error} }));
56
+ async (error) => {
57
+ const originalRequest = error.config;
58
+
59
+ // Skip refresh for auth-related endpoints to prevent infinite loops
60
+ const authEndpoints = ['/login', '/auth/refresh', '/logout', '/register'];
61
+ const isAuthEndpoint = authEndpoints.some(endpoint =>
62
+ originalRequest.url?.includes(endpoint)
63
+ );
64
+
65
+ // Handle 401 Unauthorized
66
+ if (error.response?.status === 401 && !isAuthEndpoint && !originalRequest._retry) {
67
+ if (isRefreshing) {
68
+ // If already refreshing, queue this request
69
+ return new Promise((resolve, reject) => {
70
+ failedQueue.push({ resolve, reject });
71
+ }).then(token => {
72
+ originalRequest.headers.Authorization = 'Bearer ' + token;
73
+ return instance(originalRequest);
74
+ }).catch(err => {
75
+ return Promise.reject(err);
76
+ });
77
+ }
78
+
79
+ originalRequest._retry = true;
80
+ isRefreshing = true;
81
+
82
+ try {
83
+ const refreshToken = dashStorage.getItem('refreshToken');
84
+
85
+ if (!refreshToken) {
86
+ // No refresh token available, logout user
87
+ await handleAuthFailure();
88
+ return Promise.reject(error);
89
+ }
90
+
91
+ // Attempt to refresh the access token
92
+ const refreshResponse = await axios.post(
93
+ `${constants.system.ADMIN_API_URL}/auth/refresh`,
94
+ { refresh_token: refreshToken }
95
+ );
96
+
97
+ const { token, refresh_token } = refreshResponse.data;
98
+
99
+ // Store new tokens
100
+ dashStorage.setItem('token', token);
101
+ dashStorage.setItem('refreshToken', refresh_token);
102
+
103
+ // Update original request with new token
104
+ originalRequest.headers.Authorization = 'Bearer ' + token;
105
+
106
+ // Process queued requests
107
+ processQueue(null, token);
108
+
109
+ // Retry original request
110
+ return instance(originalRequest);
111
+ } catch (refreshError) {
112
+ // Refresh failed, logout user
113
+ await handleAuthFailure();
114
+ processQueue(refreshError, null);
115
+ return Promise.reject(refreshError);
116
+ }
117
+ }
118
+
119
+ // Dispatch global error event for other errors
120
+ if (error.response?.status !== 401) {
121
+ window.dispatchEvent(new MessageEvent('GlobalError', { data: { error } }));
122
+ }
123
+
41
124
  if (error.response && error.response.status === 422) {
42
125
  /**
43
126
  * Error Handling This is important for the system to parse Form field errors within AutoAdmin.
@@ -45,6 +128,7 @@ export const initAxios = () => {
45
128
  **/
46
129
  return Promise.reject(error.response.data?.errors || error);
47
130
  }
131
+
48
132
  return Promise.reject(error);
49
133
  },
50
134
  );
@@ -52,6 +136,25 @@ export const initAxios = () => {
52
136
  return instance;
53
137
  };
54
138
 
139
+ // Helper function to handle authentication failure
140
+ async function handleAuthFailure() {
141
+ // Clear auth data
142
+ dashStorage.removeItem('token');
143
+ dashStorage.removeItem('refreshToken');
144
+ dashStorage.removeItem('user');
145
+ dashStorage.removeItem('auth');
146
+ dashStorage.removeItem('authenticated');
147
+ dashStorage.removeItem('roles');
148
+
149
+ // Dispatch logout event
150
+ window.dispatchEvent(new MessageEvent('auth:logout'));
151
+
152
+ // Redirect to login if not already there
153
+ if (!window.location.pathname.includes('/login')) {
154
+ window.location.href = '/login';
155
+ }
156
+ }
157
+
55
158
  const useAxios = () => {
56
159
  const axios = initAxios();
57
160
  return {
@@ -9,9 +9,11 @@ export const logoutFromStorage = async () => {
9
9
  removeCookie('token');
10
10
  dashStorage.removeItem('user');
11
11
  dashStorage.removeItem('auth');
12
+ dashStorage.removeItem('token');
13
+ dashStorage.removeItem('refreshToken'); // Clear refresh token for security
12
14
  dashStorage.setItem('authenticated', 'false');
13
15
  dashStorage.setItem('roles', null); // before it was guest role.
14
-
16
+
15
17
  // Clear auth data from device store (Electron/Capacitor)
16
18
  await clearDeviceStoreAuth();
17
19
  };
@@ -40,6 +42,11 @@ export default {
40
42
 
41
43
  dashStorage.setItem('token', loginResponse.data.token);
42
44
 
45
+ // Store refresh token for automatic token refresh
46
+ if (loginResponse.data.refresh_token) {
47
+ dashStorage.setItem('refreshToken', loginResponse.data.refresh_token);
48
+ }
49
+
43
50
  try {
44
51
  let { data: auth } = await axios.get('/auth/getauth');
45
52
 
@@ -311,8 +311,57 @@ const dashEnglish = {
311
311
  auto_reset_notice: 'This screen will automatically reset in 30 seconds',
312
312
  loading: 'Loading...',
313
313
  },
314
+ // Service account API keys
315
+ serviceAccount: {
316
+ reveal: {
317
+ title: 'Copy your API key now',
318
+ warning:
319
+ 'This is the only time this key will be shown. It cannot be recovered later — if you lose it, you will have to issue a new one.',
320
+ forKey: 'Key',
321
+ copy: 'Copy',
322
+ copied: 'Copied',
323
+ dismiss: 'I have saved it',
324
+ },
325
+ status: {
326
+ active: 'Active',
327
+ disabled: 'Disabled',
328
+ },
329
+ expiration: {
330
+ '1': '1 day',
331
+ '7': '7 days',
332
+ '30': '30 days',
333
+ never: 'Never',
334
+ },
335
+ notify: {
336
+ enabled: 'API key enabled',
337
+ disabled: 'API key disabled',
338
+ toggle_error: 'Could not change the key status',
339
+ },
340
+ action: {
341
+ enable: 'Enable',
342
+ disable: 'Disable',
343
+ },
344
+ // Shown when a tenancy-level role is selected: such a key reaches every
345
+ // tenant in the account, so the tenant field stops being a boundary.
346
+ tenancy_role_warning:
347
+ 'This role grants access to every tenant in the account. The selected tenant is informational, not a security boundary.',
348
+ },
314
349
  // Resource labels for admin panel
315
350
  resource: {
351
+ tenancy: {
352
+ serviceAccounts: {
353
+ label: 'Service Accounts',
354
+ name: 'Name',
355
+ tenant: 'Tenant',
356
+ roles: 'Roles',
357
+ expiration: 'Expiration',
358
+ status: 'Status',
359
+ token: 'Key',
360
+ expires_at: 'Expires',
361
+ last_used_at: 'Last used',
362
+ created_at: 'Created',
363
+ },
364
+ },
316
365
  // Groups
317
366
  groups: {
318
367
  system_resources: 'System Resources',
@@ -525,6 +574,37 @@ const dashEnglish = {
525
574
  has_modifiers: 'Customizable',
526
575
  no_more_products: 'No more products',
527
576
  },
577
+ // JSON Color Selector - image-based theme extraction
578
+ colorSelector: {
579
+ imageExtractor: {
580
+ title_ai: 'AI Theme Generator',
581
+ title_local: 'Theme from Image',
582
+ upload_prompt: 'Upload an Image to Extract Colors',
583
+ upload_hint: "Select an image file and we'll automatically extract a color palette",
584
+ upload_button: 'Choose Image',
585
+ drop_hint: 'or drag and drop it here',
586
+ invalid_file_type: 'Please select a valid image file',
587
+ file_too_large: 'File size must be less than %{limit}MB',
588
+ extracting: 'Extracting colors from image...',
589
+ extracted_colors: 'Extracted Colors (%{count} colors found)',
590
+ dominant_label: 'Dominant',
591
+ uploaded_image_alt: 'Uploaded image',
592
+ no_palette_error: 'Please extract colors from an image first',
593
+ theme_generation_failed: 'Failed to generate theme',
594
+ theme_description_label: 'Theme Description (Optional)',
595
+ theme_description_placeholder:
596
+ "Optional: Describe the theme style you want (e.g., 'modern and minimalist', 'warm and cozy', 'professional corporate')",
597
+ apply_colors_button: 'Apply Colors',
598
+ generate_button: 'Generate AI Theme',
599
+ generating_button: 'Generating...',
600
+ apply_colors_help_label: 'Apply Colors:',
601
+ apply_colors_help_desc:
602
+ 'Maps the extracted palette to your base theme colors locally — no AI involved',
603
+ generate_help_label: 'Generate AI Theme:',
604
+ generate_help_desc:
605
+ 'Uses AI to create a complete color scheme based on extracted colors and your description',
606
+ },
607
+ },
528
608
  };
529
609
 
530
610
  export default {
@@ -318,8 +318,57 @@ const dashSpanish = {
318
318
  auto_reset_notice: 'Esta pantalla se reiniciará automáticamente en 30 segundos',
319
319
  loading: 'Cargando...',
320
320
  },
321
+ // Service account API keys
322
+ serviceAccount: {
323
+ reveal: {
324
+ title: 'Copia tu API Key ahora',
325
+ warning:
326
+ 'Esta es la única vez que se mostrará esta clave. No se puede recuperar más adelante — si la pierdes, tendrás que emitir una nueva.',
327
+ forKey: 'Clave',
328
+ copy: 'Copiar',
329
+ copied: 'Copiado',
330
+ dismiss: 'La he guardado',
331
+ },
332
+ status: {
333
+ active: 'Activa',
334
+ disabled: 'Deshabilitada',
335
+ },
336
+ expiration: {
337
+ '1': '1 día',
338
+ '7': '7 días',
339
+ '30': '30 días',
340
+ never: 'Nunca',
341
+ },
342
+ notify: {
343
+ enabled: 'API Key habilitada',
344
+ disabled: 'API Key deshabilitada',
345
+ toggle_error: 'No se pudo cambiar el estado de la API Key',
346
+ },
347
+ action: {
348
+ enable: 'Habilitar',
349
+ disable: 'Deshabilitar',
350
+ },
351
+ // Shown when a tenancy-level role is selected: such a key reaches every
352
+ // tenant in the account, so the tenant field stops being a boundary.
353
+ tenancy_role_warning:
354
+ 'Este rol otorga acceso a todos los locales de la cuenta. El local seleccionado es informativo, no un límite de seguridad.',
355
+ },
321
356
  // Resource labels for admin panel
322
357
  resource: {
358
+ tenancy: {
359
+ serviceAccounts: {
360
+ label: 'Cuentas de Servicio',
361
+ name: 'Nombre',
362
+ tenant: 'Local',
363
+ roles: 'Roles',
364
+ expiration: 'Expiración',
365
+ status: 'Estado',
366
+ token: 'Clave',
367
+ expires_at: 'Expira',
368
+ last_used_at: 'Último uso',
369
+ created_at: 'Creado',
370
+ },
371
+ },
323
372
  // Groups
324
373
  groups: {
325
374
  system_resources: 'Recursos de Sistema',
@@ -531,6 +580,37 @@ const dashSpanish = {
531
580
  has_modifiers: 'Personalizable',
532
581
  no_more_products: 'No hay más productos',
533
582
  },
583
+ // JSON Color Selector - extracción de tema desde imagen
584
+ colorSelector: {
585
+ imageExtractor: {
586
+ title_ai: 'Generador de Tema con IA',
587
+ title_local: 'Tema desde Imagen',
588
+ upload_prompt: 'Sube una Imagen para Extraer Colores',
589
+ upload_hint: 'Selecciona un archivo de imagen y extraeremos automáticamente una paleta de colores',
590
+ upload_button: 'Elegir Imagen',
591
+ drop_hint: 'o arrástrala aquí',
592
+ invalid_file_type: 'Por favor selecciona un archivo de imagen válido',
593
+ file_too_large: 'El archivo debe pesar menos de %{limit}MB',
594
+ extracting: 'Extrayendo colores de la imagen...',
595
+ extracted_colors: 'Colores Extraídos (%{count} colores encontrados)',
596
+ dominant_label: 'Dominante',
597
+ uploaded_image_alt: 'Imagen subida',
598
+ no_palette_error: 'Primero extrae los colores de una imagen',
599
+ theme_generation_failed: 'No se pudo generar el tema',
600
+ theme_description_label: 'Descripción del Tema (Opcional)',
601
+ theme_description_placeholder:
602
+ "Opcional: Describe el estilo de tema que deseas (ej: 'moderno y minimalista', 'cálido y acogedor', 'corporativo profesional')",
603
+ apply_colors_button: 'Aplicar Colores',
604
+ generate_button: 'Generar Tema con IA',
605
+ generating_button: 'Generando...',
606
+ apply_colors_help_label: 'Aplicar Colores:',
607
+ apply_colors_help_desc:
608
+ 'Aplica la paleta extraída a los colores base de tu tema localmente — sin usar IA',
609
+ generate_help_label: 'Generar Tema con IA:',
610
+ generate_help_desc:
611
+ 'Usa IA para crear un esquema de color completo basado en los colores extraídos y tu descripción',
612
+ },
613
+ },
534
614
  };
535
615
 
536
616
  export default {
@@ -3,3 +3,6 @@ import esMessages from './es';
3
3
 
4
4
  export const en = enMessages;
5
5
  export const es = esMessages;
6
+
7
+ export { mergeTranslations } from './mergeTranslations';
8
+ export type { TranslationMessages } from './mergeTranslations';