data-primals-engine 1.2.6 → 1.3.0

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/README.md CHANGED
@@ -84,6 +84,8 @@ MONGO_DB_URL=mongodb://127.0.0.1:27017
84
84
  | JWT_SECRET | Secret key for signing JWT authentication tokens. | a_long_random_secret_string |
85
85
  | OPENAI_API_KEY | Your optional OpenAI API key for AI features. | sk-xxxxxxxxxxxxxxxxxxxx |
86
86
  | GOOGLE_API_KEY | Your optional Google (Gemini) API key for AI features. | AIzaSyxxxxxxxxxxxxxxxxxxxx |
87
+ | DEEPSEEK_API_KEY | Your optional DeepSeek API key for AI features. | AIzaSyxxxxxxxxxxxxxxxxxxxx |
88
+ | ANTHROPIC_API_KEY | Your optional Anthropic API key for AI features. | AIzaSyxxxxxxxxxxxxxxxxxxxx |
87
89
  | AWS_ACCESS_KEY_ID | AWS access key for S3 storage (files, backups). Keep empty to disable | AKIAIOSFODNN7EXAMPLE |
88
90
  | AWS_SECRET_ACCESS_KEY | AWS secret access key. | wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY | |
89
91
  | AWS_REGION | Region for your S3 bucket. | eu-west-3 | |
@@ -489,15 +491,49 @@ await loadFromDump(currentUser, { modelsOnly: false });
489
491
 
490
492
  ### installPack(packId, user, lang)
491
493
 
492
- > Installs a predefined data pack.
494
+ > Installs a data pack.
493
495
 
494
496
  Example:
495
497
 
498
+ ```javascript
499
+ const result = await installPack([
500
+ {
501
+ "name": "My custom pack",
502
+ "description": "Markdown **description** of the pack",
503
+ "tags": ["customPack", "tag1", "tag2"],
504
+ "models": [
505
+ "env", // default model
506
+ {
507
+ "name": "post",
508
+ "description": "Defines a post",
509
+ "fields": [
510
+ {"name": "subject", "type": "string", "required": true},
511
+ ]
512
+ }, // or custom
513
+ ],
514
+ "data": {
515
+ "all": { // all languages installed data
516
+ "post": [
517
+ {"subject": "My pack first data"}
518
+ ]
519
+ },
520
+ "en": { // English specific installed data
521
+ "post": [
522
+ {"subject": "My english first post"}
523
+ ]
524
+ }
525
+ }
526
+ }
527
+ ], user, "en");
528
+ // Returns installation summary
529
+ ```
530
+
496
531
  ```javascript
497
532
  const result = await installPack("61d1f1a9e3f1a9e3f1a9e3f1", user, "en");
498
533
  // Returns installation summary
499
534
  ```
500
535
 
536
+ > You can also open the pack gallery to see the JSON structure of each pack, before installing them.
501
537
 
502
538
  ---
503
539
 
@@ -538,7 +574,7 @@ A workflow is composed of two main parts: **Triggers** and **Actions**.
538
574
  - **CreateData**: Create a new document in any model.
539
575
  - **UpdateData**: Modify one or more existing documents that match a filter.
540
576
  - **SendEmail**: Send a transactional email using dynamic data from the trigger.
541
- - **CallWebhook**: Make an HTTP request (GET, POST, etc.) to an external service or API.
577
+ - **HttpRequest**: Make an HTTP request (GET, POST, etc.) to an external service or API.
542
578
  - **ExecuteScript**: Run a custom JavaScript snippet for complex logic, data transformation, or conditional branching.
543
579
  - **GenerateAIContent**: Use an integrated AI provider (like OpenAI or Gemini) to generate text, summarize content, or make decisions.
544
580
  - **Wait**: Pause the workflow for a specific duration before continuing to the next step
@@ -61,6 +61,7 @@ import i18next from "i18next";
61
61
  import {websiteTranslations} from "./translations.js";
62
62
 
63
63
  import { Tooltip } from 'react-tooltip';
64
+ import {providers} from "../../src/modules/assistant/constants.js";
64
65
 
65
66
  let queryClient = new QueryClient();
66
67
 
@@ -113,40 +114,32 @@ function Layout ({header, routes, body, footer}) {
113
114
  },
114
115
  body: JSON.stringify({
115
116
  model: 'env', // On cible le modèle 'env'
116
- filter: {
117
- // On cherche les enregistrements dont le nom est SOIT l'un, SOIT l'autre
118
- "$or": [
119
- { "name": "OPENAI_API_KEY" },
120
- { "name": "GOOGLE_API_KEY" }
121
- ]
122
- },
123
- limit: 2 // Pas besoin de plus de 2 résultats
117
+ filter: { "name": { "$in": Object.values(providers).map(p => p.key) } },
118
+ limit: Object.values(providers).length
124
119
  })
125
120
  }).then(res => res.json());
126
121
  },
127
122
  {
128
123
  enabled: !!me && models.find(f => f.name === 'env' && me?.username === f.username), // Toujours activé uniquement si l'utilisateur est connecté
129
- staleTime: 60000, // On peut réduire le staleTime, car l'utilisateur peut ajouter/supprimer une clé à tout moment
124
+ staleTime: 60000,
130
125
  onSuccess: (response) => {
131
-
132
- // La réponse de l'API de recherche est un objet { success: true, data: [...] }
133
126
  if (!response.data) {
134
127
  setAssistantConfig(null);
135
128
  return;
136
129
  }
137
-
138
130
  const availableKeys = response.data;
131
+ const newConfig = Object.keys(providers).reduce((config, providerKey) => {
132
+ const envVarName = providers[providerKey].key;
133
+ const foundKey = availableKeys.find(key => key.name === envVarName);
134
+ if (foundKey) {
135
+ config[providerKey] = foundKey.value;
136
+ }
137
+ return config;
138
+ }, {});
139
139
 
140
- const newConfig = {
141
- openai: availableKeys.find(key => key.name === 'OPENAI_API_KEY')?.value || (useAI ? process.env.OPENAI_API_KEY : undefined),
142
- google: availableKeys.find(key => key.name === 'GOOGLE_API_KEY')?.value || (useAI ? process.env.GOOGLE_API_KEY : undefined),
143
- };
144
-
145
- // On met à jour l'état si au moins une clé est disponible
146
- if (newConfig.openai || newConfig.google) {
140
+ if (Object.values(newConfig).filter(Boolean).length > 0) {
147
141
  setAssistantConfig(newConfig);
148
142
  } else {
149
- // Important : on s'assure de remettre à null si aucune clé n'est trouvée
150
143
  setAssistantConfig(null);
151
144
  }
152
145
  },
@@ -7,6 +7,7 @@ import { useModelContext } from "./contexts/ModelContext.jsx";
7
7
  import { Trans, useTranslation } from "react-i18next";
8
8
  import Markdown from 'react-markdown';
9
9
  import {useQueryClient} from "react-query";
10
+ import {providers} from "../../src/modules/assistant/constants.js";
10
11
 
11
12
  const AssistantChat = ({ config }) => {
12
13
  const { selectedModel } = useModelContext();
@@ -170,10 +171,10 @@ const AssistantChat = ({ config }) => {
170
171
 
171
172
  // Logique pour le sélecteur de fournisseur (OpenAI/Google)
172
173
  const availableProviders = useMemo(() => {
173
- const providers = [];
174
- if (config?.openai) providers.push({ value: 'openai', label: 'OpenAI' });
175
- if (config?.google) providers.push({ value: 'google', label: 'Google' });
176
- return providers;
174
+ const prs = Object.keys(providers).map(p => {
175
+ return config?.[p] ? ({value: p, label: p}) : null;
176
+ })
177
+ return prs.filter(Boolean);
177
178
  }, [config]);
178
179
 
179
180
  const [selectedProvider, setSelectedProvider] = useState(null);
@@ -241,7 +241,7 @@ export const DataEditor = forwardRef(function MyDataEditor({
241
241
  return <CheckboxField
242
242
  help={t('field_' + model.name + '_' + field.name + '_hint', field.hint || '')}
243
243
  key={field.name} {...inputProps} checked={value}
244
- onChange={(e) => handleChange({name: field.name, value: e.target.checked})}/>
244
+ onChange={(e) => handleChange({name: field.name, value: e})}/>
245
245
  case 'string':
246
246
  case 'string_t':
247
247
  {
@@ -165,6 +165,51 @@ const Header = ({
165
165
  ;
166
166
  }
167
167
 
168
+
169
+ const RichText = ({ value, initialLang }) => {
170
+ const availableLangs = useMemo(() => (value ? Object.keys(value).filter(k => value[k]) : []), [value]);
171
+
172
+ const [selectedLang, setSelectedLang] = useState(() => {
173
+ if (availableLangs.includes(initialLang)) {
174
+ return initialLang;
175
+ }
176
+ return availableLangs[0] || null;
177
+ });
178
+
179
+ useEffect(() => {
180
+ setSelectedLang(initialLang);
181
+ }, [initialLang]);
182
+
183
+ if (!value || availableLangs.length === 0) {
184
+ return null;
185
+ }
186
+
187
+ if (availableLangs.length === 1) {
188
+ return <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[availableLangs[0]] || '' }} />;
189
+ }
190
+
191
+ return (
192
+ <div className="richtext-t-cell">
193
+ <div className="lang-switcher">
194
+ {availableLangs.map(langCode => (
195
+ <button
196
+ key={langCode}
197
+ className={`lang-btn ${selectedLang === langCode ? 'active' : ''}`}
198
+ onClick={(e) => {
199
+ e.stopPropagation();
200
+ setSelectedLang(langCode);
201
+ }}
202
+ title={langCode.toUpperCase()}
203
+ >
204
+ {langCode.toUpperCase()}
205
+ </button>
206
+ ))}
207
+ </div>
208
+ <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[selectedLang] || '' }} />
209
+ </div>
210
+ );
211
+ };
212
+
168
213
  export function DataTable({
169
214
  model,
170
215
  checkedItems,
@@ -458,7 +503,7 @@ export function DataTable({
458
503
  <CheckboxField className={"input-ref"}
459
504
  checked={checkedItems.some(i => i._id === item._id)}
460
505
  onChange={(e) => {
461
- if (e.target.checked) {
506
+ if (e) {
462
507
  setCheckedItems(items => {
463
508
  return [...items, item];
464
509
  });
@@ -630,8 +675,7 @@ export function DataTable({
630
675
  }
631
676
  if (field.type === 'richtext_t') {
632
677
  return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
633
- {hiddenable(<div className="rte-value"
634
- dangerouslySetInnerHTML={{__html: item[field.name]?.[lang] || ''}}></div>)}
678
+ {hiddenable(<RichText value={item[field.name]} initialLang={lang} />)}
635
679
  </td>;
636
680
  }
637
681
  if (field.type === 'color') {
@@ -99,7 +99,7 @@ function ExportDialog({ isOpen, onClose, onExport, currentModel, availableModels
99
99
  name="exportSelection"
100
100
  label={t('exportDialog.exportSelection.label', "Exporter la sélection")}
101
101
  checked={exportSelection}
102
- onChange={(e) => setExportSelection(e.target.checked)}
102
+ onChange={(e) => setExportSelection(e)}
103
103
  disabled={!hasSelection}
104
104
  hint={hasSelection
105
105
  ? t('exportDialog.exportSelection.hint', "Exportera uniquement les lignes actuellement sélectionnées dans le tableau.")
@@ -119,7 +119,7 @@ function ExportDialog({ isOpen, onClose, onExport, currentModel, availableModels
119
119
  multiple={true}
120
120
  />
121
121
 
122
- <CheckboxField label={t('exportDialog.withModels.label', "Inclure les modèles ?")} checked={withModels} onChange={(e) => setWithModels(e.target.checked)} />
122
+ <CheckboxField label={t('exportDialog.withModels.label', "Inclure les modèles ?")} checked={withModels} onChange={(e) => setWithModels(e)} />
123
123
 
124
124
  {/* OU: Utiliser un composant MultiSelectField (à créer/importer) pour plusieurs modèles */}
125
125
  {/*
@@ -1431,8 +1431,8 @@ export const ModelField = ({field, disableable=false, showModel=true, value, fie
1431
1431
  <CheckboxField
1432
1432
  checked={checked}
1433
1433
  onChange={e => {
1434
- setChecked(e.target.checked);
1435
- if (!e.target.checked) {
1434
+ setChecked(e);
1435
+ if (!e) {
1436
1436
  onChange({name: field?.name, value: null});
1437
1437
  }
1438
1438
  }}
@@ -12,9 +12,9 @@ const KanbanCard = ({ card, model, subItemsField }) => {
12
12
  const subItems = subItemsField && card[subItemsField] ? card[subItemsField] : [];
13
13
  const tr = useTranslation();
14
14
  // Logique du Drag & Drop natif pour la carte
15
-
16
15
  const lang = (tr.i18n.resolvedLanguage || tr.i18n.language).split(/[-_]/)?.[0];
17
16
 
17
+ console.log(subItems)
18
18
  const getfield = (model, data) => {
19
19
  const v = getDataAsString(model, data, tr, models);
20
20
  if(!v){
@@ -66,6 +66,8 @@ const KanbanCard = ({ card, model, subItemsField }) => {
66
66
  <div className="kanban-card-subitems">
67
67
  {subItems.map((subItem, index) => {
68
68
  const it = subItem;
69
+ if( typeof(subItem) === 'string')
70
+ return subItem;
69
71
  const v = getDataAsString(model, subItem, tr, models);
70
72
  if (!v) {
71
73
  const r = `
@@ -81,7 +83,7 @@ const KanbanCard = ({ card, model, subItemsField }) => {
81
83
  className="image" src={`/resources/${it.guid}`}
82
84
  alt={`${it.name} (${it.guid})`}/></a>
83
85
  }
84
- return '';
86
+ return v;
85
87
  })}
86
88
  </div>
87
89
  )}
@@ -19,7 +19,7 @@ const KanbanConfigModal = ({ isOpen, onClose, onSave, model, initialSettings })
19
19
  const groupableFields = useMemo(() => {
20
20
  // ... (pas de changement ici)
21
21
  return modelFields
22
- .filter(field => ['select', 'string', 'string_t'].includes(field.type))
22
+ .filter(field => ['enum', 'model', 'string', 'string_t'].includes(field.type))
23
23
  .map(field => ({
24
24
  label: t(`field_${model.name}_${field.name}`, field.name),
25
25
  value: field.name,
@@ -29,7 +29,7 @@ const KanbanConfigModal = ({ isOpen, onClose, onSave, model, initialSettings })
29
29
  const subItemFields = useMemo(() => {
30
30
  // ... (pas de changement ici)
31
31
  return modelFields
32
- .filter(field => field.type === 'relation' && field.multiple)
32
+ .filter(field => (field.type === 'relation' && field.multiple) || field.type === 'array')
33
33
  .map(field => ({
34
34
  label: t(`field_${field.name}`, { defaultValue: field.name }),
35
35
  value: field.name,
@@ -64,11 +64,9 @@ const KanbanConfigModal = ({ isOpen, onClose, onSave, model, initialSettings })
64
64
  onSave({ groupByField, subItemsField: subItemsField || '' });
65
65
  };
66
66
 
67
- // --- MODIFICATION 3 : Supprimer le useEffect fautif ---
68
- // useEffect(() => {
69
- // setGroupByField(groupableFields[0]?.value || '');
70
- // }, []); // <--- CE BLOC EST SUPPRIMÉ
71
-
67
+ useEffect(() => {
68
+ setSubItemsField(subItemFields[0]?.value);
69
+ })
72
70
  if (!isOpen) return null;
73
71
 
74
72
  return (
@@ -284,7 +284,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
284
284
  checked={field.multiline}
285
285
  onChange={(e) => {
286
286
  const newFields = [...fields];
287
- newFields[index].multiline = e.target.checked;
287
+ newFields[index].multiline = e;
288
288
  setFields(newFields);
289
289
  }}
290
290
  help={field.multiline && t('modelcreator.multiline.hint')}
@@ -373,7 +373,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
373
373
  checked={field.multiple}
374
374
  onChange={(e) => {
375
375
  const newFields = [...fields];
376
- newFields[index].multiple = e.target.checked;
376
+ newFields[index].multiple = e;
377
377
  setFields(newFields);
378
378
  }}
379
379
  /></div>
@@ -422,7 +422,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
422
422
  checked={field.required}
423
423
  onChange={(e) => {
424
424
  const newFields = [...fields];
425
- newFields[index].required = e.target.checked;
425
+ newFields[index].required = e;
426
426
  setFields(newFields);
427
427
  }}
428
428
  help={field.required && t('modelcreator.required.hint')}
@@ -440,7 +440,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
440
440
  checked={field.unique}
441
441
  onChange={(e) => {
442
442
  const newFields = [...fields];
443
- newFields[index].unique = e.target.checked;
443
+ newFields[index].unique = e;
444
444
  setFields(newFields);
445
445
  }}
446
446
  help={field.unique && t('modelcreator.unique.hint')}
@@ -522,7 +522,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
522
522
  disabled={modelLocked || (isLocalUser(me) && field.locked)}
523
523
  onChange={(e) => {
524
524
  const newFields = [...fields];
525
- newFields[index].default = e.target.checked;
525
+ newFields[index].default = e;
526
526
  setFields(newFields);
527
527
  }}
528
528
  />
@@ -612,7 +612,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
612
612
  checked={field.condition !== undefined}
613
613
  onChange={(e) => {
614
614
  const newFields = [...fields];
615
- if (e.target.checked) {
615
+ if (e) {
616
616
  newFields[index].condition = null;
617
617
  } else {
618
618
  delete newFields[index].condition;
@@ -701,7 +701,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
701
701
  checked={field.asMain}
702
702
  onChange={(e) => {
703
703
  const newFields = [...fields];
704
- newFields[index].asMain = e.target.checked;
704
+ newFields[index].asMain = e;
705
705
  setFields(newFields);
706
706
  }}
707
707
  help={field.asMain && t('modelcreator.asMain.hint')}
@@ -752,7 +752,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
752
752
  checked={field.hiddenable}
753
753
  onChange={(e) => {
754
754
  const newFields = [...fields];
755
- newFields[index].hiddenable = e.target.checked;
755
+ newFields[index].hiddenable = e;
756
756
  setFields(newFields);
757
757
  }}
758
758
  help={field.unique && t('modelcreator.hiddenable.hint')}
@@ -768,7 +768,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
768
768
  checked={field.anonymized}
769
769
  onChange={(e) => {
770
770
  const newFields = [...fields];
771
- newFields[index].anonymized = e.target.checked;
771
+ newFields[index].anonymized = e;
772
772
  setFields(newFields);
773
773
  }}
774
774
  />
@@ -1,13 +1,16 @@
1
1
  import React, { useState } from 'react';
2
2
  import {useMutation, useQuery, useQueryClient} from 'react-query';
3
3
  import { Trans, useTranslation } from 'react-i18next';
4
- import { FaBoxOpen, FaChevronLeft, FaDownload, FaStar, FaTag, FaUser } from 'react-icons/fa';
4
+ import {FaBoxOpen, FaChevronLeft, FaDownload, FaPlus, FaStar, FaTag, FaUser} from 'react-icons/fa';
5
5
  import Button from './Button.jsx';
6
6
  import { useNotificationContext } from './NotificationProvider.jsx';
7
7
  import { useModelContext } from './contexts/ModelContext.jsx';
8
8
 
9
9
  import './PackGallery.scss';
10
10
  import {elementsPerPage} from "../../src/constants.js";
11
+ import Markdown from "react-markdown";
12
+ import {Dialog, DialogProvider} from "./Dialog.jsx";
13
+ import {TextField} from "./Field.jsx";
11
14
 
12
15
  // --- API Fetching Functions ---
13
16
  const fetchPacks = async (sortBy, lang) => {
@@ -117,7 +120,8 @@ const PackDetail = ({ packId, onBack }) => {
117
120
  </div>
118
121
  <div className="pack-content">
119
122
  <h2><Trans i18nKey="packs.description">Description</Trans></h2>
120
- <div className="description-content" dangerouslySetInnerHTML={{__html: pack.description || ''}}/>
123
+
124
+ <div className="description-content"><Markdown>{pack.description}</Markdown></div>
121
125
 
122
126
  <h2><Trans i18nKey="packs.modelsIncluded">Modèles inclus</Trans></h2>
123
127
  <p>{renderModelsList(pack.models)}</p>
@@ -127,6 +131,8 @@ const PackDetail = ({ packId, onBack }) => {
127
131
  <pre>{JSON.stringify(pack.data, null, 2)}</pre>
128
132
  </div>
129
133
  </div>
134
+
135
+
130
136
  </div>
131
137
  );
132
138
  };
@@ -151,11 +157,15 @@ const installPackMutationFn = async ({packId, lang}) => {
151
157
  const PackGallery = () => {
152
158
  const { t, i18n } = useTranslation();
153
159
  const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
160
+ const { addNotification } = useNotificationContext();
154
161
 
162
+ const [showManualInstallDialog, setShowManualInstallDialog] = useState(false);
163
+ const [manualPackJson, setManualPackJson] = useState('');
155
164
  const [view, setView] = useState('list'); // 'list' ou 'detail'
156
165
  const [selectedPackId, setSelectedPackId] = useState(null);
157
166
  const [sortBy, setSortBy] = useState({ field: '_updatedAt', order: -1 });
158
167
 
168
+ const queryClient = useQueryClient()
159
169
  const { data: packs, isLoading, isError } = useQuery(['packs', sortBy], () => fetchPacks(sortBy, lang));
160
170
 
161
171
  const handleSelectPack = (packId) => {
@@ -163,6 +173,40 @@ const PackGallery = () => {
163
173
  setView('detail');
164
174
  };
165
175
 
176
+ const handleManualInstall = async () => {
177
+ try {
178
+ const packData = JSON.parse(manualPackJson.trim());
179
+ addNotification({ status: 'info', title: t('packs.install.started', `Installation du pack personnalisé...`) });
180
+
181
+ const response = await fetch('/api/packs/install', {
182
+ method: 'POST',
183
+ headers: {
184
+ 'Content-Type': 'application/json',
185
+ },
186
+ body: JSON.stringify({ packData, lang }),
187
+ });
188
+
189
+ if (!response.ok) {
190
+ throw new Error(await response.text());
191
+ }
192
+
193
+ addNotification({
194
+ status: 'completed',
195
+ title: t('datatable.pack.install.success', 'Pack installé avec succès !')
196
+ });
197
+ setShowManualInstallDialog(false);
198
+ setManualPackJson('');
199
+ queryClient.invalidateQueries(['packs', sortBy]); // Rafraîchir la liste
200
+
201
+ } catch (error) {
202
+ addNotification({
203
+ status: 'error',
204
+ title: t('datatable.pack.install.error', `Échec de l'installation`),
205
+ message: error.message
206
+ });
207
+ }
208
+ };
209
+
166
210
  const handleBackToList = () => {
167
211
  setSelectedPackId(null);
168
212
  setView('list');
@@ -182,16 +226,52 @@ const PackGallery = () => {
182
226
  <>
183
227
  <div className="gallery-header">
184
228
  <h1><Trans i18nKey="packs.galleryTitle">Galerie de Packs</Trans></h1>
185
- <div className="sort-options">
186
- <span><Trans i18nKey="packs.sortBy">Trier par :</Trans></span>
187
- <Button onClick={() => setSortBy({ field: '_updatedAt', order: -1 })} className={sortBy.field === '_updatedAt' ? 'active' : ''}>
188
- <Trans i18nKey="packs.sort.lastUpdated">Derniers ajouts</Trans>
189
- </Button>
190
- <Button onClick={() => setSortBy({ field: 'stars', order: -1 })} className={sortBy.field === 'stars' ? 'active' : ''}>
191
- <Trans i18nKey="packs.sort.mostStarred">Plus populaires</Trans>
229
+ <div className="header-actions">
230
+ <div className="sort-options">
231
+ <span><Trans i18nKey="packs.sortBy">Trier par :</Trans></span>
232
+ <Button onClick={() => setSortBy({ field: '_updatedAt', order: -1 })} className={sortBy.field === '_updatedAt' ? 'active' : ''}>
233
+ <Trans i18nKey="packs.sort.lastUpdated">Derniers ajouts</Trans>
234
+ </Button>
235
+ <Button onClick={() => setSortBy({ field: 'stars', order: -1 })} className={sortBy.field === 'stars' ? 'active' : ''}>
236
+ <Trans i18nKey="packs.sort.mostStarred">Plus populaires</Trans>
237
+ </Button>
238
+ </div>
239
+ <Button
240
+ onClick={() => setShowManualInstallDialog(true)}
241
+ className="add-pack-button"
242
+ >
243
+ <FaPlus /> <Trans i18nKey="packs.manualInstall">Importer</Trans>
192
244
  </Button>
193
245
  </div>
194
246
  </div>
247
+ {showManualInstallDialog && (
248
+ <DialogProvider>
249
+ <Dialog
250
+ isModal={true}
251
+ onClose={() => setShowManualInstallDialog(false)}
252
+ title={t('packs.manualInstall.title', 'Installation manuelle de pack')}
253
+ >
254
+ <div className="manual-install-dialog">
255
+ <p>
256
+ <Trans i18nKey="packs.manualInstall.instructions">
257
+ Collez ici le JSON de configuration du pack que vous souhaitez installer.
258
+ </Trans>
259
+ </p>
260
+ <TextField
261
+ multiline={true}
262
+ value={manualPackJson}
263
+ onChange={(e) => setManualPackJson(e.target.value)}
264
+ placeholder={t('packs.manualInstall.placeholder', '{"name": "Mon Pack", "description": "...", "models": [...], "data": [...]}')}
265
+ rows={15}
266
+ />
267
+ <div className="flex actions right">
268
+ <Button onClick={() => setShowManualInstallDialog(false)}><Trans i18nKey={"btns.cancel"}>Annuler</Trans></Button>
269
+ <Button disabled={!manualPackJson.trim()} className="btn-primary" onClick={() => handleManualInstall()}><Trans i18nKey={"packs.install"}>Installer</Trans></Button>
270
+ </div>
271
+ </div>
272
+ </Dialog>
273
+ </DialogProvider>
274
+ )}
195
275
  <div className="pack-list">
196
276
  {packs.map(pack => (
197
277
  <PackCard key={pack._id} pack={pack} onSelect={handleSelectPack} />
@@ -7,20 +7,38 @@
7
7
 
8
8
  .gallery-header {
9
9
  display: flex;
10
+ flex-wrap: wrap;
11
+ gap: 1rem ;
10
12
  justify-content: space-between;
11
13
  align-items: center;
12
14
  padding-bottom: 1rem;
13
15
  border-bottom: 1px solid #e0e0e0;
14
16
  margin-bottom: 1rem;
17
+
15
18
  h1 {
16
19
  margin: 0;
20
+ flex: 1; // Permet au titre de prendre l'espace disponible
21
+ min-width: 200px; // Empêche le titre de devenir trop petit
17
22
  }
18
- .sort-options {
23
+
24
+ .header-actions {
19
25
  display: flex;
20
- gap: 0.5rem;
26
+ gap: 1rem;
21
27
  align-items: center;
22
- .btn.active {
23
- color: white;
28
+
29
+ .sort-options {
30
+ display: flex;
31
+ gap: 0.5rem;
32
+ align-items: center;
33
+ flex-wrap: wrap; // Pour le responsive
34
+
35
+ .btn.active {
36
+ color: white;
37
+ }
38
+ }
39
+
40
+ .add-pack-button {
41
+ white-space: nowrap; // Empêche le texte du bouton de se casser
24
42
  }
25
43
  }
26
44
  }
@@ -153,4 +171,40 @@
153
171
  }
154
172
  }
155
173
  }
174
+ }
175
+
176
+ // À ajouter à la fin de votre fichier CSS
177
+ .manual-install-dialog {
178
+ width: 100%;
179
+
180
+ p {
181
+ margin-top: 0;
182
+ color: #666;
183
+ font-size: 0.9em;
184
+ }
185
+
186
+ textarea {
187
+ width: 100%;
188
+ padding: 1rem;
189
+ border: 1px solid #ddd;
190
+ border-radius: 4px;
191
+ font-family: monospace;
192
+ resize: vertical;
193
+ min-height: 300px;
194
+ background-color: #f9f9f9;
195
+ color: #333;
196
+
197
+ &:focus {
198
+ outline: none;
199
+ border-color: var(--primary-color);
200
+ box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.2);
201
+ }
202
+ }
203
+
204
+ .dialog-actions {
205
+ display: flex;
206
+ justify-content: flex-end;
207
+ gap: 0.5rem;
208
+ margin-top: 1rem;
209
+ }
156
210
  }