@navette-js/panels 1.0.0-beta.1 → 1.0.0-beta.2

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/lib/index.js CHANGED
@@ -1,10 +1,19 @@
1
- // @navette-js/panels — port de filament/panels : Panel, mises en page, pages, navigation, connexion, session.
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // @navette-js/panels: Panel, layouts, pages, resources, navigation, authentication and session.
2
10
  import { AsyncLocalStorage } from 'node:async_hooks';
3
11
  import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
4
12
  import { checkVerificationUrl, consoleMailer, readToken, signToken, signVerificationUrl } from "./auth.js";
5
13
  export { consoleMailer, fileMailer, hashPassword, knexAuthProvider, knexTenantProvider, mailText, verifyPassword } from "./auth.js";
6
- import { readFileSync } from 'node:fs';
7
- import { fileURLToPath } from 'node:url';
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
17
  import express from 'express';
9
18
  import { Action, useColorManager, ActionGroup } from '@navette-js/actions';
10
19
  import { Checkbox, Field, Select, TextInput, Wizard, richDocumentToHtml, richHtmlToDocument, fillSchema, fillSchemaFromRecord, getSchemaState, validateSchema, useColorManager as useFormsColorManager } from '@navette-js/forms';
@@ -28,7 +37,6 @@ views.namespace('filament-panels', dir('../views'));
28
37
  views.namespace('filament', fileURLToPath(new URL('../views', import.meta.resolve('@navette-js/support'))));
29
38
  views.share({ t, tc });
30
39
  export const ASSET_VERSION = '5.11.2.0';
31
- // ------------------------------------------------------------------ navigation
32
40
  export class NavigationItem {
33
41
  label;
34
42
  _url = '#';
@@ -45,12 +53,11 @@ export class NavigationItem {
45
53
  group(group) { this._group = group; return this; }
46
54
  sort(sort) { this._sort = sort; return this; }
47
55
  badge(badge, color = 'primary') { this._badge = badge; this._badgeColor = color; return this; }
48
- /** Par défaut un élément est actif sur son URL exacte, comme une page Filament (les resources fourniront leur propre règle). */
56
+ /** By default an item is active on its exact URL; this overrides the rule. */
49
57
  isActiveWhen(fn) { this._isActive = fn; return this; }
50
58
  isActive(path) { return this._isActive ? this._isActive(path) : path === this._url; }
51
59
  }
52
60
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
53
- // ------------------------------------------------------------------ panel
54
61
  export class Panel {
55
62
  _id = 'admin';
56
63
  _path = '';
@@ -79,31 +86,31 @@ export class Panel {
79
86
  brandLogo(url) { this._brandLogo = url; return this; }
80
87
  darkModeBrandLogo(url) { this._darkModeBrandLogo = url; return this; }
81
88
  brandLogoHeight(height) { this._brandLogoHeight = height; return this; }
82
- /** Icône d'onglet du navigateur. */
89
+ /** Browser tab icon. */
83
90
  favicon(url) { this._favicon = url; return this; }
84
91
  getFavicon() { return this._favicon; }
85
92
  /**
86
- * Feuille de thème compilée par l'application (Tailwind) : elle remplace la feuille par défaut de Filament.
87
- * Équivalent de `->viteTheme()` : le thème importe `@navette-js/panels/theme.css` et déclare ses propres sources.
93
+ * Theme stylesheet compiled by the application (Tailwind); it replaces the default stylesheet.
94
+ * The theme imports `@navette-js/panels/theme.css` and declares its own sources.
88
95
  */
89
96
  theme(url) { this._theme = url; return this; }
90
97
  getTheme() { return this._theme; }
91
98
  /**
92
- * Mouvement et indicateurs de chargement propres à Navette (ressorts, pastille d'activité, roue des zones différées).
93
- * C'est un écart assumé avec Filament : `motion(false)` rend l'interface d'origine, sans rien d'ajouté.
99
+ * Navette's motion and loading indicators (springs, activity dot, spinner for deferred areas).
100
+ * `motion(false)` renders the plain interface without them.
94
101
  */
95
102
  motion(enabled = true) { this._motion = enabled; return this; }
96
103
  hasMotion() { return this._motion; }
97
- /** Thèmes proposés à l'utilisateur ; le premier est le thème par défaut. */
104
+ /** Themes offered to the user; the first one is the default. */
98
105
  themes(themes) {
99
106
  for (const theme of themes)
100
107
  if (!/^[a-z0-9-]{1,40}$/.test(theme.id))
101
- throw new Error(`Identifiant de thème invalide : ${theme.id}`);
108
+ throw new Error(`Invalid theme id: ${theme.id}`);
102
109
  this._themes = themes;
103
110
  return this;
104
111
  }
105
112
  getThemes() { return this._themes; }
106
- /** Thème de la requête : celui du cookie s'il est déclaré, sinon le premier. Null si le panel n'a pas de thèmes. */
113
+ /** Theme for the request: the cookie's theme if declared, otherwise the first one. `null` if the panel has no themes. */
107
114
  resolveTheme(req) {
108
115
  if (!this._themes.length)
109
116
  return null;
@@ -111,17 +118,17 @@ export class Panel {
111
118
  return this._themes.find((theme) => theme.id === id) ?? this._themes[0];
112
119
  }
113
120
  rememberTheme(res, theme) { res.cookie(`navette_${this._id}_theme`, theme.id, { sameSite: 'lax', secure: res.req?.secure === true, maxAge: 1000 * 60 * 60 * 24 * 365 }); }
114
- /** Exécute un rendu avec les couleurs du thème de la requête. */
121
+ /** Runs a render with the colors of the request's theme. */
115
122
  withTheme(req, fn) {
116
123
  const theme = this.resolveTheme(req);
117
- // Les composants des autres paquets lisent un gestionnaire de couleurs partagé par tous les panels :
118
- // les couleurs de ce panel (et de son thème) n'y valent que le temps de la requête.
124
+ // Components from other packages read a color manager shared by all panels:
125
+ // this panel's (and its theme's) colors only apply for the duration of the request.
119
126
  const withPanelColors = () => sharedColors.runWith(`${this._id}:${theme?.id ?? ''}`, this.colors.getColors(), fn);
120
- // Les render hooks du panel ne valent que pour ses propres requêtes.
127
+ // A panel's render hooks only apply to its own requests.
121
128
  return runWithRenderHooks(this._renderHooks, () => (theme?.colors ? this.colors.runWith(theme.id, theme.colors, withPanelColors) : withPanelColors()));
122
129
  }
123
130
  setColors(colors) { this.colors.register(colors); return this; }
124
- /** Police du panel. Sans URL : fournisseur Bunny Fonts, comme `->font('Albert Sans')` dans Filament. */
131
+ /** Panel font. Without a URL, it is loaded from Bunny Fonts. */
125
132
  font(family, url) {
126
133
  this._font = { family, url: url ?? `https://fonts.bunny.net/css?family=${slug(family)}:400,500,600,700&display=swap`, preconnect: url ? null : 'https://fonts.bunny.net' };
127
134
  return this;
@@ -130,7 +137,7 @@ export class Panel {
130
137
  auth(provider) { this._auth = provider; return this; }
131
138
  key(key) { this._key = key; return this; }
132
139
  pages(pages) { this._pages.push(...pages); return this; }
133
- /** Enregistre des resources : leurs pages et leur élément de navigation. */
140
+ /** Registers resources: their pages and navigation items. */
134
141
  resources(resources) {
135
142
  for (const resource of resources) {
136
143
  resource.panel = this;
@@ -140,13 +147,13 @@ export class Panel {
140
147
  return this;
141
148
  }
142
149
  navigationItems(items) { this._navigationItems.push(...items); return this; }
143
- /** Ordre des groupes de navigation. */
150
+ /** Order of navigation groups. */
144
151
  navigationGroups(groups) { this._navigationGroups = groups; return this; }
145
152
  _plugins = [];
146
- /** Applique un plugin : un objet qui configure le panel (resources, pages, render hooks, thèmes…). */
153
+ /** Applies a plugin: an object that configures the panel (resources, pages, render hooks, themes…). */
147
154
  plugin(plugin) {
148
155
  if (this._plugins.some((p) => p.id === plugin.id))
149
- throw new Error(`Plugin déjà enregistré : ${plugin.id}`);
156
+ throw new Error(`Plugin already registered: ${plugin.id}`);
150
157
  this._plugins.push(plugin);
151
158
  plugin.register(this);
152
159
  return this;
@@ -156,15 +163,15 @@ export class Panel {
156
163
  getPlugins() { return this._plugins; }
157
164
  _renderHooks = new RenderHookRegistry();
158
165
  /**
159
- * Insère du HTML à un emplacement nommé de l'interface de ce panel (noms : `PanelsRenderHook`, `TablesRenderHook`, `ActionsRenderHook`, `WidgetsRenderHook`).
160
- * `scopes` limite le hook à certaines pages ou resources (leur classe). Le HTML rendu est inséré tel quel : échappez toute donnée avec `e()`.
166
+ * Inserts HTML at a named location of this panel's UI (names: `PanelsRenderHook`, `TablesRenderHook`, `ActionsRenderHook`, `WidgetsRenderHook`).
167
+ * `scopes` limits the hook to some pages or resources (their class). The HTML is inserted as-is: escape any data with `e()`.
161
168
  */
162
169
  renderHook(name, render, scopes = null) { this._renderHooks.register(name, render, scopes); return this; }
163
170
  getRenderHooks() { return this._renderHooks; }
164
171
  _authorizer = null;
165
- /** Branche votre propre système de permissions : consulté avant toute politique, pour chaque capacité, page et widget. */
172
+ /** Plugs in your own permission system: consulted before any policy, for every ability, page and widget. */
166
173
  authorizeUsing(authorizer) { this._authorizer = authorizer; return this; }
167
- /** Décision d'autorisation : le crochet du panel d'abord, puis la règle fournie ; sans utilisateur, toujours non. */
174
+ /** Authorization decision: the panel hook first, then the given rule. Always `false` without a user. */
168
175
  async authorize(user, ability, subject = {}) {
169
176
  if (!user)
170
177
  return false;
@@ -173,7 +180,7 @@ export class Panel {
173
180
  return decided;
174
181
  return subject.rule ? Boolean(await subject.rule()) : true;
175
182
  }
176
- /** Une page est-elle accessible à cet utilisateur ? Liste : `viewAny` ; création : `create` ; édition : vérifiée sur l'enregistrement ; page libre : `canAccess`. */
183
+ /** Whether the user can open a page. List: `viewAny`; create: `create`; edit: checked against the record; custom page: `canAccess`. */
177
184
  async canAccessPage(PageClass, user) {
178
185
  const resource = PageClass.resource;
179
186
  if (resource && PageClass.prototype instanceof EditRecord)
@@ -185,37 +192,37 @@ export class Panel {
185
192
  return this.authorize(user, 'access', { page: PageClass, rule: () => PageClass.canAccess(user) });
186
193
  }
187
194
  _widgets = [];
188
- /** Widgets du panel (tableau de bord, pages libres) : enregistrés comme composants, à afficher par `getHeaderWidgets()` d'une page. */
195
+ /** Panel widgets (dashboard, custom pages): registered as components, displayed through a page's `getHeaderWidgets()`. */
189
196
  widgets(widgets) { this._widgets.push(...widgets); return this; }
190
197
  getWidgets() { return this._widgets; }
191
198
  maxContentWidth(width) { this._maxContentWidth = width; return this; }
192
199
  getId() { return this._id; }
193
200
  getBrandName() { return this._brandName; }
194
201
  getAuth() { if (!this._auth)
195
- throw new Error('Aucun fournisseur d\'authentification : panel.auth(...).'); return this._auth; }
202
+ throw new Error('No auth provider: call panel.auth(...).'); return this._auth; }
196
203
  getLoginPage() { return this._login; }
197
204
  getPages() { return this._pages; }
198
205
  getResources() { return this._resources; }
199
- // -------------------------------------------------------------- recherche globale
206
+ // -------------------------------------------------------------- global search
200
207
  _globalSearch = true;
201
208
  _globalSearchKeyBindings = [];
202
209
  _globalSearchDebounce = '500ms';
203
210
  _globalSearchSuffix = null;
204
- /** `false` retire le champ de recherche globale. Il n'apparaît de toute façon que si une resource est cherchable. */
211
+ /** `false` removes the global search field. It only appears when at least one resource is searchable. */
205
212
  globalSearch(condition = true) { this._globalSearch = condition; return this; }
206
- /** Raccourcis qui placent le curseur dans le champ : `['command+k', 'ctrl+k']`. */
213
+ /** Keyboard shortcuts that focus the field: `['command+k', 'ctrl+k']`. */
207
214
  globalSearchKeyBindings(keyBindings) { this._globalSearchKeyBindings = keyBindings.filter((binding) => /^[a-z0-9+]+$/.test(binding)); return this; }
208
215
  getGlobalSearchKeyBindings() { return this._globalSearchKeyBindings; }
209
- /** Délai entre la frappe et la recherche (`500ms` par défaut). */
216
+ /** Delay between typing and searching (`500ms` by default). */
210
217
  globalSearchDebounce(debounce) { if (/^[0-9]+m?s$/.test(debounce))
211
218
  this._globalSearchDebounce = debounce; return this; }
212
219
  getGlobalSearchDebounce() { return this._globalSearchDebounce; }
213
220
  globalSearchFieldSuffix(suffix) { this._globalSearchSuffix = suffix; return this; }
214
- /** Affiche le premier raccourci dans le champ, écrit selon la plateforme du navigateur (comme Filament : « META+K » hors Mac pour `command+k`). */
221
+ /** Shows the first shortcut in the field, written for the browser's platform (`command+k` shows as "META+K" outside macOS). */
215
222
  globalSearchFieldKeyBindingSuffix() { this._globalSearchSuffix = (userAgent) => keyBindingSuffix(this._globalSearchKeyBindings, userAgent); return this; }
216
223
  getGlobalSearchFieldSuffix(userAgent = '') { return typeof this._globalSearchSuffix === 'function' ? this._globalSearchSuffix(userAgent) : this._globalSearchSuffix; }
217
224
  hasGlobalSearch() { return this._globalSearch && this._resources.some((resource) => resource.canGloballySearch()); }
218
- /** Résultats par resource, dans l'ordre d'enregistrement ; seules les resources que l'utilisateur peut lister sont interrogées. */
225
+ /** Results per resource, in registration order. Only resources the user can list are queried. */
219
226
  async getGlobalSearchResults(search, user) {
220
227
  const categories = [];
221
228
  if (!this._globalSearch)
@@ -230,38 +237,38 @@ export class Panel {
230
237
  }
231
238
  return categories;
232
239
  }
233
- // -------------------------------------------------------------- notifications en base
240
+ // -------------------------------------------------------------- database notifications
234
241
  _databaseNotifications = null;
235
242
  _databaseNotificationsPolling = '30s';
236
- /** Active la cloche des notifications enregistrées en base, lues et écrites par ce stockage (`knexNotificationStore(knex)`). */
243
+ /** Enables the database notifications bell, read from and written to this store (`knexNotificationStore(knex)`). */
237
244
  databaseNotifications(store) { this._databaseNotifications = store; return this; }
238
245
  getDatabaseNotifications() { return this._databaseNotifications; }
239
- /** Intervalle de rafraîchissement (`30s` par défaut) ; `null` pour ne pas interroger le serveur. */
246
+ /** Polling interval (`30s` by default); `null` disables polling. */
240
247
  databaseNotificationsPolling(interval) { this._databaseNotificationsPolling = interval; return this; }
241
248
  getDatabaseNotificationsPollingInterval() { return this._databaseNotificationsPolling; }
242
- // -------------------------------------------------------------- mise en page : barre du haut, navigation, sidebar
249
+ // -------------------------------------------------------------- layout: topbar, navigation, sidebar
243
250
  _topbar = true;
244
251
  _topNavigation = false;
245
252
  _navigation = true;
246
253
  _sidebarCollapsibleOnDesktop = false;
247
254
  _sidebarFullyCollapsibleOnDesktop = false;
248
255
  _positions = { userMenu: null, globalSearch: null, databaseNotifications: null };
249
- /** Barre du haut (présente par défaut, comme dans Filament) : `topbar(false)` met logo, recherche, cloche et menu dans la sidebar. */
256
+ /** Topbar, shown by default. `topbar(false)` moves the logo, search, bell and user menu into the sidebar. */
250
257
  topbar(condition = true) { this._topbar = condition; return this; }
251
258
  hasTopbar() { return this._topbar; }
252
- /** Navigation dans la barre du haut (menus déroulants par groupe) au lieu de la sidebar. */
259
+ /** Navigation in the topbar (one dropdown per group) instead of the sidebar. */
253
260
  topNavigation(condition = true) { this._topNavigation = condition; return this; }
254
261
  hasTopNavigation() { return this._topNavigation; }
255
- /** `navigation(false)` retire toute navigation (ni sidebar ni menus). */
262
+ /** `navigation(false)` removes all navigation (no sidebar, no menus). */
256
263
  navigation(condition = true) { this._navigation = condition; return this; }
257
264
  hasNavigation() { return this._navigation; }
258
- /** Sidebar repliable sur grand écran en une colonne d'icônes. */
265
+ /** On desktop, the sidebar can collapse to a column of icons. */
259
266
  sidebarCollapsibleOnDesktop(condition = true) { this._sidebarCollapsibleOnDesktop = condition; return this; }
260
267
  isSidebarCollapsibleOnDesktop() { return this._sidebarCollapsibleOnDesktop; }
261
- /** Sidebar entièrement masquable sur grand écran. */
268
+ /** On desktop, the sidebar can be fully hidden. */
262
269
  sidebarFullyCollapsibleOnDesktop(condition = true) { this._sidebarFullyCollapsibleOnDesktop = condition; return this; }
263
270
  isSidebarFullyCollapsibleOnDesktop() { return this._sidebarFullyCollapsibleOnDesktop; }
264
- /** Emplacement du menu utilisateur ; par défaut, la barre du haut si elle existe. */
271
+ /** Where the user menu goes; defaults to the topbar when there is one. */
265
272
  userMenuPosition(position) { this._positions.userMenu = position; return this; }
266
273
  getUserMenuPosition() { return this._positions.userMenu ?? (this._topbar ? 'topbar' : 'sidebar'); }
267
274
  globalSearchPosition(position) { this._positions.globalSearch = position; return this; }
@@ -269,37 +276,37 @@ export class Panel {
269
276
  databaseNotificationsPosition(position) { this._positions.databaseNotifications = position; return this; }
270
277
  getDatabaseNotificationsPosition() { return this._positions.databaseNotifications ?? (this._topbar ? 'topbar' : 'sidebar'); }
271
278
  _userMenuItems = {};
272
- /** Éléments ajoutés au menu utilisateur (actions à adresse ou à modale). `sort()` négatif : au-dessus du choix du thème. Clés `profile` et `logout` : remplacent ceux par défaut. */
279
+ /** Items added to the user menu (URL or modal actions). A negative `sort()` places them above the theme switcher. Keys `profile` and `logout` replace the defaults. */
273
280
  userMenuItems(items) {
274
281
  for (const [key, item] of Array.isArray(items) ? items.map((item) => [item.getName(), item]) : Object.entries(items))
275
282
  this._userMenuItems[key] = item;
276
283
  return this;
277
284
  }
278
285
  getUserMenuItems() { return this._userMenuItems; }
279
- // -------------------------------------------------------------- inscription, mot de passe oublié, vérification, profil
286
+ // -------------------------------------------------------------- registration, password reset, email verification, profile
280
287
  _registration = null;
281
288
  _passwordReset = null;
282
289
  _emailVerification = null;
283
290
  _profile = null;
284
291
  _mailer = consoleMailer;
285
- /** Page d'inscription (`/register`). Le fournisseur d'utilisateurs doit savoir créer (`create`) et chercher par adresse (`findByEmail`). */
292
+ /** Registration page (`/register`). The auth provider must implement `create` and `findByEmail`. */
286
293
  registration(page = Register) { this._registration = page; return this; }
287
294
  hasRegistration() { return this._registration !== null; }
288
295
  getRegistrationPage() { return this._registration; }
289
- /** Mot de passe oublié : demande (`/password-reset/request`) et réinitialisation par un lien signé envoyé par e-mail. */
296
+ /** Password reset: request page (`/password-reset/request`), then reset through a signed link sent by email. */
290
297
  passwordReset(request = RequestPasswordReset, reset = ResetPassword) { this._passwordReset = { request, reset }; return this; }
291
298
  hasPasswordReset() { return this._passwordReset !== null; }
292
299
  getPasswordResetPages() { return this._passwordReset; }
293
- /** Vérification de l'adresse e-mail : un lien signé est envoyé à l'inscription ; tant qu'il n'est pas suivi, seule l'invitation à vérifier est accessible (`isRequired`). */
300
+ /** Email verification: a signed link is sent on registration. Until it is followed, only the verification prompt is accessible (`isRequired`). */
294
301
  emailVerification(prompt = EmailVerificationPrompt, isRequired = true) { this._emailVerification = { prompt, isRequired }; return this; }
295
302
  hasEmailVerification() { return this._emailVerification !== null; }
296
303
  isEmailVerificationRequired() { return this._emailVerification?.isRequired === true; }
297
304
  getEmailVerificationPrompt() { return this._emailVerification?.prompt ?? null; }
298
- /** Page de profil (`/profile`) : nom, adresse, mot de passe. `isSimple` : mise en page simple (par défaut) ou complète. */
305
+ /** Profile page (`/profile`): name, email, password. `isSimple`: simple layout (default) or full panel layout. */
299
306
  profile(page = EditProfile, isSimple = true) { this._profile = { page, isSimple }; return this; }
300
307
  hasProfile() { return this._profile !== null; }
301
308
  getProfile() { return this._profile; }
302
- /** Envoi des e-mails (réinitialisation, vérification). Par défaut : écrits dans la console. */
309
+ /** Mailer for password reset and verification emails. Defaults to the console. */
303
310
  mailer(mailer) { this._mailer = mailer; return this; }
304
311
  async sendMail(message) { await this._mailer.send(message); }
305
312
  getAuthKey() { return this._key; }
@@ -309,7 +316,7 @@ export class Panel {
309
316
  const token = signToken(this._key, `${this._id}.password-reset`, { uid: user.id, fp: fingerprint }, 60 * 60);
310
317
  return `${this.getBaseUrl('password-reset/reset')}?email=${encodeURIComponent(user.email)}&token=${encodeURIComponent(token)}`;
311
318
  }
312
- /** Charge d'un jeton de réinitialisation valide pour ce panel, sinon `null`. */
319
+ /** Payload of a password reset token valid for this panel, otherwise `null`. */
313
320
  readResetPasswordToken(token) { return readToken(this._key, `${this._id}.password-reset`, token); }
314
321
  getVerifyEmailUrl(user) {
315
322
  const expires = Math.floor(Date.now() / 1000) + 60 * 60;
@@ -319,14 +326,14 @@ export class Panel {
319
326
  checkVerifyEmailUrl(id, hash, expires, signature) { return checkVerificationUrl(`${this._key}.${this._id}`, id, hash, expires, signature); }
320
327
  getEmailVerificationPromptUrl() { return this.getBaseUrl('email-verification/prompt'); }
321
328
  getProfileUrl() { return this._profile ? this.getBaseUrl('profile') : null; }
322
- /** L'utilisateur doit-il encore vérifier son adresse avant d'utiliser le panel ? */
329
+ /** Whether the user must still verify their email before using the panel. */
323
330
  async mustVerifyEmail(user) {
324
331
  if (!user || !this.isEmailVerificationRequired())
325
332
  return false;
326
333
  const auth = this.getAuth();
327
334
  return auth.hasVerifiedEmail ? !(await auth.hasVerifiedEmail(user)) : false;
328
335
  }
329
- // -------------------------------------------------------------- locataires (multi-tenancy)
336
+ // -------------------------------------------------------------- multi-tenancy
330
337
  _tenant = null;
331
338
  _tenantOwnershipColumn = null;
332
339
  _tenantRegistration = null;
@@ -335,18 +342,18 @@ export class Panel {
335
342
  _tenantMenuItems = {};
336
343
  _tenantScope = new AsyncLocalStorage();
337
344
  /**
338
- * Rend le panel multi-locataire. `ownershipColumn` : colonne qui rattache un enregistrement à son locataire (`team_id`) ;
339
- * les resources du panel ne lisent et n'écrivent alors que les enregistrements du locataire courant (voir `Resource.isScopedToTenant`).
345
+ * Makes the panel multi-tenant. `ownershipColumn` is the column linking a record to its tenant (`team_id`);
346
+ * the panel's resources then only read and write the current tenant's records (see `Resource.isScopedToTenant`).
340
347
  */
341
348
  tenant(provider, options = {}) { this._tenant = provider; this._tenantOwnershipColumn = options.ownershipColumn ?? null; return this; }
342
349
  hasTenancy() { return this._tenant !== null; }
343
350
  getTenantProvider() { if (!this._tenant)
344
- throw new Error('Panel sans locataires : panel.tenant(...).'); return this._tenant; }
351
+ throw new Error('Panel has no tenancy: call panel.tenant(...).'); return this._tenant; }
345
352
  getTenantOwnershipColumn() { return this._tenantOwnershipColumn; }
346
- /** Page de création d'un locataire (`/new`). */
353
+ /** Tenant registration page (`/new`). */
347
354
  tenantRegistration(page) { this._tenantRegistration = page; return this; }
348
355
  getTenantRegistrationPage() { return this._tenantRegistration; }
349
- /** Page de profil du locataire (`/<locataire>/profile`). */
356
+ /** Tenant profile page (`/<tenant>/profile`). */
350
357
  tenantProfile(page) { this._tenantProfile = page; return this; }
351
358
  getTenantProfilePage() { return this._tenantProfile; }
352
359
  tenantMenu(condition = true) { this._tenantMenu = condition; return this; }
@@ -357,41 +364,41 @@ export class Panel {
357
364
  return this;
358
365
  }
359
366
  getTenantMenuItems() { return this._tenantMenuItems; }
360
- /** Exécute `fn` pour un locataire : les adresses produites (`getUrl`) et les requêtes des resources lui sont rattachées. */
367
+ /** Runs `fn` for a tenant: generated URLs (`getUrl`) and resource queries are scoped to it. */
361
368
  withTenant(tenant, fn) { return this._tenantScope.run({ tenant }, fn); }
362
- /** Locataire de la requête en cours (équivalent de `Filament::getTenant()`), ou `null`. */
369
+ /** Tenant of the current request, or `null`. */
363
370
  getTenant() { return (this._tenantScope.getStore()?.tenant ?? null); }
364
371
  getTenantUrlKey(tenant) { return this.getTenantProvider().getKey(tenant); }
365
372
  _subNavigationPosition = 'start';
366
- /** Position par défaut de la sous-navigation des pages (`start`, comme Filament). */
373
+ /** Default position of page sub-navigation (`start`). */
367
374
  subNavigationPosition(position) { this._subNavigationPosition = position; return this; }
368
375
  getSubNavigationPosition() { return this._subNavigationPosition; }
369
376
  _appUrl = null;
370
- /** Adresse publique de l'application (`https://exemple.fr`), utilisée dans les liens envoyés par e-mail. Sans elle, l'origine de la requête est reprise : à éviter en production (en-tête `Host` falsifiable). */
377
+ /** Public URL of the application (`https://example.com`), used in emailed links. Without it the request origin is used, which should be avoided in production (the `Host` header can be forged). */
371
378
  appUrl(url) { this._appUrl = url ? url.replace(/\/$/, '') : null; return this; }
372
379
  getAppUrl() { return this._appUrl; }
373
380
  getMaxContentWidth() { return this._maxContentWidth; }
374
- /** Adresse dans le panel ; dans un panel à locataires, préfixée par le locataire courant (ou celui donné). */
381
+ /** URL inside the panel; in a multi-tenant panel, prefixed with the current (or given) tenant. */
375
382
  getUrl(sub = '', tenant = this.getTenant()) {
376
383
  if (this._tenant && tenant)
377
384
  return '/' + [this._path, encodeURIComponent(this.getTenantUrlKey(tenant)), sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/');
378
385
  return this.getBaseUrl(sub);
379
386
  }
380
- /** Adresse hors locataire (connexion, inscription, profil, création d'un locataire). */
387
+ /** URL outside any tenant (login, registration, profile, tenant registration). */
381
388
  getBaseUrl(sub = '') { return '/' + [this._path, sub.replace(/^\/|\/$/g, '')].filter(Boolean).join('/'); }
382
389
  getLoginUrl() { return this.getBaseUrl('login'); }
383
390
  getLogoutUrl() { return this.getBaseUrl('logout'); }
384
- /** Préfixe de l'endpoint Livewire de ce panel (`/livewire`, ou `/<chemin>/livewire`) : chaque panel a le sien. */
391
+ /** Prefix of this panel's Livewire endpoint (`/livewire` or `/<path>/livewire`): each panel has its own. */
385
392
  getLivewirePrefix() { return this._path ? `/${this._path}/livewire` : '/livewire'; }
386
393
  assetUrl(file) { return `/${file}?v=${ASSET_VERSION}`; }
387
394
  getBrand() { return { name: this._brandName, logo: this._brandLogo, darkLogo: this._darkModeBrandLogo, height: this._brandLogoHeight }; }
388
395
  getFont() { return this._font; }
389
- /** Avatar par défaut : port de UiAvatarsProvider (service externe ui-avatars.com, initiales du nom). */
396
+ /** Default avatar: the name's initials from the external ui-avatars.com service. */
390
397
  getUserAvatarUrl(user) { return this.getAvatarUrlFor(user.name); }
391
- /** Avatar d'un locataire : celui de son fournisseur (`getAvatarUrl`), sinon ses initiales. */
398
+ /** Tenant avatar: from the tenant provider (`getAvatarUrl`), otherwise its initials. */
392
399
  getTenantAvatarUrl(tenant) { return this.getTenantProvider().getAvatarUrl?.(tenant) ?? this.getAvatarUrlFor(this.getTenantProvider().getName(tenant)); }
393
400
  _spa = false;
394
- /** Navigation sans rechargement (`wire:navigate`) entre les pages du panel, comme `->spa()`. */
401
+ /** SPA mode: navigate between panel pages without full reloads (`wire:navigate`). */
395
402
  spa(condition = true) { this._spa = condition; return this; }
396
403
  hasSpaMode() { return this._spa; }
397
404
  getAvatarUrlFor(name) {
@@ -399,8 +406,8 @@ export class Panel {
399
406
  const background = '#' + convertToRgb(this.colors.getColor('gray')[950]).map((n) => n.toString(16).padStart(2, '0')).join('');
400
407
  return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials).replace(/%20/g, '+')}&format=svg&color=FFFFFF&background=${encodeURIComponent(background)}`;
401
408
  }
402
- /** Groupes de navigation : pages enregistrées + éléments libres, triés, groupés dans l'ordre déclaré. */
403
- /** Compteurs de navigation calculés (équivalent de getNavigationBadge()) : adresse de la page → valeur. */
409
+ /** Navigation groups: registered pages plus custom items, sorted, grouped in the declared order. */
410
+ /** Computed navigation badges: page URL → value. */
404
411
  async resolveNavigationBadges() {
405
412
  const badges = new Map();
406
413
  for (const page of this._pages)
@@ -408,7 +415,7 @@ export class Panel {
408
415
  badges.set(page.slug, await page.getNavigationBadge());
409
416
  return badges;
410
417
  }
411
- /** Adresses des pages du menu que cet utilisateur ne peut pas ouvrir. */
418
+ /** URLs of menu pages this user cannot open. */
412
419
  async resolveInaccessiblePages(user) {
413
420
  const hidden = new Set();
414
421
  for (const page of this._pages)
@@ -431,7 +438,7 @@ export class Panel {
431
438
  return { label, itemsId: label ? `fi-sidebar-group-items-${slug(label)}-${createHash('md5').update(label).digest('hex').slice(0, 8)}` : '', isActive: own.some((i) => i.isActive), items: own };
432
439
  }).filter((g) => g.items.length);
433
440
  }
434
- // --- session : cookie signé (identifiant d'utilisateur), suffisant pour le jalon 2
441
+ // --- session: an HMAC-signed cookie holding the user id
435
442
  sign(value) { return createHmac('sha256', this._key).update(value).digest('base64url'); }
436
443
  logIn(res, user, remember) {
437
444
  const value = Buffer.from(JSON.stringify({ uid: user.id })).toString('base64url');
@@ -454,11 +461,8 @@ export class Panel {
454
461
  }
455
462
  }
456
463
  }
457
- // ------------------------------------------------------------------ render hooks
458
464
  /**
459
- * Emplacements de render hook d'un panel — mêmes noms que `Filament\View\PanelsRenderHook`.
460
- * Ceux d'une brique pas encore portée (barre du haut, recherche globale, menu de locataire, sous-navigation, inscription,
461
- * réinitialisation du mot de passe, enregistrements liés) sont déclarés mais ne sont rendus nulle part pour l'instant : voir `UNPLACED_RENDER_HOOKS`.
465
+ * Render hook locations of a panel. A few are declared but not rendered anywhere yet: see `UNPLACED_RENDER_HOOKS`.
462
466
  */
463
467
  export const PanelsRenderHook = {
464
468
  AUTH_LOGIN_FORM_AFTER: 'panels::auth.login.form.after',
@@ -498,7 +502,7 @@ export const PanelsRenderHook = {
498
502
  PAGE_HEADER_WIDGETS_END: 'panels::page.header-widgets.end',
499
503
  PAGE_HEADER_WIDGETS_START: 'panels::page.header-widgets.start',
500
504
  PAGE_START: 'panels::page.start',
501
- /** Le nom contient « module-menu » dans Filament : conservé tel quel. */
505
+ /** The hook name says "module-menu", not "mobile-menu": kept for compatibility. */
502
506
  PAGE_SUB_NAVIGATION_MOBILE_MENU_AFTER: 'panels::page.sub-navigation.module-menu.after',
503
507
  PAGE_SUB_NAVIGATION_MOBILE_MENU_BEFORE: 'panels::page.sub-navigation.module-menu.before',
504
508
  PAGE_SUB_NAVIGATION_SIDEBAR_AFTER: 'panels::page.sub-navigation.sidebar.after',
@@ -546,23 +550,21 @@ export const PanelsRenderHook = {
546
550
  USER_MENU_PROFILE_AFTER: 'panels::user-menu.profile.after',
547
551
  USER_MENU_PROFILE_BEFORE: 'panels::user-menu.profile.before',
548
552
  };
549
- /** Emplacements de render hook des widgets — mêmes noms que `Filament\Widgets\View\WidgetsRenderHook`. Rendus par `TableWidget`. */
553
+ /** Widget render hook locations, rendered by `TableWidget`. */
550
554
  export const WidgetsRenderHook = {
551
555
  TABLE_WIDGET_END: 'widgets::table-widget.end',
552
556
  TABLE_WIDGET_START: 'widgets::table-widget.start',
553
557
  };
554
558
  /**
555
- * Emplacements déclarés mais rendus nulle part pour l'instant, avec la brique qui leur manque.
556
- * Les quatre « tabs » ne sont rendus par aucune vue de Filament 5.11.2 non plus.
559
+ * Hook locations that are declared but not rendered anywhere yet, with the reason.
557
560
  */
558
561
  export const UNPLACED_RENDER_HOOKS = {
559
- ...Object.fromEntries(['RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER', 'RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE'].map((k) => [PanelsRenderHook[k], 'page ManageRelatedRecords'])),
560
- ...Object.fromEntries(['RESOURCE_PAGES_LIST_RECORDS_TABS_END', 'RESOURCE_PAGES_LIST_RECORDS_TABS_START', 'RESOURCE_TABS_END', 'RESOURCE_TABS_START'].map((k) => [PanelsRenderHook[k], 'non rendu par Filament 5.11.2'])),
562
+ ...Object.fromEntries(['RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_AFTER', 'RESOURCE_PAGES_MANAGE_RELATED_RECORDS_TABLE_BEFORE'].map((k) => [PanelsRenderHook[k], 'ManageRelatedRecords page not implemented'])),
563
+ ...Object.fromEntries(['RESOURCE_PAGES_LIST_RECORDS_TABS_END', 'RESOURCE_PAGES_LIST_RECORDS_TABS_START', 'RESOURCE_TABS_END', 'RESOURCE_TABS_START'].map((k) => [PanelsRenderHook[k], 'reserved, no view renders it'])),
561
564
  };
562
- // ------------------------------------------------------------------ pages
563
- /** Portée Alpine d'un composant de schéma sans clé (cellule de grille anonyme). */
565
+ /** Alpine scope of a schema component without a key (anonymous grid cell). */
564
566
  const ANONYMOUS_SCHEMA_COMPONENT = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
565
- /** Base commune : composant à état hébergeant des schémas (`<nom>Schema(schema)`). */
567
+ /** Common base: a stateful component hosting schemas (`<name>Schema(schema)`). */
566
568
  class BasePage extends Component {
567
569
  get panel() { return this._ctx.panel; }
568
570
  getSchema(name) {
@@ -572,15 +574,15 @@ class BasePage extends Component {
572
574
  return this[`${name}Schema`](schema);
573
575
  }
574
576
  contentSchema(schema) { return schema.components([]); }
575
- /** Portées des render hooks de cette page : sa classe (et sa resource pour une page de resource), comme `getRenderHookScopes()` dans Filament. */
577
+ /** Render hook scopes of this page: its class (and its resource, for a resource page). */
576
578
  getRenderHookScopes() {
577
579
  const resource = this.constructor.resource;
578
580
  return resource ? [this.constructor, resource] : [this.constructor];
579
581
  }
580
582
  }
581
- /** Page d'un panel, dans la mise en page complète (sidebar, en-tête). */
583
+ /** A panel page, in the full layout (sidebar, header). */
582
584
  export class Page extends BasePage {
583
- /** Méthodes que le navigateur peut appeler. Une sous-classe ajoute les siennes : `static callable = ['approve']`. */
585
+ /** Methods the browser may call. A subclass adds its own: `static callable = ['approve']`. */
584
586
  static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod'];
585
587
  static slug = '';
586
588
  static title = null;
@@ -591,23 +593,23 @@ export class Page extends BasePage {
591
593
  static navigationBadge = null;
592
594
  static navigationBadgeColor = 'primary';
593
595
  static shouldRegisterNavigation = true;
594
- /** Page réservée à certains utilisateurs : rendre `false` la retire du menu et la refuse (403). */
596
+ /** Restricts the page to some users: returning `false` hides it from the menu and denies it (403). */
595
597
  static canAccess(_user) { return true; }
596
598
  static getTitle() { return this.title ?? this.name.replace(/([a-z])([A-Z])/g, '$1 $2'); }
597
599
  static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
598
- /** Chaque requête, y compris les mises à jour envoyées par le navigateur, exige un utilisateur authentifié : un snapshot conservé après déconnexion ne suffit pas. */
600
+ /** Every request, including browser updates, requires an authenticated user: a snapshot kept after logout is not enough. */
599
601
  async boot() {
600
602
  const ctx = this._ctx;
601
603
  if (!ctx?.user)
602
- throw new WireError(401, 'Non authentifié.');
603
- // Dans un panel à locataires, toute page vit chez un locataire dont l'utilisateur est membre (vérifié par le routeur).
604
+ throw new WireError(401, 'Unauthenticated.');
605
+ // In a multi-tenant panel, every page belongs to a tenant the user is a member of (checked by the router).
604
606
  if (ctx.panel.hasTenancy() && !ctx.tenant)
605
- throw new WireError(404, 'Locataire introuvable.');
607
+ throw new WireError(404, 'Tenant not found.');
606
608
  if (await ctx.panel.mustVerifyEmail(ctx.user))
607
- throw new WireError(403, 'Adresse e-mail à vérifier.');
608
- // Vérifié à chaque requête, pas seulement à l'affichage : une requête forgée vers une page interdite est refusée.
609
+ throw new WireError(403, 'Email address not verified.');
610
+ // Checked on every request, not only on display: a forged request to a forbidden page is denied.
609
611
  if (!(await this.isAccessAuthorized(ctx)))
610
- throw new WireError(403, 'Accès refusé.');
612
+ throw new WireError(403, 'Forbidden.');
611
613
  }
612
614
  isAccessAuthorized(ctx) { return ctx.panel.canAccessPage(this.constructor, ctx.user); }
613
615
  getTitle() { return this.constructor.getTitle(); }
@@ -615,19 +617,19 @@ export class Page extends BasePage {
615
617
  getBreadcrumbs() { return []; }
616
618
  getHeaderActions() { return []; }
617
619
  getPageClasses() { return []; }
618
- /** Sous-navigation de la page : vide par défaut. */
620
+ /** Page sub-navigation; empty by default. */
619
621
  getSubNavigation() { return []; }
620
- /** Position de la sous-navigation : `start` (colonne avant le contenu), `end` (après) ou `top` (onglets). Par défaut : celle de la resource, sinon du panel. */
622
+ /** Sub-navigation position: `start` (column before the content), `end` (after) or `top` (tabs). Defaults to the resource's, then the panel's. */
621
623
  static subNavigationPosition = null;
622
624
  getSubNavigationPosition() {
623
625
  const own = this.constructor.subNavigationPosition;
624
- const resource = this.constructor.resource ?? this.constructor.related?.parentResource;
626
+ const resource = this.constructor.resource ?? this.constructor.parentResource;
625
627
  return own ?? resource?.subNavigationPosition ?? this.panel.getSubNavigationPosition();
626
628
  }
627
- /** Widgets affichés sous le contenu de la page (port de getFooterWidgets). */
629
+ /** Widgets shown below the page content. */
628
630
  getFooterWidgets() { return []; }
629
631
  getFooterWidgetsColumns() { return 2; }
630
- /** Port de filament-widgets::components.widgets, dans le schéma `footerWidgets` de la page. */
632
+ /** Footer widget grid, in the page's `footerWidgets` schema. */
631
633
  async renderFooterWidgets() {
632
634
  const widgets = await this.filterVisibleWidgets(this.getFooterWidgets());
633
635
  if (!widgets.length)
@@ -635,11 +637,11 @@ export class Page extends BasePage {
635
637
  const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
636
638
  return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'footerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getFooterWidgetsColumns(), 'footerWidgets')}${liberate(PanelsRenderHook.PAGE_FOOTER_WIDGETS_END)}</div>`;
637
639
  }
638
- /** Widgets affichés au-dessus du contenu de la page, et données passées à chacun (propriétés du widget). */
640
+ /** Widgets shown above the page content, with the data passed to each (widget properties). */
639
641
  getHeaderWidgets() { return []; }
640
642
  getHeaderWidgetsColumns() { return 2; }
641
643
  getWidgetData() { return {}; }
642
- /** Port de filament-widgets::components.widgets, dans le schéma `headerWidgets` de la page. */
644
+ /** Header widget grid, in the page's `headerWidgets` schema. */
643
645
  async renderHeaderWidgets() {
644
646
  const widgets = await this.filterVisibleWidgets(this.getHeaderWidgets());
645
647
  if (!widgets.length)
@@ -647,7 +649,7 @@ export class Page extends BasePage {
647
649
  const liberate = (hook) => `<div class="fi-sc-liberated" wire:key="" ${ANONYMOUS_SCHEMA_COMPONENT}>${renderHook(hook, this.getRenderHookScopes())}</div>`;
648
650
  return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'headerWidgets' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_START)}${await this.renderWidgetsGrid(widgets, this.getHeaderWidgetsColumns(), 'headerWidgets')}${liberate(PanelsRenderHook.PAGE_HEADER_WIDGETS_END)}</div>`;
649
651
  }
650
- /** Widgets que l'utilisateur courant a le droit de voir (port de filterVisibleWidgets). */
652
+ /** Widgets the current user is allowed to see. */
651
653
  async filterVisibleWidgets(candidates) {
652
654
  const viewer = this._ctx.user;
653
655
  const widgets = [];
@@ -656,50 +658,51 @@ export class Page extends BasePage {
656
658
  widgets.push(widget);
657
659
  return widgets;
658
660
  }
659
- /** Grille de widgets d'un schéma (port de Grid + getWidgetsSchemaComponents) : chaque widget est un composant enfant, « lazy » par défaut. */
661
+ /** Widget grid of a schema: each widget is a child component, lazy-loaded by default. */
660
662
  async renderWidgetsGrid(widgets, columns, schemaKey) {
661
663
  const items = [];
662
664
  for (const [index, widget] of widgets.entries()) {
663
- const key = `${schemaKey}.${widget.componentName}-${index}`;
664
- const child = await this.mountChild(widget.componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
665
+ const componentName = this._runtime.nameOf(widget);
666
+ const key = `${schemaKey}.${componentName}-${index}`;
667
+ const child = await this.mountChild(componentName, this.getWidgetData(), `${this.id}.${key}`, { lazy: widget.isLazy });
665
668
  items.push(`<div class="fi-sc-liberated" wire:key="${e(`${this.id}.${key}`)}" x-data="filamentSchemaComponent(${e(js({ key, exposedMethods: [], path: '', containerPath: '' }))})">${child}</div>`);
666
669
  }
667
670
  const grid = new Attributes().grid(typeof columns === 'number' ? { lg: columns } : columns).class(['fi-sc', 'fi-sc-has-gap']);
668
671
  return `<div ${ANONYMOUS_SCHEMA_COMPONENT} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div><div ${grid.toHtml()}>${items.join('')}</div></div></div></div>`;
669
672
  }
670
- /** Contenu de la page : par défaut son schéma `content`. */
673
+ /** Page content: its `content` schema by default. */
671
674
  async renderContent() { return (await this.getSchema('content')).toHtml(); }
672
- // --- actions montées (port de InteractsWithActions) : la pile vit dans l'état du composant, les modales sont rendues côté serveur
675
+ // --- mounted actions: the stack lives in the component state, modals are rendered server-side
673
676
  mountedActions = [];
674
677
  getActionMountContext() { return {}; }
675
678
  getActionRecord() { return null; }
676
- /** Enregistrement visé par une action montée dans un contexte donné (ligne de table…). */
679
+ /** Record targeted by an action mounted in a given context (table row…). */
677
680
  async resolveActionRecord(_context) { return this.getActionRecord(); }
678
- /** Actions pouvant être montées dans un contexte donné (en-tête de page par défaut). */
681
+ /** Actions that can be mounted in a given context (page header by default). */
679
682
  getMountableActions(context) {
680
683
  return typeof context.schemaComponent === 'string' ? this.getSchemaComponentActions(context.schemaComponent) : this.getHeaderActions();
681
684
  }
682
685
  /**
683
- * Actions portées par un composant de schéma (Repeater : ajouter, supprimer, déplacer ; Section : actions d'en-tête).
684
- * La clé vient du navigateur : elle doit désigner un composant réel du schéma, ou le schéma lui-même.
686
+ * Actions owned by a schema component (Repeater: add, delete, move; Section: header actions).
687
+ * The key comes from the browser: it must match a real component of the schema, or the schema itself.
685
688
  */
686
689
  async getSchemaComponentActions(key) {
687
690
  const name = key.split('.')[0];
688
- // Le composant peut appartenir au formulaire d'une action déjà montée (éditeur riche dans une modale).
691
+ // The component may belong to the form of an already mounted action (rich editor in a modal).
689
692
  const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
690
693
  if (!mountedIndex && (!/^[A-Za-z]+$/.test(name) || typeof this[`${name}Schema`] !== 'function'))
691
694
  return [];
692
695
  const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : this.getSchema(name);
693
696
  if (!schema)
694
697
  return [];
695
- // Seuls les composants visibles exposent leurs actions : un composant masqué (section, callout…) n'en monte aucune.
698
+ // Only visible components expose their actions: a hidden component (section, callout…) mounts none.
696
699
  const components = (await schema.getVisibleFlatComponents());
697
700
  const exact = components.find((c) => c.getKey() === key && typeof c.getActions === 'function');
698
701
  if (exact)
699
702
  return exact.getActions();
700
703
  return key === schema.getKey() ? components.filter((c) => !c.hasStatePath() && typeof c.getActions === 'function').flatMap((c) => c.getActions()) : [];
701
704
  }
702
- /** Contexte retenu pour une action montée : celui du serveur, jamais celui du navigateur tel quel. */
705
+ /** Context kept for a mounted action: the server's, never the browser's as-is. */
703
706
  resolveMountContext(clientContext) {
704
707
  const component = clientContext?.schemaComponent;
705
708
  return typeof component === 'string' && component.length <= 200 ? { ...this.getActionMountContext(), schemaComponent: component } : this.getActionMountContext();
@@ -707,11 +710,11 @@ export class Page extends BasePage {
707
710
  async findMountableAction(name, context = this.getActionMountContext()) {
708
711
  const action = (await this.getMountableActions(context)).find((a) => a.getName() === name && a.isMountable());
709
712
  if (!action)
710
- throw new WireError(404, `Action introuvable : ${String(name)}`);
713
+ throw new WireError(404, `Action not found: ${String(name)}`);
711
714
  return action;
712
715
  }
713
716
  async actionContext(mounted) { return { livewire: this, record: await this.resolveActionRecord(mounted.context), arguments: mounted.arguments, data: emptyToNull(mounted.data ?? {}) }; }
714
- /** Schéma du formulaire d'une action montée : clé `mountedActionSchema<n>`, état sous `mountedActions.<n>.data` (comme Filament). */
717
+ /** Form schema of a mounted action: key `mountedActionSchema<n>`, state under `mountedActions.<n>.data`. */
715
718
  async getMountedActionSchema(index) {
716
719
  const mounted = this.mountedActions[index];
717
720
  if (!mounted)
@@ -723,7 +726,7 @@ export class Page extends BasePage {
723
726
  const mounted = this.mountedActions[index];
724
727
  return (await this.findMountableAction(mounted.name, mounted.context)).toModalHtml(await this.actionContext(mounted), index, (await (await this.getMountedActionSchema(index))?.toHtml()) ?? null);
725
728
  }
726
- /** Annule un rendu partiel de modale préparé par un champ réactif, quand la même requête exécute ensuite une action. */
729
+ /** Cancels a partial modal render prepared by a live field when the same request then runs an action. */
727
730
  cancelModalPartialRender() {
728
731
  for (const name of Object.keys(this._effects.partials ?? {}))
729
732
  if (name.startsWith('action-modals.')) {
@@ -731,7 +734,7 @@ export class Page extends BasePage {
731
734
  this._skipRender = false;
732
735
  }
733
736
  }
734
- /** Champ réactif du formulaire d'une action montée : exécute `afterStateUpdated`, puis ne renvoie que la modale concernée (comme Filament). */
737
+ /** Live field in a mounted action's form: runs `afterStateUpdated`, then only returns the affected modal. */
735
738
  async updatedMountedActions(_value, key) {
736
739
  const m = /^(\d+)\.data\.(.+)$/.exec(key ?? '');
737
740
  if (!m)
@@ -745,7 +748,7 @@ export class Page extends BasePage {
745
748
  await field.callAfterStateUpdated((name, value) => { data[name] = value; });
746
749
  this.renderPartial(`action-modals.${index}`, await this.renderActionModal(index));
747
750
  }
748
- /** Appel, depuis le JavaScript d'un champ, d'une méthode que ce champ expose (ex. recherche d'un Select). Sans rendu. */
751
+ /** Calls a method exposed by a field, from that field's JavaScript (e.g. Select search). Does not re-render. */
749
752
  async callSchemaComponentMethod(componentKey, method, args = []) {
750
753
  if (typeof componentKey !== 'string' || typeof method !== 'string') {
751
754
  this.skipRender();
@@ -754,34 +757,34 @@ export class Page extends BasePage {
754
757
  const name = componentKey.split('.')[0];
755
758
  const mountedIndex = /^mountedActionSchema(\d+)$/.exec(name);
756
759
  const schema = mountedIndex ? await this.getMountedActionSchema(Number(mountedIndex[1])) : typeof this[`${name}Schema`] === 'function' ? await this.getSchema(name) : null;
757
- // Un composant masqué n'expose aucune méthode, même si le navigateur en forge l'appel.
760
+ // A hidden component exposes no method, even if the browser forges the call.
758
761
  const component = schema ? (await schema.getVisibleFlatComponents()).find((c) => c.getKey() === componentKey) : undefined;
759
- // Seules les méthodes que le composant déclare exposées sont appelables depuis le navigateur.
762
+ // Only methods the component declares as exposed can be called from the browser.
760
763
  if (!component || !component.getExposedMethods().includes(method) || typeof component[method] !== 'function') {
761
764
  this.skipRender();
762
765
  return null;
763
766
  }
764
767
  if (component.isRenderlessMethod(method))
765
768
  this.skipRender();
766
- // Arguments nommés (objet), comme les envoie le JavaScript de Filament : passés dans l'ordre de leurs clés.
769
+ // Named arguments (an object), as sent by the front-end JavaScript: passed in key order.
767
770
  return component[method](...(Array.isArray(args) ? args : args && typeof args === 'object' ? Object.values(args) : []));
768
771
  }
769
- /** Une action masquée ne peut être ni montée ni exécutée, même par un appel forgé depuis le navigateur. */
772
+ /** A hidden action can be neither mounted nor run, even through a call forged by the browser. */
770
773
  async assertActionVisible(action, context = {}) {
771
774
  const record = await this.resolveActionRecord(context);
772
775
  if (context.recordKey !== undefined && record === null)
773
- throw new WireError(404, 'Enregistrement introuvable');
776
+ throw new WireError(404, 'Record not found.');
774
777
  const ctx = { livewire: this, component: null, operation: null, record, get: () => undefined, state: undefined };
775
778
  if (!(await action.isVisible(ctx)) || (await action.isDisabled(ctx)))
776
- throw new WireError(403, `Action indisponible : ${action.getName()}`);
777
- // Une action qui mène à une adresse pour cet enregistrement est un lien : elle ne se monte pas.
779
+ throw new WireError(403, `Action unavailable: ${action.getName()}`);
780
+ // An action with a URL for this record is a link: it cannot be mounted.
778
781
  if (action.resolveUrl(ctx) !== null)
779
- throw new WireError(404, `Action introuvable : ${action.getName()}`);
782
+ throw new WireError(404, `Action not found: ${action.getName()}`);
780
783
  }
781
784
  syncActionModals() {
782
785
  this.dispatch('sync-action-modals', { id: this.id, newActionNestingIndex: this.mountedActions.length ? this.mountedActions.length - 1 : null, shouldOverlayParentActions: false });
783
786
  }
784
- /** Faux pour un composant à table : ses modales sont rendues dans la table (comme Filament). */
787
+ /** False for a table component: its modals are rendered inside the table. */
785
788
  rendersActionModalsInPage() { return true; }
786
789
  async renderActionModals() {
787
790
  const modals = [];
@@ -791,7 +794,7 @@ export class Page extends BasePage {
791
794
  livewireId: ${js(this.id)},
792
795
  })" style="height: 0">${modals.join('')}</div>`;
793
796
  }
794
- /** Monte une action : sans modale elle s'exécute tout de suite ; avec modale, seul le fragment des modales est renvoyé. */
797
+ /** Mounts an action: without a modal it runs immediately; with one, only the modals fragment is returned. */
795
798
  async mountAction(name, args = {}, _context = {}) {
796
799
  this.cancelModalPartialRender();
797
800
  const context = this.resolveMountContext(_context);
@@ -805,7 +808,7 @@ export class Page extends BasePage {
805
808
  this.mountedActions.push(mounted);
806
809
  const schema = await this.getMountedActionSchema(this.mountedActions.length - 1);
807
810
  if (schema) {
808
- // État initial : valeurs par défaut des champs, puis `fillForm()`.
811
+ // Initial state: field defaults, then `fillForm()`.
809
812
  const data = {};
810
813
  mounted.data = data;
811
814
  fillSchema(schema, data);
@@ -841,7 +844,7 @@ export class Page extends BasePage {
841
844
  if (schema)
842
845
  callContext.data = await getSchemaState(schema);
843
846
  await action.call(callContext);
844
- // « … & create another » : la modale reste ouverte sur un formulaire remis à zéro.
847
+ // "… & create another": the modal stays open with a reset form.
845
848
  if (schema && _args && typeof _args === 'object' && _args.another === true) {
846
849
  const data = {};
847
850
  mounted.data = data;
@@ -866,25 +869,25 @@ export class Page extends BasePage {
866
869
  }
867
870
  }
868
871
  /**
869
- * Tableau de bord (port de Filament\Pages\Dashboard) : une grille de widgets, précédée d'un formulaire de filtres
870
- * quand la page définit `filtersFormSchema(schema)` (port de HasFiltersForm). Les filtres sont passés aux widgets sous `pageFilters`.
872
+ * Dashboard: a widget grid, preceded by a filters form when the page defines `filtersFormSchema(schema)`.
873
+ * The filters are passed to widgets as `pageFilters`.
871
874
  */
872
875
  export class Dashboard extends Page {
873
876
  static slug = '';
874
877
  static title = 'Dashboard';
875
878
  static navigationIcon = 'o-home';
876
879
  static navigationSort = -2;
877
- /** État du formulaire de filtres. Reste `null` tant que la page n'a pas de filtres. */
880
+ /** Filters form state. Stays `null` while the page has no filters. */
878
881
  filters = null;
879
- /** Widgets du tableau de bord. Par défaut : ceux du panel (`panel.widgets([...])`). */
882
+ /** Dashboard widgets. Defaults to the panel's (`panel.widgets([...])`). */
880
883
  getWidgets() { return this.panel.getWidgets(); }
881
- /** Colonnes de la grille : un nombre (au point de rupture `lg`), ou un nombre par point de rupture. */
884
+ /** Grid columns: a number (at the `lg` breakpoint), or one number per breakpoint. */
882
885
  getColumns() { return 2; }
883
886
  hasFiltersForm() { return typeof this.filtersFormSchema === 'function'; }
884
887
  getSchema(name) {
885
888
  if (name !== 'filtersForm')
886
889
  return super.getSchema(name);
887
- // Comme getFiltersForm() : grille 2 / 3 / 4 colonnes, état sous `filters`, champs « live ».
890
+ // 2 / 3 / 4 column grid, state under `filters`, live fields.
888
891
  const schema = this.filtersFormSchema(Schema.make(this).key('filtersForm').columns({ md: 2, xl: 3, '2xl': 4 }).statePath('filters').partialName('table-filters-form'));
889
892
  for (const component of schema.getFlatComponents())
890
893
  if (component instanceof Field && typeof component.live === 'function')
@@ -904,13 +907,13 @@ export class Dashboard extends Page {
904
907
  return `<div x-data="filamentSchema({ livewireId: ${e(js(this.id))}, schemaKey: 'content' })" x-on:form-validation-error.window="handleFormValidationError" x-on:reset-schema-component-state.window="handleClientSideStateReset" class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));">${filters}${widgets}</div>`;
905
908
  }
906
909
  }
907
- /** Limiteur de tentatives en mémoire : `max` essais par fenêtre, par clé. Suffisant pour une instance ; à remplacer par un magasin partagé si le panel tourne sur plusieurs. */
910
+ /** In-memory rate limiter: `max` attempts per window, per key. Fine for a single instance; use a shared store when running several. */
908
911
  export class Throttle {
909
912
  hits = new Map();
910
913
  max;
911
914
  windowMs;
912
915
  constructor(max = 5, windowMs = 60_000) { this.max = max; this.windowMs = windowMs; }
913
- /** Enregistre un essai ; rend 0 s'il est permis, sinon le nombre de secondes à attendre. */
916
+ /** Records an attempt; returns 0 if allowed, otherwise the number of seconds to wait. */
914
917
  hit(key, now = Date.now()) {
915
918
  const recent = (this.hits.get(key) ?? []).filter((at) => at > now - this.windowMs);
916
919
  if (recent.length >= this.max) {
@@ -919,7 +922,7 @@ export class Throttle {
919
922
  }
920
923
  recent.push(now);
921
924
  this.hits.set(key, recent);
922
- // Le magasin ne grossit pas indéfiniment.
925
+ // Keep the store from growing forever.
923
926
  if (this.hits.size > 10_000)
924
927
  for (const [k, v] of this.hits)
925
928
  if (!v.some((at) => at > now - this.windowMs))
@@ -929,35 +932,35 @@ export class Throttle {
929
932
  clear(key) { this.hits.delete(key); }
930
933
  }
931
934
  const loginThrottle = new Throttle();
932
- /** Page à mise en page « simple » (connexion, inscription…). */
935
+ /** Page with the simple layout (login, registration…). */
933
936
  export class SimplePage extends BasePage {
934
937
  getHeading() { return this.getTitle(); }
935
- /** Sous-titre, en HTML de confiance (composé par la page, données échappées). */
938
+ /** Subheading, as trusted HTML (built by the page, with data escaped). */
936
939
  getSubheading() { return null; }
937
940
  hasLogo() { return true; }
938
941
  async render() {
939
942
  return views.render('filament-panels::pages.simple', { brand: this.panel.getBrand(), heading: this.getHeading(), subheading: this.getSubheading(), hasLogo: this.hasLogo(), content: await this.getSchema('content').toHtml(), livewireId: this.id, renderHookScopes: this.getRenderHookScopes() });
940
943
  }
941
944
  }
942
- /** Gestionnaire de couleurs lu par les autres paquets, partagé par tous les panels ; chaque requête y pose les couleurs de son panel (`Panel.withTheme`). */
945
+ /** Color manager read by the other packages and shared by all panels; each request sets its panel's colors on it (`Panel.withTheme`). */
943
946
  const sharedColors = new ColorManager();
944
- /** Lien « fi-link » d'une page d'authentification (port de `Action::make()->link()`). */
947
+ /** `fi-link` link of an authentication page. */
945
948
  function authLink(url, label, options = {}) {
946
949
  const classes = ['fi-ac-link-action', 'fi-link', `fi-size-${options.size ?? 'md'}`, ...sharedColors.getComponentClasses('link', 'primary')];
947
950
  return `<a href="${e(url)}" class="${classes.join(' ')}" >${options.iconName ? icon(options.iconName) : ''}<span class="fi-link-label"> ${e(label)} </span></a>`;
948
951
  }
949
- /** Adresse absolue pour un e-mail : celle déclarée par `panel.appUrl()`, sinon l'origine de la requête. */
952
+ /** Absolute URL for an email: based on `panel.appUrl()`, otherwise the request origin. */
950
953
  function absoluteUrl(panel, req, path) { return `${panel.getAppUrl() ?? `${req.protocol}://${req.get('host')}`}${path}`; }
951
954
  const registerThrottle = new Throttle(2);
952
955
  const passwordResetThrottle = new Throttle(2);
953
956
  const verificationThrottle = new Throttle(2);
954
957
  const throttled = (group, wait) => Notification.make().danger().title(t(`${group}.notifications.throttled.title`, { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t(`${group}.notifications.throttled.body`, { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
955
- /** Règles de mot de passe de Laravel (`Password::default()` : 8 caractères au moins) et de confirmation (`same`). */
958
+ /** Password rules (at least 8 characters) and confirmation rule (`same`). */
956
959
  const passwordRules = (confirmationField, confirmationLabel) => [
957
960
  { name: 'min', passes: (value) => String(value).length >= 8, message: (attribute) => `The ${attribute} field must be at least 8 characters.` },
958
961
  { name: 'same', passes: (value, ctx) => value === ctx.get(confirmationField), message: (attribute) => `The ${attribute} field must match ${confirmationLabel}.` },
959
962
  ];
960
- /** E-mail de vérification de l'adresse (texte de la notification `VerifyEmail` de Laravel). */
963
+ /** Email verification message. */
961
964
  async function sendVerificationEmail(panel, req, user) {
962
965
  await panel.sendMail({
963
966
  to: user.email, subject: 'Verify Email Address',
@@ -972,7 +975,7 @@ const formContent = (submit, actions, options = {}) => [
972
975
  .footer([Actions.make(actions).alignment(options.alignment ?? null).fullWidth(options.fullWidth ?? true).key('form-actions')]),
973
976
  ...(options.after ? [RenderHook.make(options.after)] : []),
974
977
  ];
975
- /** Inscription (port de Filament\Auth\Pages\Register) : nom, adresse, mot de passe confirmé ; l'utilisateur est connecté, puis invité à vérifier son adresse si le panel l'exige. */
978
+ /** Registration page: name, email, confirmed password. The user is logged in, then asked to verify their email if the panel requires it. */
976
979
  export class Register extends SimplePage {
977
980
  static callable = ['register', 'callSchemaComponentMethod'];
978
981
  data = {};
@@ -1003,9 +1006,9 @@ export class Register extends SimplePage {
1003
1006
  contentSchema(schema) {
1004
1007
  return schema.components(formContent('register', [Action.make('register').label(t('filament-panels::auth/pages/register.form.actions.register.label')).submit('register')], { before: PanelsRenderHook.AUTH_REGISTER_FORM_BEFORE, after: PanelsRenderHook.AUTH_REGISTER_FORM_AFTER }));
1005
1008
  }
1006
- /** Données validées, juste avant la création (ajouter une valeur, normaliser l'adresse…). */
1009
+ /** Validated data, just before creation (add a value, normalize the email…). */
1007
1010
  mutateFormDataBeforeRegister(data) { return data; }
1008
- /** Création de l'utilisateur : par défaut `create()` du fournisseur d'utilisateurs. */
1011
+ /** Creates the user; defaults to the auth provider's `create()`. */
1009
1012
  handleRegistration(data) {
1010
1013
  return this.panel.getAuth().create({ name: String(data.name), email: String(data.email), password: String(data.password) });
1011
1014
  }
@@ -1029,7 +1032,7 @@ export class Register extends SimplePage {
1029
1032
  this.redirect(panel.getUrl());
1030
1033
  }
1031
1034
  }
1032
- /** Demande de réinitialisation du mot de passe (port de RequestPasswordReset) : un lien signé, valable 60 minutes, part par e-mail. */
1035
+ /** Password reset request page: a signed link, valid for 60 minutes, is sent by email. */
1033
1036
  export class RequestPasswordReset extends SimplePage {
1034
1037
  static callable = ['request', 'callSchemaComponentMethod'];
1035
1038
  data = {};
@@ -1074,14 +1077,14 @@ export class RequestPasswordReset extends SimplePage {
1074
1077
  outro: ['This password reset link will expire in 60 minutes.', 'If you did not request a password reset, no further action is required.'],
1075
1078
  });
1076
1079
  }
1077
- // Écart de sécurité assumé : Filament (via Laravel) répond « adresse inconnue » quand aucun compte n'existe, ce qui
1078
- // révèle quelles adresses sont inscrites. Navette répond toujours de la même façon.
1080
+ // Security: always answer the same way, whether or not an account exists, so the response
1081
+ // does not reveal which email addresses are registered.
1079
1082
  Notification.make().success().title('We have emailed your password reset link.').body(t('filament-panels::auth/pages/password-reset/request-password-reset.notifications.sent.body')).send();
1080
1083
  this.data = {};
1081
1084
  fillSchema(form, this.data);
1082
1085
  }
1083
1086
  }
1084
- /** Nouveau mot de passe, depuis le lien reçu par e-mail (port de ResetPassword). Le lien ne sert qu'une fois : il meurt avec l'ancien mot de passe. */
1087
+ /** Sets a new password from the emailed link. The link works once: it dies with the old password. */
1085
1088
  export class ResetPassword extends SimplePage {
1086
1089
  static callable = ['resetPassword', 'callSchemaComponentMethod'];
1087
1090
  static locked = ['email', 'token'];
@@ -1097,7 +1100,7 @@ export class ResetPassword extends SimplePage {
1097
1100
  this.token = typeof params.token === 'string' ? params.token : null;
1098
1101
  this.email = typeof params.email === 'string' ? params.email : null;
1099
1102
  }
1100
- /** Comme Filament : le formulaire n'a pas de chemin d'état, ses champs sont des propriétés de la page. */
1103
+ /** The form has no state path: its fields are page properties. */
1101
1104
  getSchema(name) { return name === 'form' ? this.formSchema(Schema.make(this).key('form')) : super.getSchema(name); }
1102
1105
  getTitle() { return t('filament-panels::auth/pages/password-reset/reset-password.title'); }
1103
1106
  getHeading() { return t('filament-panels::auth/pages/password-reset/reset-password.heading'); }
@@ -1126,7 +1129,7 @@ export class ResetPassword extends SimplePage {
1126
1129
  const auth = panel.getAuth();
1127
1130
  const payload = panel.readResetPasswordToken(this.token);
1128
1131
  const user = payload ? await auth.find(payload.uid) : null;
1129
- // Le jeton désigne l'utilisateur ; l'adresse de l'URL doit être la sienne et son mot de passe ne doit pas avoir changé depuis l'envoi.
1132
+ // The token identifies the user; the URL's email must be theirs and their password must not have changed since the link was sent.
1130
1133
  if (!payload || !user || user.email !== this.email || (await auth.getPasswordFingerprint(user)) !== payload.fp) {
1131
1134
  Notification.make().danger().title('This password reset token is invalid.').send();
1132
1135
  return;
@@ -1136,7 +1139,7 @@ export class ResetPassword extends SimplePage {
1136
1139
  this.redirect(panel.getLoginUrl());
1137
1140
  }
1138
1141
  }
1139
- /** Invitation à vérifier son adresse (port de EmailVerificationPrompt), avec le lien « Resend it. ». */
1142
+ /** Email verification prompt, with a "Resend it." link. */
1140
1143
  export class EmailVerificationPrompt extends SimplePage {
1141
1144
  static callable = ['mountAction'];
1142
1145
  async mount() {
@@ -1156,11 +1159,11 @@ export class EmailVerificationPrompt extends SimplePage {
1156
1159
  text(`${e(t('filament-panels::auth/pages/email-verification/email-verification-prompt.messages.notification_not_received'))} ${link}`),
1157
1160
  ]);
1158
1161
  }
1159
- /** Seule action de la page : renvoyer l'e-mail (deux fois par minute au plus). */
1162
+ /** The page's only action: resend the email (at most twice per minute). */
1160
1163
  async mountAction(name) {
1161
1164
  const { req, panel, user } = this._ctx;
1162
1165
  if (name !== 'resendNotification' || !user)
1163
- throw new WireError(404, 'Action introuvable.');
1166
+ throw new WireError(404, 'Action not found.');
1164
1167
  this.skipRender();
1165
1168
  const wait = verificationThrottle.hit(`${panel.getId()}:verify:${user.id}`);
1166
1169
  if (wait > 0) {
@@ -1172,7 +1175,7 @@ export class EmailVerificationPrompt extends SimplePage {
1172
1175
  Notification.make().success().title(t('filament-panels::auth/pages/email-verification/email-verification-prompt.notifications.notification_resent.title')).send();
1173
1176
  }
1174
1177
  }
1175
- /** Profil de l'utilisateur (port de EditProfile) : nom, adresse, nouveau mot de passe ; le mot de passe actuel est exigé pour changer l'adresse ou le mot de passe. */
1178
+ /** User profile page: name, email, new password. The current password is required to change the email or password. */
1176
1179
  export class EditProfile extends SimplePage {
1177
1180
  static callable = ['save', 'callSchemaComponentMethod'];
1178
1181
  static slug = 'profile';
@@ -1181,11 +1184,11 @@ export class EditProfile extends SimplePage {
1181
1184
  async mount() {
1182
1185
  const { user } = this._ctx;
1183
1186
  if (!user)
1184
- throw new WireError(401, 'Non authentifié.');
1187
+ throw new WireError(401, 'Unauthenticated.');
1185
1188
  this.data = { name: user.name, email: user.email, password: null, passwordConfirmation: null, currentPassword: null };
1186
1189
  }
1187
1190
  async boot() { if (!this._ctx?.user)
1188
- throw new WireError(401, 'Non authentifié.'); }
1191
+ throw new WireError(401, 'Unauthenticated.'); }
1189
1192
  getTitle() { return this.constructor.getLabel(); }
1190
1193
  hasLogo() { return false; }
1191
1194
  formSchema(schema) {
@@ -1213,7 +1216,7 @@ export class EditProfile extends SimplePage {
1213
1216
  async save() {
1214
1217
  const { panel, user } = this._ctx;
1215
1218
  if (!user)
1216
- throw new WireError(401, 'Non authentifié.');
1219
+ throw new WireError(401, 'Unauthenticated.');
1217
1220
  const form = this.getSchema('form');
1218
1221
  this._errors = await validateSchema(form);
1219
1222
  if (Object.keys(this._errors).length) {
@@ -1227,18 +1230,18 @@ export class EditProfile extends SimplePage {
1227
1230
  }
1228
1231
  }
1229
1232
  const blankValue = (value) => value === null || value === undefined || value === '';
1230
- /** Création d'un locataire (port de RegisterTenant) : la sous-classe donne son libellé et son formulaire (`formSchema`). */
1233
+ /** Tenant registration page: the subclass provides its label and form (`formSchema`). */
1231
1234
  export class RegisterTenant extends SimplePage {
1232
1235
  static callable = ['register', 'callSchemaComponentMethod'];
1233
1236
  static slug = 'new';
1234
1237
  data = {};
1235
1238
  static getLabel() { return 'Register tenant'; }
1236
- /** Le panel accepte-t-il la création ? Par défaut : si le fournisseur de locataires sait créer. */
1239
+ /** Whether tenants can be created; by default, if the tenant provider implements `create`. */
1237
1240
  static canView(panel, _user) { return typeof panel.getTenantProvider().create === 'function'; }
1238
1241
  async boot() {
1239
1242
  const ctx = this._ctx;
1240
1243
  if (!ctx?.user)
1241
- throw new WireError(401, 'Non authentifié.');
1244
+ throw new WireError(401, 'Unauthenticated.');
1242
1245
  if (!(await this.constructor.canView(ctx.panel, ctx.user)))
1243
1246
  throw new WireError(404, 'Not Found');
1244
1247
  }
@@ -1266,7 +1269,7 @@ export class RegisterTenant extends SimplePage {
1266
1269
  this.redirect(panel.getUrl('', tenant));
1267
1270
  }
1268
1271
  }
1269
- /** Profil du locataire courant (port de EditTenantProfile), dans la mise en page complète. */
1272
+ /** Current tenant's profile page, in the full layout. */
1270
1273
  export class EditTenantProfile extends Page {
1271
1274
  static callable = [...Page.callable, 'save'];
1272
1275
  static slug = 'profile';
@@ -1319,7 +1322,7 @@ export class Login extends SimplePage {
1319
1322
  RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_BEFORE),
1320
1323
  Form.make([EmbeddedSchema.make('form')]).id('form').livewireSubmitHandler('authenticate')
1321
1324
  .footer([Actions.make([Action.make('authenticate').label(t('filament-panels::auth/pages/login.form.actions.authenticate.label')).submit('authenticate')]).fullWidth().key('form-actions')]),
1322
- // Formulaire de défi multi-facteur : masqué tant que la MFA n'est pas portée (jalon 7).
1325
+ // Multi-factor challenge form: hidden until MFA is implemented.
1323
1326
  Form.make([]).id('multiFactorChallengeForm').hidden(),
1324
1327
  RenderHook.make(PanelsRenderHook.AUTH_LOGIN_FORM_AFTER),
1325
1328
  ]);
@@ -1331,7 +1334,7 @@ export class Login extends SimplePage {
1331
1334
  return;
1332
1335
  }
1333
1336
  const { req, res, panel } = this._ctx;
1334
- // Comme Filament : cinq tentatives par minute et par adresse IP, puis attente.
1337
+ // Five attempts per minute per IP address, then wait.
1335
1338
  const wait = loginThrottle.hit(`${panel.getId()}:${req.ip ?? ''}`);
1336
1339
  if (wait > 0) {
1337
1340
  Notification.make().danger().title(t('filament-actions::notifications.throttled.title', { seconds: wait, minutes: Math.ceil(wait / 60) })).body(t('filament-actions::notifications.throttled.body', { seconds: wait, minutes: Math.ceil(wait / 60) })).send();
@@ -1347,16 +1350,15 @@ export class Login extends SimplePage {
1347
1350
  this.redirect(panel.getUrl());
1348
1351
  }
1349
1352
  }
1350
- /** L'utilisateur du composant hôte a-t-il cette capacité sur la resource (ou le relation manager) du composant ? À utiliser dans `authorize()` de vos actions. */
1353
+ /** Whether the host component's user has this ability on the component's resource (or relation manager). Use it in your actions' `authorize()`. */
1351
1354
  export const can = (livewire, ability, record = null) => canOn(livewire, ability, record);
1352
1355
  function canOn(livewire, ability, record = null) {
1353
1356
  const resource = livewire?.resource;
1354
1357
  return typeof resource?.can === 'function' ? resource.can(ability, livewire._ctx?.user ?? null, record) : true;
1355
1358
  }
1356
- // ------------------------------------------------------------------ resources
1357
1359
  const plural = (s) => (/[^aeiou]y$/.test(s) ? s.slice(0, -1) + 'ies' : /(s|x|z|ch|sh)$/.test(s) ? s + 'es' : s + 's');
1358
1360
  const ucfirst = (s) => s.replace(/^./, (c) => c.toUpperCase());
1359
- /** Resource : un modèle, ses pages, sa table et sa navigation (port partiel de Filament\Resources\Resource). */
1361
+ /** Resource: a model with its pages, table and navigation. */
1360
1362
  export class Resource {
1361
1363
  static panel;
1362
1364
  static slug = '';
@@ -1367,33 +1369,33 @@ export class Resource {
1367
1369
  static navigationSort = 0;
1368
1370
  static navigationBadge = null;
1369
1371
  static adapter;
1370
- /** Dans un panel à locataires : les enregistrements sont-ils propres à chaque locataire ? (vrai par défaut, comme Filament) */
1372
+ /** In a multi-tenant panel, whether records belong to each tenant (`true` by default). */
1371
1373
  static isScopedToTenant = true;
1372
- /** Colonne du locataire pour cette resource (par défaut celle du panel : `panel.tenant(provider, { ownershipColumn })`). */
1374
+ /** Tenant column for this resource (defaults to the panel's: `panel.tenant(provider, { ownershipColumn })`). */
1373
1375
  static tenantOwnershipColumn = null;
1374
1376
  /**
1375
- * Pages de la resource, à remplacer par vos sous-classes pour en changer le comportement :
1377
+ * Resource pages; replace them with your subclasses to change their behavior:
1376
1378
  * `static pages = { index: MyList, create: MyCreate, edit: MyEdit }`.
1377
1379
  */
1378
1380
  static pages = {};
1379
- /** Page de consultation (`ViewRecord`) : vrai pour l'ajouter aux pages de la resource ; la ligne d'une table y mène alors, comme dans Filament. */
1381
+ /** `true` adds a view page (`ViewRecord`) to the resource; table rows then link to it. */
1380
1382
  static hasViewPage = false;
1381
- /** Qui peut faire quoi sur cette resource. Sans politique, tout utilisateur connecté peut tout. */
1383
+ /** Who can do what on this resource. Without a policy, any signed-in user can do everything. */
1382
1384
  static policy = null;
1383
- /** L'utilisateur a-t-il cette capacité (sur cet enregistrement, s'il y en a un) ? */
1385
+ /** Whether the user has this ability (on this record, if any). */
1384
1386
  static can(ability, user, record = null) {
1385
1387
  const rule = this.policy?.[ability];
1386
1388
  return this.panel.authorize(user, ability, { resource: this, record, rule: rule ? () => rule(user, record) : null });
1387
1389
  }
1388
- /** Widgets de la resource (équivalent de getWidgets()) : enregistrés comme composants. */
1390
+ /** Resource widgets, registered as components. */
1389
1391
  static widgets = [];
1390
- /** Widgets affichés au-dessus de la table de la page de liste (équivalent de ListRecords::getHeaderWidgets()). */
1392
+ /** Widgets shown above the table on the list page. */
1391
1393
  static headerWidgets = [];
1392
- /** Relation managers affichés sous le formulaire d'édition (équivalent de getRelations()). */
1394
+ /** Relation managers shown below the edit form. */
1393
1395
  static relations = [];
1394
- /** Onglets de la page de liste (équivalent de ListRecords::getTabs()). */
1396
+ /** Tabs of the list page. */
1395
1397
  static tabs() { return {}; }
1396
- /** Resource « simple » (équivalent de ManageRecords) : une seule page, création et édition en modales. */
1398
+ /** Simple resource: a single page, with create and edit in modals. */
1397
1399
  static isSimple = false;
1398
1400
  static getPluralModelLabel() { return this.pluralModelLabel ?? plural(this.modelLabel); }
1399
1401
  static getTitleCasePluralModelLabel() { return this.getPluralModelLabel().split(' ').map(ucfirst).join(' '); }
@@ -1403,11 +1405,11 @@ export class Resource {
1403
1405
  return `${base}/${record}/${page}`;
1404
1406
  return page === 'index' ? base : page === 'create' ? `${base}/create` : page === 'view' ? `${base}/${record}` : `${base}/${record}/edit`;
1405
1407
  }
1406
- /** Pages d'enregistrements liés (port de ManageRelatedRecords) : `{ comments: ManagePostComments }` répond sous `<slug>/{record}/comments`. */
1408
+ /** Related records pages: `{ comments: ManagePostComments }` is served at `<slug>/{record}/comments`. */
1407
1409
  static relatedPages = {};
1408
- /** Pages proposées dans la sous-navigation d'un enregistrement, dans l'ordre : `['view', 'edit', 'comments']`. Vide : pas de sous-navigation. */
1410
+ /** Pages listed in a record's sub-navigation, in order: `['view', 'edit', 'comments']`. Empty: no sub-navigation. */
1409
1411
  static recordSubNavigation = [];
1410
- /** Port de getRecordSubNavigation() + generateNavigationItems() : un onglet par page déclarée, celui de la page courante actif. */
1412
+ /** Record sub-navigation: one tab per declared page, the current page's tab active. */
1411
1413
  static getRecordSubNavigation(page) {
1412
1414
  const pages = this.getPages();
1413
1415
  const key = page.getRecordKey() ?? '';
@@ -1422,25 +1424,25 @@ export class Resource {
1422
1424
  });
1423
1425
  }
1424
1426
  static _registered = new WeakMap();
1425
- /** Classe de page réellement enregistrée pour un nom (les pages sont générées une seule fois par resource). */
1427
+ /** Page class actually registered under a name (pages are generated once per resource). */
1426
1428
  static getRegisteredPage(name) { return this.getPages()[name]; }
1427
1429
  static table(table) { return table; }
1428
1430
  static form(schema) { return schema; }
1429
- /** Schéma de la page de consultation (équivalent de `infolist()`). */
1431
+ /** Schema of the view page. */
1430
1432
  static infolist(schema) { return schema; }
1431
- // -------------------------------------------------------------- recherche globale
1432
- /** Colonnes interrogées par la recherche globale ; par défaut, l'attribut de titre. Tableau vide : resource non cherchable. */
1433
+ // -------------------------------------------------------------- global search
1434
+ /** Columns searched by global search; defaults to the title attribute. Empty array: the resource is not searchable. */
1433
1435
  static globallySearchableAttributes = null;
1434
1436
  static getGloballySearchableAttributes() { return this.globallySearchableAttributes ?? (this.recordTitleAttribute ? [this.recordTitleAttribute] : []); }
1435
1437
  static canGloballySearch() { return this.getGloballySearchableAttributes().length > 0; }
1436
- /** Rang du groupe de cette resource dans les résultats (`$globalSearchSort`) ; sans rang : après les autres, dans l'ordre d'enregistrement. */
1438
+ /** Position of this resource's group in the results; without one, after the others in registration order. */
1437
1439
  static globalSearchSort = null;
1438
- /** Nombre de résultats rendus par resource (50, comme Filament). */
1440
+ /** Maximum results rendered per resource. */
1439
1441
  static globalSearchResultsLimit = 50;
1440
1442
  static getGlobalSearchResultTitle(record) { return this.getRecordTitle(record); }
1441
- /** Précisions affichées sous le titre : `{ Author: 'Jane' }`. Les valeurs vides sont écartées. */
1443
+ /** Details shown under the title: `{ Author: 'Jane' }`. Empty values are skipped. */
1442
1444
  static getGlobalSearchResultDetails(_record) { return {}; }
1443
- /** Adresse d'un résultat : page « View », sinon édition ; pour une resource simple, la liste avec l'action ouverte. Nul si l'utilisateur n'a aucun accès. */
1445
+ /** URL of a result: the view page, otherwise the edit page; for a simple resource, the list with the action open. `null` if the user has no access. */
1444
1446
  static async getGlobalSearchResultUrl(record, user) {
1445
1447
  const key = this.adapter.getKey(record);
1446
1448
  const canView = await this.can('view', user, record);
@@ -1467,11 +1469,11 @@ export class Resource {
1467
1469
  }
1468
1470
  return results;
1469
1471
  }
1470
- /** Attribut affiché comme titre d'un enregistrement (`$recordTitleAttribute`). */
1472
+ /** Attribute used as a record's title. */
1471
1473
  static recordTitleAttribute = null;
1472
- /** Sans attribut de titre : le libellé du modèle, en capitales initiales (« Leave Request »), comme Filament. */
1474
+ /** Without a title attribute: the model label in title case ("Leave Request"). */
1473
1475
  static getRecordTitle(record) { return this.recordTitleAttribute ? String(record?.[this.recordTitleAttribute] ?? '') : this.modelLabel.split(' ').map(ucfirst).join(' '); }
1474
- /** Pages de la resource, générées : liste, création, édition. */
1476
+ /** Generated resource pages: list, create, edit. */
1475
1477
  static getPages() {
1476
1478
  const known = Resource._registered.get(this);
1477
1479
  if (known)
@@ -1522,90 +1524,90 @@ export class Resource {
1522
1524
  return { index: List, create: Create, edit: Edit, view: View, ...related };
1523
1525
  }
1524
1526
  }
1525
- /** Widget : composant affiché dans une grille de page (port de Filament\Widgets\Widget). */
1527
+ /** Widget: a component displayed in a page's widget grid. */
1526
1528
  export class Widget extends Component {
1527
1529
  static componentName;
1528
- /** Largeur dans la grille de widgets : un nombre de colonnes, ou `'full'`. */
1530
+ /** Width in the widget grid: a number of columns, or `'full'`. */
1529
1531
  static columnSpan = 1;
1530
- /** Chargé quand il devient visible (comme `$isLazy`). */
1532
+ /** Loaded when it becomes visible. */
1531
1533
  static isLazy = true;
1532
1534
  get panel() { return this._ctx.panel; }
1533
- /** Widget réservé à certains utilisateurs : rendre `false` le retire de la page et refuse son chargement. */
1535
+ /** Restricts the widget to some users: returning `false` removes it from the page and refuses to load it. */
1534
1536
  static canView(_user) { return true; }
1535
1537
  async boot() {
1536
1538
  const ctx = this._ctx;
1537
1539
  if (!ctx?.user)
1538
- throw new WireError(401, 'Non authentifié.');
1540
+ throw new WireError(401, 'Unauthenticated.');
1539
1541
  const WidgetClass = this.constructor;
1540
1542
  if (!(await ctx.panel.authorize(ctx.user, 'view', { widget: WidgetClass, rule: () => WidgetClass.canView(ctx.user) })))
1541
- throw new WireError(403, 'Accès refusé.');
1543
+ throw new WireError(403, 'Forbidden.');
1542
1544
  }
1543
- /** Classes et style de colonne de grille, au point de rupture `lg` comme Filament. */
1545
+ /** Grid column classes and style, at the `lg` breakpoint. */
1544
1546
  gridColumn() {
1545
1547
  const span = this.constructor.columnSpan;
1546
- // Comme Filament : la portée est toujours écrite, même pour une seule colonne.
1548
+ // The span is always written, even for a single column.
1547
1549
  return { classes: ['fi-grid-col', 'lg:fi-grid-col-span'], style: `--col-span-lg: ${span === 'full' ? '1 / -1' : `span ${Math.floor(span)} / span ${Math.floor(span)}`};` };
1548
1550
  }
1549
1551
  placeholder() {
1550
1552
  const column = this.gridColumn();
1551
1553
  return `<div role="status" aria-busy="true" style="height: 8rem; ${column.style}" class="${cls(['fi-section', 'fi-loading-section', ...column.classes])}"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
1552
1554
  }
1553
- /** Enveloppe commune (port de filament-widgets::components.widget). */
1555
+ /** Common widget wrapper. */
1554
1556
  wrap(content, classes = []) {
1555
1557
  const column = this.gridColumn();
1556
1558
  return `<div${column.style ? ` style="${column.style}"` : ''} class="${cls(['fi-wi-widget', ...column.classes, ...classes])}">${content}</div>`;
1557
1559
  }
1558
1560
  }
1559
1561
  /**
1560
- * Calendrier (propre à Navette) : vues mois, semaine et liste, navigation rendue par le serveur, sans bibliothèque JavaScript.
1562
+ * Calendar widget: month, week and list views, server-rendered navigation, no JavaScript library.
1561
1563
  *
1562
- * Le minimum : étendre la classe et rendre les événements de la période affichée.
1564
+ * Minimal use: extend the class and return the events of the displayed period.
1563
1565
  *
1564
1566
  * class LeaveCalendar extends CalendarWidget {
1565
1567
  * static heading = 'Leave';
1566
1568
  * async getEvents({ start, end }) { return [{ id: 1, title: 'Ada — vacation', start: '2026-11-12', end: '2026-11-14', color: 'success' }]; }
1567
1569
  * }
1568
1570
  *
1569
- * Tout le reste se redéfinit méthode par méthode : vues proposées, premier jour de la semaine, langue, libellés, nombre
1570
- * d'événements par case, réaction au clic sur un jour (`onDateClick`) ou sur un événement (`onEventClick`), rendu d'un
1571
- * événement (`renderEvent`), pied de case (`renderDayFooter`), boutons de la barre d'outils (`renderToolbarEnd`), et
1572
- * l'apparence par les variables CSS de `.nv-cal` (`getExtraClasses`, `getStyle`).
1571
+ * Everything else can be overridden method by method: available views, first day of the week, locale, labels, events
1572
+ * per cell, day clicks (`onDateClick`) and event clicks (`onEventClick`), event rendering (`renderEvent`), cell footer
1573
+ * (`renderDayFooter`), toolbar buttons (`renderToolbarEnd`), and appearance through the `.nv-cal` CSS variables
1574
+ * (`getExtraClasses`, `getStyle`).
1573
1575
  */
1574
1576
  export class CalendarWidget extends Widget {
1575
1577
  static columnSpan = 'full';
1576
- /** Titre affiché avant la période (« Leave — November 2026 »). */
1578
+ /** Heading shown before the period ("Leave — November 2026"). */
1577
1579
  static heading = null;
1578
1580
  static callable = ['previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
1579
- /** La vue et la date ne changent que par les méthodes ci-dessous, qui les valident. */
1581
+ /** View and date only change through the methods below, which validate them. */
1580
1582
  static locked = ['view', 'date'];
1581
1583
  view = 'month';
1582
- /** Date d'ancrage (`YYYY-MM-DD`) : le mois ou la semaine affichés la contiennent. Vide : aujourd'hui. */
1584
+ /** Anchor date (`YYYY-MM-DD`): the displayed month or week contains it. Empty: today. */
1583
1585
  date = '';
1584
- /** Vues proposées, la première étant celle d'arrivée. Une seule vue : le sélecteur disparaît. */
1586
+ /** Available views; the first is the initial one. With a single view, the switcher is hidden. */
1585
1587
  getViews() { return ['month', 'week', 'list']; }
1586
- /** 0 = dimanche, 1 = lundi… */
1588
+ /** 0 = Sunday, 1 = Monday… */
1587
1589
  getFirstDayOfWeek() { return 1; }
1588
- /** Langue des noms de mois et de jours (`Intl`). */
1590
+ /** Locale for month and day names (`Intl`). */
1589
1591
  getLocale() { return 'en-US'; }
1590
1592
  getLabels() { return CALENDAR_LABELS; }
1591
- /** En vue mois, au-delà de ce nombre, la case affiche « +N more » (qui ouvre la liste sur ce jour). */
1593
+ /** In month view, beyond this number the cell shows "+N more" (which opens the list on that day). */
1592
1594
  getMaxEventsPerDay() { return 3; }
1593
1595
  getHeading() { return this.constructor.heading; }
1594
- /** Date du jour, `YYYY-MM-DD` (UTC par défaut ; à redéfinir pour un autre fuseau). */
1596
+ /** Today's date, `YYYY-MM-DD` (UTC by default; override for another time zone). */
1595
1597
  getToday() { return new Date().toISOString().slice(0, 10); }
1596
- /** Clic sur un numéro de jour. Redéfinir cette méthode rend les jours cliquables : ouvrir une page (`this.redirect`), notifier, changer de vue… */
1598
+ /** Click on a day number. Overriding this makes days clickable: open a page (`this.redirect`), notify, switch view… */
1597
1599
  onDateClick(_date) { }
1598
- /** Clic sur un événement sans adresse. L'événement est relu côté serveur : `event.extra` est disponible. */
1600
+ /** Click on an event without a URL. The event is reloaded server-side, so `event.extra` is available. */
1599
1601
  onEventClick(_event) { }
1600
- /** HTML d'un événement (déjà échappé), ou `null` pour le rendu par défaut (`renderCalendarEvent`). */
1602
+ /** Already-escaped HTML for an event, or `null` for the default rendering (`renderCalendarEvent`). */
1601
1603
  renderEvent(_event, _context) { return null; }
1602
- /** HTML ajouté au bas de chaque case (déjà échappé). */
1604
+ /** Already-escaped HTML appended to the bottom of each cell. */
1603
1605
  renderDayFooter(_day, _events) { return ''; }
1604
- /** HTML placé à droite de la barre d'outils (déjà échappé) : un lien « New », un filtre… */
1606
+ /** Already-escaped HTML at the end of the toolbar: a "New" link, a filter… */
1605
1607
  renderToolbarEnd() { return ''; }
1606
- /** Classes ajoutées au calendrier, pour y accrocher vos styles ou redéfinir ses variables CSS. */
1608
+ /** Extra classes on the calendar, to hook your styles or override its CSS variables. */
1607
1609
  getExtraClasses() { return []; }
1608
- /** Variables CSS posées sur le calendrier : `{ '--nv-cal-day-min-height': '9rem' }`. */
1610
+ /** CSS variables set on the calendar: `{ '--nv-cal-day-min-height': '9rem' }`. */
1609
1611
  getStyle() { return {}; }
1610
1612
  // ---------------------------------------------------------- navigation
1611
1613
  anchor() { return isDate(this.date) ? this.date : this.getToday(); }
@@ -1616,7 +1618,7 @@ export class CalendarWidget extends Widget {
1616
1618
  goToToday() { this.date = this.getToday(); }
1617
1619
  setView(view) { if (typeof view === 'string' && this.getViews().includes(view) && CALENDAR_VIEWS.includes(view))
1618
1620
  this.view = view; }
1619
- /** « +N more » : la liste, ancrée sur ce jour (ou la semaine si la liste n'est pas proposée). */
1621
+ /** "+N more": the list view anchored on that day (or the week view if list is not available). */
1620
1622
  showDay(date) {
1621
1623
  if (!isDate(date))
1622
1624
  return;
@@ -1628,7 +1630,7 @@ export class CalendarWidget extends Widget {
1628
1630
  eventsClickable() { return this.onEventClick !== CalendarWidget.prototype.onEventClick; }
1629
1631
  async selectDate(date) { if (isDate(date) && this.datesClickable())
1630
1632
  await this.onDateClick(date); }
1631
- /** L'identifiant vient du navigateur : l'événement est recherché parmi ceux de la période affichée, jamais cru sur parole. */
1633
+ /** The id comes from the browser: the event is looked up among those of the displayed period, never trusted as-is. */
1632
1634
  async selectEvent(id) {
1633
1635
  if ((typeof id !== 'string' && typeof id !== 'number') || !this.eventsClickable())
1634
1636
  return;
@@ -1655,20 +1657,20 @@ export class CalendarWidget extends Widget {
1655
1657
  }
1656
1658
  }
1657
1659
  /**
1658
- * Page calendrier (propre à Navette) : le même calendrier que `CalendarWidget`, sur une page entière, avec les actions de
1659
- * Filament — donc des modales, des formulaires, des confirmations, des panneaux latéraux.
1660
+ * Calendar page: the same calendar as `CalendarWidget`, on a full page, with actions — so modals, forms,
1661
+ * confirmations and slide-overs.
1660
1662
  *
1661
1663
  * class Planning extends CalendarPage {
1662
1664
  * static slug = 'planning';
1663
1665
  * async getEvents({ start, end }) { return db('shifts').where('day', '>=', start).where('day', '<=', end)… }
1664
- * // Clic sur un jour : une modale avec un formulaire, prérempli avec la date.
1666
+ * // Day click: a modal with a form, prefilled with the date.
1665
1667
  * dateAction() {
1666
1668
  * return Action.make('createShift').modalHeading('New shift')
1667
1669
  * .schema([Select.make('employee_id').options(…).required(), DatePicker.make('day').required()])
1668
1670
  * .fillForm(({ arguments: args }) => ({ day: CalendarPage.dateOf(args) }))
1669
1671
  * .action(async ({ data }) => { await db('shifts').insert(data); Notification.make().success().title('Saved').send(); });
1670
1672
  * }
1671
- * // Clic sur un événement : une autre action ; l'événement est relu côté serveur.
1673
+ * // Event click: another action; the event is reloaded server-side.
1672
1674
  * eventAction() {
1673
1675
  * return Action.make('editShift').schema([…])
1674
1676
  * .fillForm(async ({ livewire, arguments: args }) => (await livewire.findEvent(args.event))?.extra ?? {})
@@ -1676,10 +1678,10 @@ export class CalendarWidget extends Widget {
1676
1678
  * }
1677
1679
  * }
1678
1680
  *
1679
- * Trois façons de réagir à un clic, au choix : une **adresse** sur l'événement (`url` : page dédiée), une **action** (modale),
1680
- * ou du **code libre** en redéfinissant `onDateClick()` / `onEventClick()` (rediriger, notifier, écrire en base, monter l'action
1681
- * de son choix avec `this.mountAction(nom, arguments)`). `getHeaderActions()` ajoute des boutons en tête de page, et
1682
- * `getCalendarActions()` déclare d'autres actions montables depuis votre propre rendu (`renderEvent`, `renderToolbarEnd`).
1681
+ * Three ways to handle a click: a **URL** on the event (`url`: dedicated page), an **action** (modal),
1682
+ * or **custom code** by overriding `onDateClick()` / `onEventClick()` (redirect, notify, write to the database, mount any
1683
+ * action with `this.mountAction(name, arguments)`). `getHeaderActions()` adds buttons to the page header, and
1684
+ * `getCalendarActions()` declares other actions mountable from your own rendering (`renderEvent`, `renderToolbarEnd`).
1683
1685
  */
1684
1686
  export class CalendarPage extends Page {
1685
1687
  static callable = ['mountAction', 'unmountAction', 'callMountedAction', 'callSchemaComponentMethod', 'previousPeriod', 'nextPeriod', 'goToToday', 'setView', 'selectDate', 'selectEvent', 'showDay'];
@@ -1698,21 +1700,21 @@ export class CalendarPage extends Page {
1698
1700
  renderToolbarEnd() { return ''; }
1699
1701
  getExtraClasses() { return []; }
1700
1702
  getStyle() { return {}; }
1701
- /** Action ouverte au clic sur un jour ; elle reçoit `arguments.date`. Nul : les jours ne sont pas cliquables (sauf `onDateClick` redéfini). */
1703
+ /** Action opened on day click; it receives `arguments.date`. `null`: days are not clickable (unless `onDateClick` is overridden). */
1702
1704
  dateAction() { return null; }
1703
- /** Action ouverte au clic sur un événement sans adresse ; elle reçoit `arguments.event` (identifiant, à relire avec `findEvent`). */
1705
+ /** Action opened on click on an event without a URL; it receives `arguments.event` (an id, to resolve with `findEvent`). */
1704
1706
  eventAction() { return null; }
1705
- /** Autres actions montables depuis votre propre HTML : `wire:click="mountAction('nom', { … })"`. */
1707
+ /** Other actions mountable from your own HTML: `wire:click="mountAction('name', { … })"`. */
1706
1708
  getCalendarActions() { return []; }
1707
- /** Par défaut : ouvre `dateAction()`. À redéfinir pour faire autre chose (rediriger vers une page de création, par exemple). */
1709
+ /** Opens `dateAction()` by default. Override to do something else (e.g. redirect to a create page). */
1708
1710
  async onDateClick(date) { const action = this.dateAction(); if (action)
1709
1711
  await this.mountAction(action.getName(), { date }); }
1710
- /** Par défaut : ouvre `eventAction()`. L'événement a été retrouvé côté serveur avant l'appel. */
1712
+ /** Opens `eventAction()` by default. The event has been resolved server-side before the call. */
1711
1713
  async onEventClick(event) { const action = this.eventAction(); if (action)
1712
1714
  await this.mountAction(action.getName(), { event: String(event.id) }); }
1713
- /** Date d'un clic, relue dans les arguments d'une action : ils viennent du navigateur, seule une vraie date est rendue. */
1715
+ /** Clicked date, read from an action's arguments. They come from the browser: only a valid date is returned. */
1714
1716
  static dateOf(args) { return isDate(args?.date) ? args.date : null; }
1715
- /** Événement de la période affichée portant cet identifiant, ou nul : ne jamais agir sur un identifiant reçu sans passer par ici. */
1717
+ /** Event of the displayed period with this id, or `null`. Never act on a received id without going through this. */
1716
1718
  async findEvent(id) {
1717
1719
  if (typeof id !== 'string' && typeof id !== 'number')
1718
1720
  return null;
@@ -1765,11 +1767,11 @@ export class CalendarPage extends Page {
1765
1767
  return widgetSection(calendar.replace('<div class="nv-cal ', `<div${style ? ` style="${e(style)}"` : ''} class="nv-cal ${extra.length ? `${extra.join(' ')} ` : ''}`));
1766
1768
  }
1767
1769
  }
1768
- /** Section sans en-tête des widgets fournis (port de `<x-filament::section>`). */
1770
+ /** Header-less section used by the built-in widgets. */
1769
1771
  const widgetSection = (content) => `<section x-data="{
1770
1772
  isCollapsed: false ,
1771
1773
  }" class="fi-section"><div class="fi-section-content-ctn"><div class="fi-section-content">${content}</div></div></section>`;
1772
- /** Compte de l'utilisateur connecté : avatar, nom, déconnexion (port de Filament\Widgets\AccountWidget). */
1774
+ /** Signed-in user's account: avatar, name, logout. */
1773
1775
  export class AccountWidget extends Widget {
1774
1776
  static isLazy = false;
1775
1777
  render() {
@@ -1779,7 +1781,7 @@ export class AccountWidget extends Widget {
1779
1781
  return this.wrap(widgetSection(`<img alt="${e(t('filament-panels::layout.avatar.alt', { name: user.name }))}" class="fi-avatar fi-circular fi-size-lg fi-user-avatar" src="${e(panel.getUserAvatarUrl(user))}" loading="lazy" /><div class="fi-account-widget-main"><h2 class="fi-account-widget-heading">${e(t('filament-panels::widgets/account-widget.welcome'))}</h2><p class="fi-account-widget-user-name">${e(user.name)}</p></div><form action="${e(panel.getLogoutUrl())}" method="post" class="fi-account-widget-logout-form"><input type="hidden" name="_token" value="${e(csrfToken(req, res))}" autocomplete="off"><button class="fi-icon-btn fi-size-md" title="${e(logout)}" aria-label="${e(logout)}" type="submit" ${loading}>${icon('m-arrow-left-end-on-rectangle')}</button><button class="fi-btn fi-size-md fi-labeled-from-sm" type="submit" ${loading}>${icon('m-arrow-left-end-on-rectangle')}${e(logout)}</button></form>`), ['fi-account-widget']);
1780
1782
  }
1781
1783
  }
1782
- /** Version de Navette et liens utiles (pendant de Filament\Widgets\FilamentInfoWidget, dont il reprend le balisage et les classes). */
1784
+ /** Navette version and useful links. */
1783
1785
  export class NavetteInfoWidget extends Widget {
1784
1786
  static isLazy = false;
1785
1787
  static homeUrl = 'https://github.com/NavetteJS';
@@ -1793,9 +1795,9 @@ export class NavetteInfoWidget extends Widget {
1793
1795
  return this.wrap(widgetSection(`<div class="fi-filament-info-widget-main"><a href="${e(WidgetClass.homeUrl)}" rel="noopener noreferrer" target="_blank"><svg aria-label="Navette" fill="currentColor" role="img" viewBox="0 0 96 24" xmlns="http://www.w3.org/2000/svg" class="fi-filament-info-widget-logo"><text x="0" y="19" font-size="22" font-weight="700" letter-spacing="-0.5">Navette</text></svg></a><p class="fi-filament-info-widget-version">v${e(WidgetClass.version)}</p></div><div class="fi-filament-info-widget-links">${link(WidgetClass.documentationUrl, icon('m-book-open'), t('filament-panels::widgets/filament-info-widget.actions.open_documentation.label'))}${link(WidgetClass.githubUrl, github, t('filament-panels::widgets/filament-info-widget.actions.open_github.label'))}</div>`), ['fi-filament-info-widget']);
1794
1796
  }
1795
1797
  }
1796
- /** Propriétés de table qu'une page de liste expose à ses widgets (port de InteractsWithPageTable / ExposesTableToWidgets). */
1798
+ /** Table properties a list page exposes to its widgets. */
1797
1799
  const PAGE_TABLE_PROPERTIES = ['activeTab', 'paginators', 'tableColumnSearches', 'tableFilters', 'tableRecordsPerPage', 'tableSearch', 'tableSort'];
1798
- /** Une statistique d'un StatsOverviewWidget (port de Stat). */
1800
+ /** One stat of a StatsOverviewWidget. */
1799
1801
  export class Stat {
1800
1802
  _description = null;
1801
1803
  _descriptionIcon = null;
@@ -1810,10 +1812,10 @@ export class Stat {
1810
1812
  static make(label, value) { return new Stat(label, value); }
1811
1813
  description(text) { this._description = text; return this; }
1812
1814
  descriptionIcon(name, position = 'after') { this._descriptionIcon = name; this._descriptionIconPosition = position; return this; }
1813
- /** Couleur de la description et de la courbe. */
1815
+ /** Color of the description and chart. */
1814
1816
  color(color) { this._color = color; return this; }
1815
1817
  icon(name) { this._icon = name; return this; }
1816
- /** Courbe de tendance : une liste de valeurs, ou des valeurs par libellé. */
1818
+ /** Trend chart: a list of values, or values keyed by label. */
1817
1819
  chart(values) { this._chart = values; return this; }
1818
1820
  url(url) { this._url = url; return this; }
1819
1821
  hasChart() { return this._chart !== null; }
@@ -1835,18 +1837,18 @@ export class Stat {
1835
1837
  return `<${tag}${this._url ? ` href="${e(this._url)}"` : ''} class="fi-wi-stats-overview-stat"><div class="fi-wi-stats-overview-stat-content"><div class="fi-wi-stats-overview-stat-label-ctn">${this._icon ? icon(this._icon) : ''}<span class="fi-wi-stats-overview-stat-label">${e(this.label)}</span></div>${filled ? `<div class="fi-wi-stats-overview-stat-value">${e(String(this.value))}</div>` : '<div class="fi-wi-stats-overview-stat-placeholder"></div>'}${description}</div>${chart}</${tag}>`;
1836
1838
  }
1837
1839
  }
1838
- /** Rangée de statistiques (port de StatsOverviewWidget). */
1840
+ /** A row of stats. */
1839
1841
  export class StatsOverviewWidget extends Widget {
1840
1842
  static columnSpan = 'full';
1841
- /** Intervalle de rafraîchissement (`'5s'` dans Filament par défaut), ou null pour ne pas rafraîchir. */
1843
+ /** Polling interval, or `null` to disable polling. */
1842
1844
  static pollingInterval = '5s';
1843
- /** Colonnes de la rangée : comme Filament, trois, ou quatre quand le nombre de statistiques s'y prête. */
1845
+ /** Row columns: three, or four when the number of stats fits better. */
1844
1846
  getColumns(count) { return count < 3 ? 3 : count % 3 !== 1 ? 3 : 4; }
1845
1847
  async render() {
1846
1848
  const stats = await this.getStats();
1847
1849
  const columns = this.getColumns(stats.length);
1848
1850
  const anonymous = `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path: '', containerPath: '' }))})"`;
1849
- // Comme Filament : une statistique à courbe reçoit un identifiant (`stats-overview-stat-<n>`), donc une clé de composant ; les autres restent anonymes.
1851
+ // A stat with a chart gets an id (`stats-overview-stat-<n>`), hence a component key; the others stay anonymous.
1850
1852
  const cells = stats.map((stat, index) => {
1851
1853
  if (!stat.hasChart())
1852
1854
  return `<div ${anonymous} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${stat.toHtml(this.panel, `${this.id}.stats.${index}`)}</div></div>`;
@@ -1861,41 +1863,41 @@ export class StatsOverviewWidget extends Widget {
1861
1863
  }
1862
1864
  }
1863
1865
  /**
1864
- * Graphique Chart.js (port de Filament\Widgets\ChartWidget). Une sous-classe donne `getType()` (`'line'`, `'bar'`, `'pie'`,
1865
- * `'doughnut'`, `'radar'`, `'polarArea'`, `'scatter'`, `'bubble'`) et `getData()`. Le rendu est fait dans le navigateur par le
1866
- * composant `chart.js` de Filament ; le serveur n'envoie de nouvelles données que si elles ont changé (somme de contrôle).
1866
+ * Chart.js chart. A subclass provides `getType()` (`'line'`, `'bar'`, `'pie'`, `'doughnut'`, `'radar'`, `'polarArea'`,
1867
+ * `'scatter'`, `'bubble'`) and `getData()`. Rendering happens in the browser; the server only sends new data when it
1868
+ * has changed (checksum).
1867
1869
  */
1868
1870
  export class ChartWidget extends Widget {
1869
1871
  static callable = ['updateChartData'];
1870
1872
  static locked = ['dataChecksum'];
1871
- /** Intervalle de rafraîchissement (`'5s'` par défaut dans Filament), ou null pour ne pas rafraîchir. */
1873
+ /** Polling interval, or `null` to disable polling. */
1872
1874
  static pollingInterval = '5s';
1873
1875
  static heading = null;
1874
1876
  static description = null;
1875
- /** Couleur des séries qui n'en précisent pas. */
1877
+ /** Color of datasets that do not set one. */
1876
1878
  static color = 'primary';
1877
- /** Hauteur maximale du graphique (`'300px'`). */
1879
+ /** Maximum chart height (`'300px'`). */
1878
1880
  static maxHeight = null;
1879
1881
  static isCollapsible = false;
1880
1882
  dataChecksum = null;
1881
- /** Filtre choisi, parmi `getFilters()`. */
1883
+ /** Selected filter, one of `getFilters()`. */
1882
1884
  filter = null;
1883
1885
  _cachedData = null;
1884
1886
  getData() { return {}; }
1885
- /** Options de Chart.js. */
1887
+ /** Chart.js options. */
1886
1888
  getOptions() { return null; }
1887
- /** Filtres proposés dans l'en-tête : valeur vers libellé. */
1889
+ /** Filters offered in the header: value → label. */
1888
1890
  getFilters() { return null; }
1889
1891
  getHeading() { return this.constructor.heading; }
1890
1892
  getDescription() { return this.constructor.description; }
1891
1893
  getColor() { return this.constructor.color; }
1892
1894
  getMaxHeight() { return this.constructor.maxHeight; }
1893
- /** Texte lu par les lecteurs d'écran à la place du graphique. */
1895
+ /** Text read by screen readers instead of the chart. */
1894
1896
  getChartAssistiveContent() { return null; }
1895
1897
  async getCachedData() { return (this._cachedData ??= await this.getData()); }
1896
1898
  async generateDataChecksum() { return createHash('md5').update(JSON.stringify(await this.getCachedData())).digest('hex'); }
1897
1899
  async mount() { this.dataChecksum = await this.generateDataChecksum(); }
1898
- /** Appelé par le rafraîchissement périodique et avant chaque rendu : n'émet les données que si elles ont changé. */
1900
+ /** Called by polling and before each render: only emits data when it has changed. */
1899
1901
  async updateChartData() {
1900
1902
  const checksum = await this.generateDataChecksum();
1901
1903
  if (checksum === this.dataChecksum)
@@ -1940,13 +1942,13 @@ export class ChartWidget extends Widget {
1940
1942
  }
1941
1943
  }
1942
1944
  /**
1943
- * Table dans un widget (port de Filament\Widgets\TableWidget) : recherche, tri, pagination simple (précédent / suivant).
1944
- * Une sous-classe donne `table(table)` et `getDataAdapterForTable()`. Pas d'actions ni de filtres pour l'instant.
1945
+ * Table in a widget: search, sort, simple pagination (previous / next).
1946
+ * A subclass provides `table(table)` and `getDataAdapterForTable()`. No actions or filters yet.
1945
1947
  */
1946
1948
  export class TableWidget extends Widget {
1947
1949
  static columnSpan = 'full';
1948
1950
  static callable = ['sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableColumnManager', 'resetTableColumnManager'];
1949
- /** Titre de la table ; par défaut tiré du nom de la classe (`FlaggedOrders` : « Flagged Orders »). */
1951
+ /** Table heading; derived from the class name by default (`FlaggedOrders` → "Flagged Orders"). */
1950
1952
  static heading = null;
1951
1953
  tableSearch = '';
1952
1954
  tableSort = null;
@@ -1984,14 +1986,14 @@ export class TableWidget extends Widget {
1984
1986
  })" style="height: 0"></div>` })}${renderHook(WidgetsRenderHook.TABLE_WIDGET_END, scopes)}`, ['fi-wi-table']);
1985
1987
  }
1986
1988
  }
1987
- /** Widgets qui reçoivent les filtres du tableau de bord (port de InteractsWithPageFilters) : `static reactive = PAGE_FILTERS` et une propriété `pageFilters`. */
1989
+ /** For widgets that receive the dashboard filters: `static reactive = PAGE_FILTERS` plus a `pageFilters` property. */
1988
1990
  export const PAGE_FILTERS = ['pageFilters'];
1989
1991
  /**
1990
- * Widget de statistiques lié à la table d'une page de liste (port de InteractsWithPageTable) : il reçoit l'état de la table
1991
- * (recherche, filtres, onglet…) et peut agréger exactement les enregistrements qu'elle affiche.
1992
+ * Stats widget bound to a list page's table: it receives the table state (search, filters, tab…)
1993
+ * and can aggregate exactly the records the table displays.
1992
1994
  */
1993
1995
  export class PageTableStatsWidget extends StatsOverviewWidget {
1994
- /** Page de liste dont la table est observée (équivalent de getTablePage()). */
1996
+ /** List page whose table is observed. */
1995
1997
  static tablePage;
1996
1998
  static reactive = PAGE_TABLE_PROPERTIES;
1997
1999
  activeTab = null;
@@ -2001,19 +2003,19 @@ export class PageTableStatsWidget extends StatsOverviewWidget {
2001
2003
  tableRecordsPerPage = 10;
2002
2004
  tableSearch = '';
2003
2005
  tableSort = null;
2004
- /** Agrégats sur la requête courante de la table. L'état reçu passe par la même validation que sur la page. */
2006
+ /** Aggregates over the table's current query. The received state goes through the same validation as on the page. */
2005
2007
  async aggregatePageTable(columns = {}, modify = null) {
2006
2008
  const PageClass = this.constructor.tablePage();
2007
2009
  const page = new PageClass();
2008
2010
  for (const property of PAGE_TABLE_PROPERTIES)
2009
2011
  page[property] = this[property];
2010
2012
  const query = getTableQuery(page.getTable(), page);
2011
- // Restriction supplémentaire propre à une statistique (« parmi les lignes affichées, celles qui… »).
2013
+ // Extra restriction for one stat ("among the displayed rows, those that…").
2012
2014
  if (modify)
2013
2015
  (query.modifiers ??= []).push(modify);
2014
2016
  return PageClass.resource.adapter.aggregate(query, columns);
2015
2017
  }
2016
- /** Tendance mensuelle sur la requête courante de la table (voir `ObjectionAdapter.trendPerMonth`). */
2018
+ /** Monthly trend over the table's current query (see `ObjectionAdapter.trendPerMonth`). */
2017
2019
  async trendPageTable(options) {
2018
2020
  const PageClass = this.constructor.tablePage();
2019
2021
  const page = new PageClass();
@@ -2022,12 +2024,12 @@ export class PageTableStatsWidget extends StatsOverviewWidget {
2022
2024
  return PageClass.resource.adapter.trendPerMonth(getTableQuery(page.getTable(), page), options);
2023
2025
  }
2024
2026
  }
2025
- /** Composant de schéma qui intègre la table du composant hôte (port de EmbeddedTable). */
2027
+ /** Schema component that embeds the host component's table. */
2026
2028
  export class EmbeddedTable extends SchemaComponent {
2027
2029
  static make() { return new EmbeddedTable(); }
2028
2030
  async toHtml() { return this.livewire.renderTable(); }
2029
2031
  }
2030
- /** Onglets des relation managers d'une page d'édition : seul le manager de l'onglet actif est monté, en composant enfant « lazy » (port de RelationManagers + Tabs). */
2032
+ /** Relation manager tabs of an edit page: only the active tab's manager is mounted, as a lazy child component. */
2031
2033
  class RelationManagerTabs extends SchemaComponent {
2032
2034
  constructor() { super(); this._keyName = 'relationManagerTabs'; }
2033
2035
  getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
@@ -2045,8 +2047,9 @@ class RelationManagerTabs extends SchemaComponent {
2045
2047
  for (const [index, manager] of managers.entries()) {
2046
2048
  let content = '';
2047
2049
  if (index === active) {
2048
- const childKey = `${key}.${index}.${manager.componentName}`;
2049
- const child = await page.mountChild(manager.componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2050
+ const componentName = page._runtime.nameOf(manager);
2051
+ const childKey = `${key}.${index}.${componentName}`;
2052
+ const child = await page.mountChild(componentName, { ownerRecord: page.recordKey }, `${id}.${childKey}`, { lazy: true });
2050
2053
  content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div wire:partial="schema-component::${e(childKey)}" x-data="filamentSchemaComponent(${e(js({ key: childKey, exposedMethods: [], path: '', containerPath: '' }))})" wire:key="${e(`${id}.${childKey}`)}" class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component">${child}</div></div></div>`;
2051
2054
  }
2052
2055
  panels.push(`<div aria-labelledby="${tabId(index)}" id="${key}.${index}" role="tabpanel" tabindex="0" wire:key="${id}.${key}.${index}.container" x-show="true" x-bind:class="{ 'fi-active': isTabSelected('${index}') }" x-on:expand="revealTab('${index}')" x-data="filamentSchemaComponent(${e(js({ key: `${key}.${index}`, exposedMethods: [], path: '', containerPath: '' }))})" class="fi-sc-tabs-tab">${content}</div>`);
@@ -2062,7 +2065,7 @@ class RelationManagerTabs extends SchemaComponent {
2062
2065
  })" x-load x-load-src="${e(page.panel.assetUrl('js/filament/schemas/components/tabs.js'))}" wire:ignore.self id="${key}" wire:key="${id}.${key}.container" class="fi-sc-tabs"><input type="hidden" x-ref="tabsData" data-active-tab="${active}" /><div x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="tablist" x-bind:role="hasVisibleTabHeaders ? 'tablist' : 'group'" aria-label="${e(t('filament::components/tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeRelationManager" class="fi-sc-tabs-tablist">${tabs.join('')}</div></div>${panels.join('')}</div>`;
2063
2066
  }
2064
2067
  }
2065
- /** Onglet d'une page de liste : restreint la requête de la table et peut afficher un compteur (port de Tab). */
2068
+ /** List page tab: restricts the table query and can show a badge. */
2066
2069
  export class Tab {
2067
2070
  _label;
2068
2071
  _badge = null;
@@ -2071,7 +2074,7 @@ export class Tab {
2071
2074
  _query = null;
2072
2075
  constructor(label = null) { this._label = label; }
2073
2076
  static make(label = null) { return new Tab(label); }
2074
- /** Compteur, calculé après l'affichage de la page (comme `lazyLoadedBadge()`). */
2077
+ /** Badge computed after the page is displayed. */
2075
2078
  badge(resolver) { this._badge = resolver; return this; }
2076
2079
  badgeColor(color) { this._badgeColor = color; this._hasBadgeColor = true; return this; }
2077
2080
  query(modifier) { this._query = modifier; return this; }
@@ -2079,16 +2082,16 @@ export class Tab {
2079
2082
  hasBadge() { return this._badge !== null; }
2080
2083
  async getBadge() { return this._badge ? this._badge() : null; }
2081
2084
  getBadgeColor() { return this._badgeColor; }
2082
- /** Faux tant que `badgeColor()` n'a pas été appelé : un badge chargé après coup ne reçoit alors aucune classe de couleur, comme dans Filament. */
2085
+ /** False until `badgeColor()` is called: a lazy-loaded badge then gets no color class. */
2083
2086
  hasBadgeColor() { return this._hasBadgeColor; }
2084
2087
  getQuery() { return this._query; }
2085
2088
  }
2086
- /** Onglets de la page de liste (port de ResourceTabs) : masqués quand la page n'en déclare pas. */
2089
+ /** List page tabs: hidden when the page declares none. */
2087
2090
  class ResourceTabs extends SchemaComponent {
2088
2091
  constructor() { super(); this._keyName = 'resourceTabs'; this._hidden = ({ livewire }) => Object.keys(livewire.getTabs()).length === 0; }
2089
2092
  getExposedMethods() { return ['getLazyLoadedTabBadges', 'getDeferredTabBadges']; }
2090
2093
  getDeferredTabBadges() { return {}; }
2091
- /** Compteurs des onglets, demandés par le navigateur une fois la page affichée. */
2094
+ /** Tab badges, requested by the browser once the page is displayed. */
2092
2095
  async getLazyLoadedTabBadges() {
2093
2096
  const page = this.livewire;
2094
2097
  const badges = {};
@@ -2153,7 +2156,7 @@ class ResourceTabs extends SchemaComponent {
2153
2156
  }" x-ref="tabsHeader" class="fi-tabs"><div x-ref="tablist" role="group" aria-label="${e(t('filament-panels::resources/pages/list-records.tabs.label'))}" x-on:keydown="handleKeydown($event)" data-prevent-disabled-clicks wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" wire:target="activeTab" class="fi-sc-tabs-tablist">${buttons.join('')}</div></div></div>`;
2154
2157
  }
2155
2158
  }
2156
- /** Page de liste d'une resource : état de table et méthodes appelées par le navigateur (port de ListRecords + InteractsWithTable). */
2159
+ /** Resource list page: table state and methods called by the browser. */
2157
2160
  export class ListRecords extends Page {
2158
2161
  static callable = ['updateTableColumnState', 'sortTable', 'gotoPage', 'nextPage', 'previousPage', 'applyTableFilters', 'resetTableFiltersForm', 'removeTableFilters', 'removeTableFilter', 'applyTableColumnManager', 'resetTableColumnManager', 'toggleTableReordering', 'reorderTable'];
2159
2162
  static resource;
@@ -2162,24 +2165,24 @@ export class ListRecords extends Page {
2162
2165
  tableColumnSearches = {};
2163
2166
  tableRecordsPerPage = 10;
2164
2167
  paginators = { page: 1 };
2165
- // Sélection : tenue par le navigateur, envoyée avec l'appel de l'action groupée (port de CanSelectRecords).
2168
+ // Selection: kept by the browser, sent along with the bulk action call.
2166
2169
  selectedTableRecords = [];
2167
2170
  deselectedTableRecords = [];
2168
2171
  isTrackingDeselectedTableRecords = false;
2169
- // Filtres : `tableDeferredFilters` est l'état du formulaire, `tableFilters` l'état appliqué (filtres différés, comme Filament par défaut).
2172
+ // Filters: `tableDeferredFilters` is the form state, `tableFilters` the applied state (deferred filters by default).
2170
2173
  tableFilters = {};
2171
2174
  tableDeferredFilters = {};
2172
2175
  tableColumns = [];
2173
2176
  isTableReordering = false;
2174
- /** Regroupement courant de la table (`identifiant:direction`), choisi dans la barre d'outils. */
2177
+ /** Current table grouping (`id:direction`), chosen in the toolbar. */
2175
2178
  tableGrouping = null;
2176
2179
  updatedTableGrouping() { this.paginators.page = 1; }
2177
- /** Entre dans le mode « tri manuel » ou en sort (port de toggleTableReordering). */
2180
+ /** Toggles manual reordering mode. */
2178
2181
  async toggleTableReordering() { if (this.getTable().getReorderColumn() !== null && (await canOn(this, 'reorder'))) {
2179
2182
  this.isTableReordering = !this.isTableReordering;
2180
2183
  this.deselectAllTableRecords();
2181
2184
  } }
2182
- /** Enregistre le nouvel ordre : seules les clés de la table sont retenues, chacune reçoit son rang (port de reorderTable). */
2185
+ /** Saves the new order: only keys belonging to the table are kept, each gets its position. */
2183
2186
  async reorderTable(order, _draggedRecordKey = null) {
2184
2187
  const column = this.getTable().getReorderColumn();
2185
2188
  if (column === null || !this.isTableReordering || !Array.isArray(order) || order.length > REORDER_LIMIT || !(await canOn(this, 'reorder')))
@@ -2196,9 +2199,9 @@ export class ListRecords extends Page {
2196
2199
  this.tableFilters = table.getDefaultFilterState();
2197
2200
  this.tableDeferredFilters = table.getDefaultFilterState();
2198
2201
  this.tableColumns = table.getDefaultColumnState();
2199
- // Groupe par défaut présélectionné, comme Filament.
2202
+ // Preselect the default group.
2200
2203
  initializeTableGrouping(table, this);
2201
- // `?tableAction=view&tableActionRecord=12` (lien d'un résultat de recherche globale) : l'action de ligne s'ouvre à l'arrivée, avec les contrôles habituels.
2204
+ // `?tableAction=view&tableActionRecord=12` (global search result link): the row action opens on arrival, with the usual checks.
2202
2205
  const query = (this._ctx?.req?.query ?? {});
2203
2206
  if (typeof query.tableAction === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(query.tableAction) && typeof query.tableActionRecord === 'string' && query.tableActionRecord.length <= 64) {
2204
2207
  try {
@@ -2210,13 +2213,13 @@ export class ListRecords extends Page {
2210
2213
  }
2211
2214
  }
2212
2215
  }
2213
- /** Formulaire des filtres : un groupe par filtre, lié à `tableDeferredFilters.<filtre>`. */
2216
+ /** Filters form: one group per filter, bound to `tableDeferredFilters.<filter>`. */
2214
2217
  tableFiltersFormSchema(schema) {
2215
2218
  const table = this.getTable();
2216
- // Au-dessus du contenu, les filtres se répartissent sur plusieurs colonnes (comme Filament) ; dans le menu déroulant, sur une seule.
2219
+ // Above the content, filters spread over several columns; in the dropdown, a single one.
2217
2220
  const columns = table.getFiltersLayout() === 'above-content-collapsible' ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
2218
2221
  return schema.statePath('tableDeferredFilters').columns(columns).partiallyRendered().components(table.getFilters().map((filter) => {
2219
- // Un filtre peut fournir son propre formulaire (constructeur de requêtes) ; sinon c'est un sélecteur.
2222
+ // A filter may provide its own form (query builder); otherwise it is a select.
2220
2223
  const custom = filter.getFormComponents?.();
2221
2224
  if (custom)
2222
2225
  return StatefulGroup.make(filter.name, custom).columnSpan(filter.getColumnSpan?.() ?? { default: 1, lg: 1 });
@@ -2225,7 +2228,7 @@ export class ListRecords extends Page {
2225
2228
  }));
2226
2229
  }
2227
2230
  applyTableFilters() {
2228
- // Seuls les filtres déclarés par la table passent dans l'état appliqué : chacun nettoie son état, ou à défaut seules ses clés connues sont reprises.
2231
+ // Only filters declared by the table reach the applied state: each sanitizes its own state, or else only its known keys are kept.
2229
2232
  this.tableFilters = Object.fromEntries(this.getTable().getFilters().map((filter) => {
2230
2233
  const deferred = this.tableDeferredFilters?.[filter.name];
2231
2234
  if (filter.sanitizeState)
@@ -2234,7 +2237,7 @@ export class ListRecords extends Page {
2234
2237
  }));
2235
2238
  this.paginators.page = 1;
2236
2239
  }
2237
- /** Retrait d'un indicateur de filtre actif : le champ visé est effacé de l'état appliqué et du formulaire. */
2240
+ /** Removes an active filter indicator: the field is cleared from both the applied state and the form. */
2238
2241
  removeTableFilter(filterName, field = null) {
2239
2242
  const filter = this.getTable().getFilters().find((candidate) => candidate.name === filterName);
2240
2243
  if (!filter)
@@ -2245,7 +2248,7 @@ export class ListRecords extends Page {
2245
2248
  this.tableDeferredFilters = { ...this.tableDeferredFilters, [filter.name]: JSON.parse(JSON.stringify(next)) };
2246
2249
  this.paginators.page = 1;
2247
2250
  }
2248
- /** Champ réactif du formulaire des filtres (changement d'opérateur d'une règle) : exécute son `afterStateUpdated`. */
2251
+ /** Live field in the filters form (e.g. a rule's operator changed): runs its `afterStateUpdated`. */
2249
2252
  async updatedTableDeferredFilters(_value, key) {
2250
2253
  if (!key)
2251
2254
  return;
@@ -2274,10 +2277,10 @@ export class ListRecords extends Page {
2274
2277
  applyTableColumnManager(state = null, _wasReordered = false) { if (state)
2275
2278
  this.tableColumns = sanitizeColumnState(this.getTable(), state); }
2276
2279
  resetTableColumnManager() { this.tableColumns = this.getTable().getDefaultColumnState(); }
2277
- // --- onglets de liste : la clé vide est l'onglet par défaut (`null` côté navigateur)
2280
+ // --- list tabs: the empty key is the default tab (`null` in the browser)
2278
2281
  activeTab = null;
2279
2282
  getTabs() { return this.resource.tabs?.() ?? {}; }
2280
- /** Onglet actif, ramené à une clé déclarée (une valeur inconnue envoyée par le navigateur vaut l'onglet par défaut). */
2283
+ /** Active tab, coerced to a declared key (an unknown value from the browser means the default tab). */
2281
2284
  getActiveTabKey() {
2282
2285
  const tabs = this.getTabs();
2283
2286
  const key = this.activeTab ?? '';
@@ -2292,11 +2295,11 @@ export class ListRecords extends Page {
2292
2295
  }
2293
2296
  get resource() { return this.constructor.resource; }
2294
2297
  getHeaderWidgets() { return this.resource.headerWidgets ?? []; }
2295
- /** Port de ExposesTableToWidgets : l'état de la table est passé aux widgets, qui peuvent refaire la même requête. */
2298
+ /** The table state is passed to widgets, which can rerun the same query. */
2296
2299
  getWidgetData() { return Object.fromEntries(PAGE_TABLE_PROPERTIES.map((property) => [property, this[property]])); }
2297
2300
  rendersActionModalsInPage() { return false; }
2298
2301
  getBulkActions() { return this.getTable().getBulkActions() ?? [DeleteBulkAction.make()]; }
2299
- /** Actions de ligne déclarées par la table, groupes dépliés. */
2302
+ /** Row actions declared by the table, with groups flattened. */
2300
2303
  getRecordActions() { return this.getTable().getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])); }
2301
2304
  getMountableActions(context) {
2302
2305
  if (typeof context.schemaComponent === 'string')
@@ -2309,7 +2312,7 @@ export class ListRecords extends Page {
2309
2312
  return this.getTable().getHeaderActions();
2310
2313
  return this.getHeaderActions();
2311
2314
  }
2312
- /** Seules les formes de contexte connues sont retenues ; la clé d'enregistrement est revérifiée en base au montage. */
2315
+ /** Only known context shapes are kept; the record key is checked again in the database on mount. */
2313
2316
  resolveMountContext(clientContext) {
2314
2317
  const c = clientContext && typeof clientContext === 'object' ? clientContext : {};
2315
2318
  if (typeof c.schemaComponent === 'string' && c.schemaComponent.length <= 200)
@@ -2326,17 +2329,17 @@ export class ListRecords extends Page {
2326
2329
  return typeof context.recordKey === 'string' ? ((await this.resource.adapter.find(context.recordKey)) ?? null) : null;
2327
2330
  }
2328
2331
  /**
2329
- * Écriture d'une cellule modifiable (port de `updateTableColumnState`). Tout vient du navigateur : la colonne doit exister, être
2330
- * modifiable et visible, l'enregistrement être dans la portée de la table et modifiable par l'utilisateur, la valeur être valide.
2332
+ * Writes an editable cell. Everything comes from the browser: the column must exist and be editable and visible, the record
2333
+ * must be in the table's scope and editable by the user, and the value must be valid.
2331
2334
  */
2332
2335
  async updateTableColumnState(columnName, recordKey, input) {
2333
- // Toutes les vérifications (colonne visible et modifiable, portée, désactivation, autorisation, validation, crochets) sont dans le paquet tables.
2336
+ // All checks (visible and editable column, scope, disabled state, authorization, validation, hooks) live in the tables package.
2334
2337
  return updateEditableColumnState(this.getTable(), this, this.getDataAdapterForTable(), columnName, recordKey, input, { authorize: (record) => canOn(this, 'update', record) });
2335
2338
  }
2336
- /** Clés sélectionnées : la liste envoyée, ou — en mode « tout sélectionner » — tous les enregistrements de la requête courante sauf ceux désélectionnés. */
2339
+ /** Selected keys: the sent list, or, in "select all" mode, every record of the current query except deselected ones. */
2337
2340
  async getSelectedRecordKeys() {
2338
2341
  const clean = (list) => (Array.isArray(list) ? list.filter((k) => typeof k === 'string' || typeof k === 'number').map(String) : []);
2339
- // La sélection vient du navigateur : elle est ramenée aux enregistrements que cette table peut réellement afficher.
2342
+ // The selection comes from the browser: it is narrowed to records this table can actually display.
2340
2343
  if (!this.isTrackingDeselectedTableRecords)
2341
2344
  return this.resource.adapter.keysInScope(clean(this.selectedTableRecords));
2342
2345
  const excluded = new Set(clean(this.deselectedTableRecords));
@@ -2351,7 +2354,7 @@ export class ListRecords extends Page {
2351
2354
  getTable() {
2352
2355
  const resource = this.resource;
2353
2356
  const table = new Table().pluralModelLabel(resource.getPluralModelLabel());
2354
- // Resource simple : la ligne ouvre l'édition en modale ; sinon elle mène à la page d'édition.
2357
+ // Simple resource: the row opens the edit modal; otherwise it links to the edit page.
2355
2358
  return resource.table(resource.isSimple ? table.recordAction('edit') : table.recordUrl((record) => resource.getUrl(resource.hasViewPage ? 'view' : 'edit', resource.adapter.getKey(record))));
2356
2359
  }
2357
2360
  async renderTable() {
@@ -2382,7 +2385,7 @@ export class ListRecords extends Page {
2382
2385
  rowActions: async (record) => resource.isSimple || table.getRecordActions().length || !(await canOn(this, 'update', record)) ? [] : [{ label: t('filament-actions::edit.single.label'), url: resource.getUrl('edit', resource.adapter.getKey(record)), icon: 'c-pencil-square', key: `edit.${createHash('md5').update(resource.adapter.getKey(record)).digest('hex')}` }],
2383
2386
  });
2384
2387
  }
2385
- /** Actions affichées dans l'en-tête de la table (aucune pour une page de liste : elles sont dans l'en-tête de page). */
2388
+ /** Table header actions (none on a list page: they go in the page header). */
2386
2389
  async renderTableHeaderActions(ctx) {
2387
2390
  const html = [];
2388
2391
  for (const action of this.getTable().getHeaderActions())
@@ -2406,7 +2409,7 @@ export class ListRecords extends Page {
2406
2409
  return [{ label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() }, { label: t('filament-panels::resources/pages/list-records.breadcrumb'), url: null }];
2407
2410
  }
2408
2411
  getHeaderActions() {
2409
- // Resource simple : création en modale ; sinon lien vers la page de création.
2412
+ // Simple resource: create in a modal; otherwise link to the create page.
2410
2413
  if (this.resource.isSimple)
2411
2414
  return [CreateAction.make(this.resource)];
2412
2415
  return [...(this.resource.listHeaderActions?.() ?? []), Action.make('create').label(t('filament-actions::create.single.label', { label: this.resource.modelLabel })).url(this.resource.getUrl('create')).authorize(({ livewire }) => canOn(livewire, 'create'))];
@@ -2417,27 +2420,27 @@ export class ListRecords extends Page {
2417
2420
  }
2418
2421
  }
2419
2422
  /**
2420
- * Table des enregistrements liés à un autre (port de RelationManager) : composant enfant de la page d'édition, chargé quand il devient visible.
2421
- * Les sous-classes déclarent `relationship`, `adapterFor()`, `table()` et `form()`.
2423
+ * Table of records related to another one: a child component of the edit page, loaded when it becomes visible.
2424
+ * Subclasses declare `relationship`, `adapterFor()`, `table()` and `form()`.
2422
2425
  */
2423
2426
  export class RelationManager extends ListRecords {
2424
2427
  static relationship = '';
2425
2428
  static modelLabel = 'record';
2426
2429
  static pluralModelLabel = null;
2427
2430
  static recordTitleAttribute = null;
2428
- /** Adaptateur limité aux enregistrements liés au propriétaire donné. */
2429
- static adapterFor(_ownerKey) { throw new Error('RelationManager.adapterFor() doit être défini.'); }
2430
- /** Politique des enregistrements liés (mêmes capacités que celle d'une resource). */
2431
+ /** Data adapter limited to records related to the given owner. */
2432
+ static adapterFor(_ownerKey) { throw new Error('RelationManager.adapterFor() must be defined.'); }
2433
+ /** Policy for related records (same abilities as a resource policy). */
2431
2434
  static policy = null;
2432
2435
  isAccessAuthorized(ctx) { return this.resource.can('viewAny', ctx.user); }
2433
2436
  static table(table) { return table; }
2434
2437
  static form(schema) { return schema; }
2435
2438
  static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
2436
- /** Clé de l'enregistrement propriétaire : fixée au montage par la page, jamais modifiable depuis le navigateur. */
2439
+ /** Owner record key: set on mount by the page, never writable from the browser. */
2437
2440
  ownerRecord = '';
2438
2441
  static locked = ['ownerRecord'];
2439
2442
  _facade = null;
2440
- /** Ce que ListRecords attend d'une resource, fourni ici par le relation manager. */
2443
+ /** What ListRecords expects from a resource, provided here by the relation manager. */
2441
2444
  get resource() {
2442
2445
  const manager = this.constructor;
2443
2446
  return (this._facade ??= {
@@ -2452,24 +2455,24 @@ export class RelationManager extends ListRecords {
2452
2455
  getTable() {
2453
2456
  const manager = this.constructor;
2454
2457
  const table = manager.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel()).heading(manager.getTitle()));
2455
- // Comme Filament : s'il existe une action de ligne « edit », cliquer la ligne l'ouvre.
2458
+ // If there is an "edit" row action, clicking the row opens it.
2456
2459
  const hasEdit = table.getRecordActions().flatMap((a) => (a instanceof ActionGroup ? a.getActions() : [a])).some((a) => a.getName() === 'edit');
2457
2460
  return hasEdit ? table.recordAction('edit') : table;
2458
2461
  }
2459
- // --- relations plusieurs-à-plusieurs : à définir par le manager qui propose Attach / Detach
2460
- /** Rattache un enregistrement existant au propriétaire. */
2461
- static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() doit être défini.'); }
2462
- /** Détache des enregistrements du propriétaire, sans les supprimer. */
2463
- static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() doit être défini.'); }
2464
- /** Enregistrements rattachables correspondant à la recherche : [clé, libellé]. */
2465
- static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() doit être défini.'); }
2466
- /** Libellé d'un enregistrement rattachable, ou null s'il ne l'est pas (sert aussi de validation). */
2467
- static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() doit être défini.'); }
2462
+ // --- many-to-many: to be defined by managers offering Attach / Detach
2463
+ /** Attaches an existing record to the owner. */
2464
+ static attach(_ownerKey, _recordKey) { throw new Error('RelationManager.attach() must be defined.'); }
2465
+ /** Detaches records from the owner without deleting them. */
2466
+ static detach(_ownerKey, _recordKeys) { throw new Error('RelationManager.detach() must be defined.'); }
2467
+ /** Attachable records matching the search: [key, label]. */
2468
+ static searchAttachable(_ownerKey, _search) { throw new Error('RelationManager.searchAttachable() must be defined.'); }
2469
+ /** Label of an attachable record, or `null` if it is not attachable (also used as validation). */
2470
+ static attachableLabel(_ownerKey, _recordKey) { throw new Error('RelationManager.attachableLabel() must be defined.'); }
2468
2471
  getHeaderActions() { return []; }
2469
2472
  placeholder() {
2470
2473
  return `<div role="status" aria-busy="true" style="height: 8rem; ;" class="fi-section fi-loading-section fi-grid-col"><span class="fi-sr-only">${e(t('filament::components/loading-section.label'))}</span></div>`;
2471
2474
  }
2472
- /** Comme Filament : un relation manager a ses propres emplacements, pas ceux de la page de liste. */
2475
+ /** A relation manager has its own render hook locations, not the list page's. */
2473
2476
  contentSchema(schema) {
2474
2477
  return schema.components([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]);
2475
2478
  }
@@ -2478,25 +2481,25 @@ export class RelationManager extends ListRecords {
2478
2481
  }
2479
2482
  }
2480
2483
  /**
2481
- * Page des enregistrements liés à un enregistrement (port de ManageRelatedRecords) : une table, avec création, consultation et
2482
- * édition en modales. Déclarée par `static relatedPages` de la resource propriétaire, elle répond sous `<slug>/{record}/<nom>`.
2484
+ * Page listing records related to a record: a table, with create, view and edit in modals.
2485
+ * Declared in the owner resource's `static relatedPages`, it is served at `<slug>/{record}/<name>`.
2483
2486
  */
2484
2487
  export class ManageRelatedRecords extends ListRecords {
2485
- /** Resource propriétaire : fixée à l'enregistrement de la page. */
2488
+ /** Owner resource: set when the page is registered. */
2486
2489
  static parentResource;
2487
2490
  static relationship = '';
2488
2491
  static modelLabel = 'record';
2489
2492
  static pluralModelLabel = null;
2490
2493
  static recordTitleAttribute = null;
2491
2494
  static policy = null;
2492
- /** Adaptateur limité aux enregistrements liés au propriétaire donné. */
2493
- static adapterFor(_ownerKey) { throw new Error('ManageRelatedRecords.adapterFor() doit être défini.'); }
2495
+ /** Data adapter limited to records related to the given owner. */
2496
+ static adapterFor(_ownerKey) { throw new Error('ManageRelatedRecords.adapterFor() must be defined.'); }
2494
2497
  static table(table) { return table; }
2495
2498
  static form(schema) { return schema; }
2496
2499
  static infolist(schema) { return schema; }
2497
2500
  static getTitle() { return this.title ?? ucfirst(this.relationship.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase()); }
2498
2501
  static getNavigationLabel() { return this.navigationLabel ?? this.getTitle(); }
2499
- /** Clé de l'enregistrement propriétaire : tirée de l'adresse au montage, jamais modifiable depuis le navigateur. */
2502
+ /** Owner record key: read from the URL on mount, never writable from the browser. */
2500
2503
  recordKey = '';
2501
2504
  static locked = ['recordKey'];
2502
2505
  _owner = null;
@@ -2507,18 +2510,18 @@ export class ManageRelatedRecords extends ListRecords {
2507
2510
  await this.hydrate();
2508
2511
  await ListRecords.prototype.mount?.call(this, params);
2509
2512
  }
2510
- /** Le propriétaire est relu et son accès vérifié à chaque requête. */
2513
+ /** The owner is reloaded and access to it checked on every request. */
2511
2514
  async hydrate() {
2512
2515
  const parent = this.related.parentResource;
2513
2516
  this._owner = await parent.adapter.find(this.recordKey);
2514
2517
  if (!this._owner)
2515
- throw new WireError(404, 'Enregistrement introuvable.');
2518
+ throw new WireError(404, 'Record not found.');
2516
2519
  if (!(await parent.can('view', this._ctx?.user ?? null, this._owner)))
2517
- throw new WireError(403, 'Accès refusé.');
2520
+ throw new WireError(403, 'Forbidden.');
2518
2521
  await ListRecords.prototype.hydrate?.call(this);
2519
2522
  }
2520
2523
  getRecordKey() { return this.recordKey; }
2521
- /** Ce que ListRecords attend d'une resource, fourni ici par la page. */
2524
+ /** What ListRecords expects from a resource, provided here by the page. */
2522
2525
  get resource() {
2523
2526
  const page = this.related;
2524
2527
  return (this._facade ??= {
@@ -2532,7 +2535,7 @@ export class ManageRelatedRecords extends ListRecords {
2532
2535
  }
2533
2536
  getTable() { return this.related.table(new Table().pluralModelLabel(this.resource.getPluralModelLabel())); }
2534
2537
  getHeaderActions() { return []; }
2535
- /** Comme Filament : le contenu de la page est un groupe (onglets éventuels, puis la table). */
2538
+ /** The page content is a group (tabs, if any, then the table). */
2536
2539
  contentSchema(schema) {
2537
2540
  return schema.components([Group.make([new ResourceTabs(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_BEFORE), EmbeddedTable.make(), RenderHook.make(PanelsRenderHook.RESOURCE_RELATION_MANAGER_AFTER)]), new RelationManagerTabs().key(null).hidden()]);
2538
2541
  }
@@ -2548,7 +2551,7 @@ export class ManageRelatedRecords extends ListRecords {
2548
2551
  }
2549
2552
  getPageClasses() { return ['fi-resource-manage-related-records-page', `fi-resource-${this.related.parentResource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
2550
2553
  }
2551
- /** Adresse de la page précédente si elle appartient à ce site, sinon la racine du site (sans barre finale, comme Laravel). */
2554
+ /** Previous page URL if it belongs to this site, otherwise the site root (without a trailing slash). */
2552
2555
  function previousUrlOf(req) {
2553
2556
  if (!req)
2554
2557
  return '';
@@ -2558,27 +2561,27 @@ function previousUrlOf(req) {
2558
2561
  if (referer.origin === origin)
2559
2562
  return `${origin}${referer.pathname === '/' ? '' : referer.pathname}${referer.search}`;
2560
2563
  }
2561
- catch { /* pas de Referer exploitable */ }
2564
+ catch { /* no usable Referer */ }
2562
2565
  return origin;
2563
2566
  }
2564
- /** Couleur représentative d'un thème (nuance 500 de sa couleur primaire), pour la pastille du menu. */
2567
+ /** A theme's representative color (shade 500 of its primary color), for the menu swatch. */
2565
2568
  function swatchOf(theme, panel) {
2566
2569
  const primary = theme.colors?.primary;
2567
2570
  return (typeof primary === 'string' ? primary : primary?.[500]) ?? panel.colors.getColor('primary')?.[500] ?? 'currentColor';
2568
2571
  }
2569
- /** Police d'un thème : sans adresse, elle est servie par Bunny Fonts comme celle du panel. */
2572
+ /** A theme's font: without a URL, it is served by Bunny Fonts like the panel font. */
2570
2573
  function fontOf(font) {
2571
2574
  return font.url ? { family: font.family, url: font.url } : { family: font.family, url: `https://fonts.bunny.net/css?family=${encodeURIComponent(font.family.toLowerCase().replace(/ /g, '-'))}:400,500,600,700&display=swap`, preconnect: 'https://fonts.bunny.net' };
2572
2575
  }
2573
2576
  function emptyToNull(data) { return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v === '' ? null : v])); }
2574
- /** Base des pages à formulaire d'une resource. */
2577
+ /** Base class for a resource's form pages. */
2575
2578
  class ResourceFormPage extends Page {
2576
- /** Champ réactif du formulaire (`live()` + `afterStateUpdated()`) : `set(nom, valeur)` écrit à côté du champ modifié. */
2579
+ /** Live form field (`live()` + `afterStateUpdated()`): `set(name, value)` writes next to the updated field. */
2577
2580
  async updatedData(_value, key) {
2578
2581
  if (!key)
2579
2582
  return;
2580
2583
  const path = `data.${key}`;
2581
- // Le champ modifié d'abord, puis ses champs parents (un Repeater recalcule un total quand une de ses lignes change), comme Filament.
2584
+ // The updated field first, then its parent fields (a Repeater recomputes a total when one of its rows changes).
2582
2585
  const fields = this.getSchema('form').getFlatComponents().filter((c) => c instanceof Field && c.hasAfterStateUpdated() && (c.getStatePath() === path || path.startsWith(`${c.getStatePath()}.`)))
2583
2586
  .sort((a, b) => b.getStatePath().length - a.getStatePath().length);
2584
2587
  for (const field of fields) {
@@ -2600,21 +2603,21 @@ class ResourceFormPage extends Page {
2600
2603
  getDataAdapter() { return this.resource.adapter; }
2601
2604
  getRecordKey() { return null; }
2602
2605
  getRecord() { return null; }
2603
- /** Deux colonnes par défaut à partir de `lg`, comme `defaultForm()` de Filament ; la resource peut en décider autrement. */
2606
+ /** Two columns from `lg` by default; the resource may decide otherwise. */
2604
2607
  formSchema(schema) {
2605
2608
  const form = this.resource.form(schema.columns(2).operation(this.getOperation()).record(this.getRecord()));
2606
2609
  const steps = this.getSteps();
2607
- // Assistant : les étapes remplacent les composants du formulaire de la resource (comme le trait HasWizard).
2610
+ // Wizard: the steps replace the resource form's components.
2608
2611
  return steps === null ? form : form.columns(null).components([this.getWizardComponent(steps)]);
2609
2612
  }
2610
2613
  /**
2611
- * Étapes d'un assistant (équivalent du trait HasWizard de Filament) : dès que la méthode en retourne, la page présente son formulaire
2612
- * étape par étape, avec « Back » / « Next » et le bouton d'enregistrement sur la dernière étape.
2614
+ * Wizard steps: as soon as this method returns some, the page shows its form step by step,
2615
+ * with "Back" / "Next" and the submit button on the last step.
2613
2616
  */
2614
2617
  getSteps() { return null; }
2615
- /** Vrai : les étapes sont accessibles dans n'importe quel ordre, sans validation au passage. */
2618
+ /** `true`: steps can be visited in any order, without validation along the way. */
2616
2619
  hasSkippableSteps() { return false; }
2617
- /** Étape affichée à l'ouverture (à partir de 1). */
2620
+ /** Step shown on open (1-based). */
2618
2621
  getStartStep() { return 1; }
2619
2622
  getWizardComponent(steps) {
2620
2623
  const [submit] = this.getFormActions();
@@ -2623,7 +2626,7 @@ class ResourceFormPage extends Page {
2623
2626
  .alpineSubmitHandler(`$wire.${this.getSubmitMethod()}()`).skippable(this.hasSkippableSteps()).contained(false);
2624
2627
  }
2625
2628
  contentSchema(schema) {
2626
- // Assistant : pas de balise <form> autour, chaque étape a la sienne et l'assistant porte les actions.
2629
+ // Wizard: no surrounding <form>; each step has its own and the wizard holds the actions.
2627
2630
  if (this.getSteps() !== null)
2628
2631
  return schema.components([EmbeddedSchema.make('form')]);
2629
2632
  return schema.components([
@@ -2631,13 +2634,13 @@ class ResourceFormPage extends Page {
2631
2634
  .footer([Actions.make(this.getFormActions()).alignment('start').key('form-actions')]),
2632
2635
  ]);
2633
2636
  }
2634
- /** Destination de « Cancel » quand le navigateur n'a pas d'historique : la liste, sauf si la page en décide autrement. */
2637
+ /** Where "Cancel" goes when the browser has no history: the list page, unless the page decides otherwise. */
2635
2638
  getCancelUrl() { return this.resource.getUrl(); }
2636
2639
  cancelAction() {
2637
2640
  return Action.make('cancel').label(t(`filament-panels::resources/pages/${this.getOperation()}-record.form.actions.cancel.label`)).color('gray')
2638
2641
  .alpineClickHandler(`document.referrer ? window.history.back() : Livewire.navigate(${js(this.getCancelUrl())})`);
2639
2642
  }
2640
- /** Valide le formulaire ; en cas d'erreur, les pose sur le composant et prévient le navigateur. */
2643
+ /** Validates the form; on error, sets the errors on the component and notifies the browser. */
2641
2644
  async validateForm() {
2642
2645
  this._errors = await validateSchema(this.getSchema('form'));
2643
2646
  if (!Object.keys(this._errors).length)
@@ -2646,7 +2649,7 @@ class ResourceFormPage extends Page {
2646
2649
  return false;
2647
2650
  }
2648
2651
  }
2649
- /** Page de création d'un enregistrement (port de CreateRecord). */
2652
+ /** Record creation page. */
2650
2653
  export class CreateRecord extends ResourceFormPage {
2651
2654
  static callable = ['create', 'createAnother'];
2652
2655
  mount() { fillSchema(this.getSchema('form'), this.data); }
@@ -2664,18 +2667,18 @@ export class CreateRecord extends ResourceFormPage {
2664
2667
  this.cancelAction(),
2665
2668
  ];
2666
2669
  }
2667
- // --- crochets de cycle de vie, à surcharger dans une sous-classe (aucun n'est appelable depuis le navigateur)
2668
- /** Transforme les données validées avant la création (ajouter l'auteur, calculer un champ…). */
2670
+ // --- lifecycle hooks, to override in a subclass (none can be called from the browser)
2671
+ /** Transforms validated data before creation (add the author, compute a field…). */
2669
2672
  mutateFormDataBeforeCreate(data) { return data; }
2670
- /** Juste avant la création ; lever une erreur l'annule. */
2673
+ /** Right before creation; throwing cancels it. */
2671
2674
  beforeCreate(_data) { }
2672
- /** La création elle-même : par défaut, l'adaptateur de la resource. */
2675
+ /** The creation itself; defaults to the resource's data adapter. */
2673
2676
  handleRecordCreation(data) { return this.getDataAdapter().create(data); }
2674
- /** Après la création (journal, envoi d'un message…). */
2677
+ /** After creation (logging, sending a message…). */
2675
2678
  afterCreate(_record) { }
2676
- /** Où aller ensuite : par défaut, la page d'édition du nouvel enregistrement. */
2679
+ /** Where to go next; defaults to the new record's edit page. */
2677
2680
  getRedirectUrl(record) { return this.resource.getUrl('edit', this.getDataAdapter().getKey(record)); }
2678
- /** Titre de la notification de succès ; `null` pour ne rien afficher. */
2681
+ /** Success notification title; `null` to show nothing. */
2679
2682
  getCreatedNotificationTitle() { return t('filament-panels::resources/pages/create-record.notifications.created.title'); }
2680
2683
  async create(another = false) {
2681
2684
  if (!(await this.validateForm()))
@@ -2696,14 +2699,14 @@ export class CreateRecord extends ResourceFormPage {
2696
2699
  }
2697
2700
  createAnother() { return this.create(true); }
2698
2701
  }
2699
- /** Action de suppression d'un enregistrement, avec confirmation (port de Filament\Actions\DeleteAction dans une page de resource). */
2702
+ /** Delete action with confirmation. */
2700
2703
  export class DeleteAction {
2701
- /** Sur une page d'édition : supprime l'enregistrement de la page puis revient à la liste. Sur une ligne de table : supprime la ligne. */
2704
+ /** On an edit page, deletes the page's record and returns to the list. On a table row, deletes the row. */
2702
2705
  static make() {
2703
2706
  return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'delete', record))
2704
2707
  .label(t('filament-actions::delete.single.label')).color('danger').keyBindings(['mod+d']).tableIcon('c-trash').groupedIcon('m-trash')
2705
2708
  .requiresConfirmation().modalIcon('o-trash')
2706
- // Masquée pour un enregistrement déjà supprimé (suppression douce).
2709
+ // Hidden for an already soft-deleted record.
2707
2710
  .hidden(({ livewire, record }) => { const target = record ?? livewire._record; return target ? livewire.getDataAdapter().isTrashed(target) : false; })
2708
2711
  .modalHeading(({ livewire, record }) => t('filament-actions::delete.single.modal.heading', { label: livewire.resource.getRecordTitle(record ?? livewire._record) }))
2709
2712
  .modalSubmitActionLabel(t('filament-actions::delete.single.modal.actions.delete.label'))
@@ -2717,7 +2720,7 @@ export class DeleteAction {
2717
2720
  });
2718
2721
  }
2719
2722
  }
2720
- /** Duplication d'une ligne de table (port de ReplicateAction) : la copie reprend les attributs de l'enregistrement, sauf sa clé, ses dates et ceux exclus. */
2723
+ /** Duplicates a table row: the copy takes the record's attributes, except its key, timestamps and excluded ones. */
2721
2724
  export class ReplicateAction {
2722
2725
  static make() {
2723
2726
  let excluded = [];
@@ -2728,7 +2731,7 @@ export class ReplicateAction {
2728
2731
  .action(async ({ livewire, record }) => {
2729
2732
  const adapter = livewire.getDataAdapter();
2730
2733
  const source = typeof record?.toJSON === 'function' ? record.toJSON() : { ...record };
2731
- // Comme Model::replicate() : ni clé primaire ni dates de création et de modification.
2734
+ // No primary key, no created/updated timestamps.
2732
2735
  for (const attribute of ['id', 'created_at', 'updated_at', ...excluded])
2733
2736
  delete source[attribute];
2734
2737
  await adapter.create(source);
@@ -2738,9 +2741,9 @@ export class ReplicateAction {
2738
2741
  return action;
2739
2742
  }
2740
2743
  }
2741
- /** Création en modale avec le formulaire de la resource (port de CreateAction). */
2744
+ /** Create in a modal with the resource form. */
2742
2745
  export class CreateAction {
2743
- /** Sans argument, la resource (ou le relation manager) est celle du composant hôte. */
2746
+ /** Without an argument, the resource (or relation manager) is the host component's. */
2744
2747
  static make(resource = null) {
2745
2748
  const of = (livewire) => resource ?? livewire.resource;
2746
2749
  return Action.make('create').authorize(({ livewire, record }) => canOn(livewire, 'create', record))
@@ -2755,9 +2758,9 @@ export class CreateAction {
2755
2758
  });
2756
2759
  }
2757
2760
  }
2758
- /** Édition en modale d'une ligne de table, avec le formulaire de la resource (port de EditAction). */
2761
+ /** Edit a table row in a modal, with the resource form. */
2759
2762
  export class EditAction {
2760
- /** Sans argument, la resource (ou le relation manager) est celle du composant hôte. */
2763
+ /** Without an argument, the resource (or relation manager) is the host component's. */
2761
2764
  static make(resource = null) {
2762
2765
  const of = (livewire) => resource ?? livewire.resource;
2763
2766
  return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record))
@@ -2773,13 +2776,13 @@ export class EditAction {
2773
2776
  });
2774
2777
  }
2775
2778
  }
2776
- /** Lien vers la page de consultation de l'enregistrement (port de ViewAction, quand la resource a une page « View »). */
2779
+ /** Link to the record's view page (when the resource has one). */
2777
2780
  export class ViewAction {
2778
2781
  static make(resource = null) {
2779
2782
  const of = (livewire) => resource ?? livewire.resource;
2780
2783
  return Action.make('view').authorize(({ livewire, record }) => canOn(livewire, 'view', record))
2781
2784
  .label(t('filament-actions::view.single.label')).color('gray').tableIcon('c-eye').groupedIcon('m-eye')
2782
- // Sans page « View » (resource simple, enregistrements liés) : l'infolist s'ouvre en modale, comme Filament.
2785
+ // Without a view page (simple resource, related records): the infolist opens in a modal.
2783
2786
  .url(({ livewire, record }) => (record && of(livewire).hasViewPage ? of(livewire).getUrl('view', of(livewire).adapter.getKey(record)) : null))
2784
2787
  .modalHeading(({ livewire, record }) => t('filament-actions::view.single.modal.heading', { label: of(livewire).getRecordTitle(record) }))
2785
2788
  .modalSubmitAction(false).modalCancelActionLabel(t('filament-actions::view.single.modal.actions.close.label'))
@@ -2787,7 +2790,7 @@ export class ViewAction {
2787
2790
  }
2788
2791
  }
2789
2792
  const managerOf = (livewire) => livewire.constructor;
2790
- /** Rattache un enregistrement existant au propriétaire d'un relation manager (port de AttachAction). */
2793
+ /** Attaches an existing record to a relation manager's owner. */
2791
2794
  export class AttachAction {
2792
2795
  static make() {
2793
2796
  return Action.make('attach')
@@ -2807,7 +2810,7 @@ export class AttachAction {
2807
2810
  });
2808
2811
  }
2809
2812
  }
2810
- /** Détache une ligne du propriétaire sans la supprimer (port de DetachAction). */
2813
+ /** Detaches a row from the owner without deleting it. */
2811
2814
  export class DetachAction {
2812
2815
  static make() {
2813
2816
  return Action.make('detach')
@@ -2821,7 +2824,7 @@ export class DetachAction {
2821
2824
  });
2822
2825
  }
2823
2826
  }
2824
- /** Détache les lignes sélectionnées (port de DetachBulkAction). */
2827
+ /** Detaches the selected rows. */
2825
2828
  export class DetachBulkAction {
2826
2829
  static make() {
2827
2830
  return Action.make('detach')
@@ -2830,14 +2833,14 @@ export class DetachBulkAction {
2830
2833
  .modalHeading(({ livewire }) => t('filament-actions::detach.multiple.modal.heading', { label: livewire.resource.getPluralModelLabel() }))
2831
2834
  .modalSubmitActionLabel(t('filament-actions::detach.multiple.modal.actions.detach.label'))
2832
2835
  .action(async ({ livewire }) => {
2833
- // Les clés sélectionnées sont celles de la requête courante du manager : elles appartiennent donc au propriétaire.
2836
+ // Selected keys come from the manager's current query, so they belong to the owner.
2834
2837
  await managerOf(livewire).detach(livewire.ownerRecord, await livewire.getSelectedRecordKeys());
2835
2838
  Notification.make().success().title(t('filament-actions::detach.multiple.notifications.detached.title')).send();
2836
2839
  livewire.deselectAllTableRecords();
2837
2840
  });
2838
2841
  }
2839
2842
  }
2840
- /** Restauration d'un enregistrement supprimé (port de RestoreAction). Visible seulement s'il est supprimé. */
2843
+ /** Restores a soft-deleted record. Only visible when the record is deleted. */
2841
2844
  export class RestoreAction {
2842
2845
  static make() {
2843
2846
  return Action.make('restore').authorize(({ livewire, record }) => canOn(livewire, 'restore', record))
@@ -2853,7 +2856,7 @@ export class RestoreAction {
2853
2856
  });
2854
2857
  }
2855
2858
  }
2856
- /** Suppression définitive d'un enregistrement supprimé (port de ForceDeleteAction). Visible seulement s'il est supprimé. */
2859
+ /** Permanently deletes a soft-deleted record. Only visible when the record is deleted. */
2857
2860
  export class ForceDeleteAction {
2858
2861
  static make() {
2859
2862
  return Action.make('forceDelete').authorize(({ livewire, record }) => canOn(livewire, 'forceDelete', record))
@@ -2869,7 +2872,7 @@ export class ForceDeleteAction {
2869
2872
  });
2870
2873
  }
2871
2874
  }
2872
- /** Suppression groupée des enregistrements sélectionnés dans une table (port de DeleteBulkAction). */
2875
+ /** Deletes the records selected in a table. */
2873
2876
  export class DeleteBulkAction {
2874
2877
  static make() {
2875
2878
  return Action.make('delete').authorize(({ livewire, record }) => canOn(livewire, 'deleteAny', record))
@@ -2884,21 +2887,21 @@ export class DeleteBulkAction {
2884
2887
  });
2885
2888
  }
2886
2889
  }
2887
- /** Page d'édition d'un enregistrement (port de EditRecord). */
2890
+ /** Record edit page. */
2888
2891
  export class EditRecord extends ResourceFormPage {
2889
2892
  static callable = ['save'];
2890
2893
  recordKey = '';
2891
- /** Onglet de relation manager actif (indice dans `Resource.relations`). */
2894
+ /** Active relation manager tab (index in `Resource.relations`). */
2892
2895
  activeRelationManager = '0';
2893
2896
  /**
2894
- * Page précédente (équivalent de `url()->previous()`), retenue au premier affichage. Elle vient de l'en-tête Referer :
2895
- * seule une adresse de ce même site est acceptée, et elle n'est plus modifiable ensuite.
2897
+ * Previous page, captured on first display from the Referer header: only a same-site URL is accepted,
2898
+ * and it cannot be changed afterwards.
2896
2899
  */
2897
2900
  previousUrl = '';
2898
2901
  getCancelUrl() { return this.previousUrl; }
2899
2902
  contentSchema(schema) {
2900
2903
  const content = super.contentSchema(schema);
2901
- // Toujours présent, comme dans Filament : masqué (colonne vide) quand la resource n'a pas de relation manager.
2904
+ // Always present: hidden (empty column) when the resource has no relation manager.
2902
2905
  return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
2903
2906
  }
2904
2907
  static locked = ['recordKey', 'previousUrl'];
@@ -2907,25 +2910,24 @@ export class EditRecord extends ResourceFormPage {
2907
2910
  this.recordKey = String(params.record ?? '');
2908
2911
  this.previousUrl = previousUrlOf(this._ctx?.req);
2909
2912
  await this.hydrate();
2910
- // Remplit le formulaire avec les valeurs de l'enregistrement pour chaque champ déclaré.
2911
2913
  fillSchemaFromRecord(this.getSchema('form'), this.data, await this.mutateFormDataBeforeFill({ ...this._record }));
2912
2914
  }
2913
- /** L'enregistrement est relu à chaque requête, comme dans Filament (pas de liaison de route). */
2915
+ /** The record is reloaded on every request (no route model binding). */
2914
2916
  async hydrate() {
2915
2917
  this._record = await this.getDataAdapter().find(this.recordKey);
2916
2918
  if (!this._record)
2917
- throw new WireError(404, 'Enregistrement introuvable.');
2919
+ throw new WireError(404, 'Record not found.');
2918
2920
  if (!(await canOn(this, 'update', this._record)))
2919
- throw new WireError(403, 'Accès refusé.');
2921
+ throw new WireError(403, 'Forbidden.');
2920
2922
  }
2921
2923
  getRecord() { return this._record; }
2922
2924
  getRecordKey() { return this.recordKey; }
2923
2925
  getActionMountContext() { return { recordKey: this.recordKey }; }
2924
2926
  getActionRecord() { return this._record; }
2925
- /** Comme une page générée par Filament : suppression, plus restauration et suppression définitive si le modèle a la suppression douce. */
2927
+ /** Delete, plus restore and force-delete when the model uses soft deletes. */
2926
2928
  getHeaderActions() {
2927
2929
  const defaults = this.getDataAdapter().supportsSoftDeletes() ? [DeleteAction.make(), RestoreAction.make(), ForceDeleteAction.make()] : [DeleteAction.make()];
2928
- // Actions propres à la resource, placées avant les actions par défaut.
2930
+ // Resource-specific actions come before the default ones.
2929
2931
  return [...(this.resource.editHeaderActions?.() ?? []), ...defaults];
2930
2932
  }
2931
2933
  getOperation() { return 'edit'; }
@@ -2934,7 +2936,7 @@ export class EditRecord extends ResourceFormPage {
2934
2936
  getBreadcrumbs() {
2935
2937
  return [
2936
2938
  { label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
2937
- // Le titre de l'enregistrement n'apparaît que si la resource déclare `recordTitleAttribute` ; il mène à la page « View » quand elle existe.
2939
+ // The record title only appears if the resource declares `recordTitleAttribute`; it links to the view page when there is one.
2938
2940
  ...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl(this.resource.hasViewPage ? 'view' : 'edit', this.recordKey) }] : []),
2939
2941
  { label: t('filament-panels::resources/pages/edit-record.breadcrumb'), url: null },
2940
2942
  ];
@@ -2944,20 +2946,20 @@ export class EditRecord extends ResourceFormPage {
2944
2946
  getFormActions() {
2945
2947
  return [Action.make('save').label(t('filament-panels::resources/pages/edit-record.form.actions.save.label')).submit('save').keyBindings(['mod+s']), this.cancelAction()];
2946
2948
  }
2947
- // --- crochets de cycle de vie, à surcharger dans une sous-classe (aucun n'est appelable depuis le navigateur)
2948
- /** Transforme les valeurs de l'enregistrement avant de remplir le formulaire. */
2949
+ // --- lifecycle hooks, to override in a subclass (none can be called from the browser)
2950
+ /** Transforms the record's values before filling the form. */
2949
2951
  mutateFormDataBeforeFill(data) { return data; }
2950
- /** Transforme les données validées avant l'enregistrement. */
2952
+ /** Transforms validated data before saving. */
2951
2953
  mutateFormDataBeforeSave(data) { return data; }
2952
- /** Juste avant l'enregistrement ; lever une erreur l'annule. */
2954
+ /** Right before saving; throwing cancels it. */
2953
2955
  beforeSave(_data) { }
2954
- /** L'enregistrement lui-même : par défaut, l'adaptateur de la resource. */
2956
+ /** The save itself; defaults to the resource's data adapter. */
2955
2957
  handleRecordUpdate(record, data) { return this.getDataAdapter().update(this.getDataAdapter().getKey(record), data); }
2956
- /** Après l'enregistrement. */
2958
+ /** After saving. */
2957
2959
  afterSave(_record) { }
2958
- /** Adresse où aller après l'enregistrement ; `null` (défaut) : rester sur la page. */
2960
+ /** URL to go to after saving; `null` (default) stays on the page. */
2959
2961
  getRedirectUrl(_record) { return null; }
2960
- /** Titre de la notification de succès ; `null` pour ne rien afficher. */
2962
+ /** Success notification title; `null` to show nothing. */
2961
2963
  getSavedNotificationTitle() { return t('filament-panels::resources/pages/edit-record.notifications.saved.title'); }
2962
2964
  async save() {
2963
2965
  if (!(await this.validateForm()))
@@ -2974,12 +2976,12 @@ export class EditRecord extends ResourceFormPage {
2974
2976
  this.redirect(url, true);
2975
2977
  }
2976
2978
  }
2977
- /** Page de consultation d'un enregistrement (port de ViewRecord) : le schéma `infolist()` de la resource, en lecture seule. */
2979
+ /** Record view page: the resource's read-only `infolist()` schema. */
2978
2980
  export class ViewRecord extends Page {
2979
2981
  static resource;
2980
2982
  static locked = ['recordKey'];
2981
2983
  recordKey = '';
2982
- /** Onglet de relation manager actif (indice dans `Resource.relations`). */
2984
+ /** Active relation manager tab (index in `Resource.relations`). */
2983
2985
  activeRelationManager = '0';
2984
2986
  _record = null;
2985
2987
  get resource() { return this.constructor.resource; }
@@ -2988,13 +2990,13 @@ export class ViewRecord extends Page {
2988
2990
  this.recordKey = String(params.record ?? '');
2989
2991
  await this.hydrate();
2990
2992
  }
2991
- /** L'enregistrement est relu à chaque requête, comme sur la page d'édition. */
2993
+ /** The record is reloaded on every request, as on the edit page. */
2992
2994
  async hydrate() {
2993
2995
  this._record = await this.getDataAdapter().find(this.recordKey);
2994
2996
  if (!this._record)
2995
- throw new WireError(404, 'Enregistrement introuvable.');
2997
+ throw new WireError(404, 'Record not found.');
2996
2998
  if (!(await canOn(this, 'view', this._record)))
2997
- throw new WireError(403, 'Accès refusé.');
2999
+ throw new WireError(403, 'Forbidden.');
2998
3000
  }
2999
3001
  getRecordKey() { return this.recordKey; }
3000
3002
  getActionMountContext() { return { recordKey: this.recordKey }; }
@@ -3002,10 +3004,10 @@ export class ViewRecord extends Page {
3002
3004
  infolistSchema(schema) { return this.resource.infolist(schema.columns(2).operation('view').record(this._record)); }
3003
3005
  contentSchema(schema) {
3004
3006
  const content = schema.components([EmbeddedSchema.make('infolist')]);
3005
- // Toujours présent, comme dans Filament : masqué (colonne vide) quand la resource n'a pas de relation manager.
3007
+ // Always present: hidden (empty column) when the resource has no relation manager.
3006
3008
  return content.components([...content.getComponents(), this.resource.relations.length ? new RelationManagerTabs() : new RelationManagerTabs().key(null).hidden()]);
3007
3009
  }
3008
- /** Par défaut : le lien vers la page d'édition (EditAction d'une page « View »). */
3010
+ /** Defaults to a link to the edit page. */
3009
3011
  getHeaderActions() { return [this.editAction()]; }
3010
3012
  editAction() {
3011
3013
  return Action.make('edit').authorize(({ livewire, record }) => canOn(livewire, 'update', record)).label(t('filament-actions::edit.single.label')).url(() => this.resource.getUrl('edit', this.recordKey));
@@ -3014,7 +3016,7 @@ export class ViewRecord extends Page {
3014
3016
  getBreadcrumbs() {
3015
3017
  return [
3016
3018
  { label: this.resource.getTitleCasePluralModelLabel(), url: this.resource.getUrl() },
3017
- // Le titre de l'enregistrement n'apparaît que si la resource déclare `recordTitleAttribute`.
3019
+ // The record title only appears if the resource declares `recordTitleAttribute`.
3018
3020
  ...(this.resource.recordTitleAttribute ? [{ label: this.resource.getRecordTitle(this._record), url: this.resource.getUrl('view', this.recordKey) }] : []),
3019
3021
  { label: t('filament-panels::resources/pages/view-record.breadcrumb'), url: null },
3020
3022
  ];
@@ -3022,10 +3024,9 @@ export class ViewRecord extends Page {
3022
3024
  getSubNavigation() { return this.resource.getRecordSubNavigation(this); }
3023
3025
  getPageClasses() { return ['fi-resource-view-record-page', `fi-resource-${this.resource.slug.replace(/\//g, '-')}`, `fi-resource-record-${this.recordKey}`]; }
3024
3026
  }
3025
- // ------------------------------------------------------------------ composants de mise en page
3026
- /** Contexte d'évaluation des actions d'un menu du panel (pas d'enregistrement). */
3027
+ /** Evaluation context for panel menu actions (no record). */
3027
3028
  const menuContext = (livewire) => ({ livewire: livewire, component: null, operation: null, record: null, get: () => undefined, state: undefined });
3028
- /** Menu utilisateur (port de HasUserMenu et de la vue user-menu) : profil, éléments du panel, thèmes, déconnexion. */
3029
+ /** User menu: profile, panel items, themes, logout. */
3029
3030
  async function renderUserMenu(component, position) {
3030
3031
  const { req, res, panel, user } = component._ctx;
3031
3032
  const ctx = menuContext(component);
@@ -3048,7 +3049,7 @@ async function renderUserMenu(component, position) {
3048
3049
  themes: panel.getThemes().map((theme) => ({ id: theme.id, label: theme.label, swatch: swatchOf(theme, panel) })), activeTheme: panel.resolveTheme(req)?.id ?? null,
3049
3050
  });
3050
3051
  }
3051
- /** Menu du locataire (port de la vue tenant-menu) : locataire courant, autres locataires, création, profil. */
3052
+ /** Tenant menu: current tenant, other tenants, registration, profile. */
3052
3053
  async function renderTenantMenu(component, options) {
3053
3054
  const { panel, user } = component._ctx;
3054
3055
  const tenant = panel.getTenant();
@@ -3081,13 +3082,13 @@ async function renderTenantMenu(component, options) {
3081
3082
  switchable: others.map((other) => ({ name: provider.getName(other), url: panel.getUrl('', other), avatarUrl: panel.getTenantAvatarUrl(other) })),
3082
3083
  });
3083
3084
  }
3084
- /** Sidebar : logo, menu du locataire, recherche, navigation, et — sans barre du haut — cloche et menu utilisateur. */
3085
+ /** Sidebar: logo, tenant menu, search, navigation and, without a topbar, the bell and user menu. */
3085
3086
  export class Sidebar extends Component {
3086
3087
  static callable = ['setTheme'];
3087
3088
  currentPath = '/';
3088
3089
  static locked = ['currentPath'];
3089
3090
  mount(params) { this.currentPath = String(params.path ?? '/'); }
3090
- /** Un élément de resource reste actif sur ses pages de création et d'édition. */
3091
+ /** A resource item stays active on its create and edit pages. */
3091
3092
  async render() {
3092
3093
  const { panel, user } = this._ctx;
3093
3094
  if (!user)
@@ -3104,22 +3105,22 @@ export class Sidebar extends Component {
3104
3105
  livewireId: this.id,
3105
3106
  });
3106
3107
  }
3107
- /** Choix d'un thème par l'utilisateur : seul un thème déclaré par le panel est accepté ; la page est rechargée pour l'appliquer. */
3108
+ /** Theme choice by the user: only a theme declared by the panel is accepted; the page reloads to apply it. */
3108
3109
  setTheme(id) { applyTheme(this, id); this.skipRender(); }
3109
3110
  }
3110
- /** Choix d'un thème (sidebar ou barre du haut) : seul un thème déclaré par le panel est accepté ; la page est rechargée. */
3111
+ /** Theme choice (sidebar or topbar): only a theme declared by the panel is accepted; the page reloads. */
3111
3112
  function applyTheme(component, id) {
3112
3113
  const { req, res, panel, user } = component._ctx;
3113
3114
  if (!user)
3114
- throw new WireError(401, 'Non authentifié.');
3115
+ throw new WireError(401, 'Unauthenticated.');
3115
3116
  const theme = panel.getThemes().find((candidate) => candidate.id === id);
3116
3117
  if (!theme)
3117
- throw new WireError(404, 'Thème inconnu.');
3118
+ throw new WireError(404, 'Unknown theme.');
3118
3119
  panel.rememberTheme(res, theme);
3119
3120
  const referer = previousUrlOf(req);
3120
3121
  component.redirect(referer.replace(/^https?:\/\/[^/]+/, '') || '/');
3121
3122
  }
3122
- /** Barre du haut (port de Filament\Livewire\Topbar) : logo, navigation éventuelle, recherche, cloche, menu utilisateur. */
3123
+ /** Topbar: logo, optional navigation, search, bell, user menu. */
3123
3124
  export class Topbar extends Component {
3124
3125
  static callable = ['setTheme'];
3125
3126
  currentPath = '/';
@@ -3147,7 +3148,7 @@ export class Topbar extends Component {
3147
3148
  }
3148
3149
  setTheme(id) { applyTheme(this, id); this.skipRender(); }
3149
3150
  }
3150
- /** Menu utilisateur des pages simples d'un utilisateur connecté (port de SimpleUserMenu). */
3151
+ /** User menu on simple pages for a signed-in user. */
3151
3152
  export class SimpleUserMenu extends Component {
3152
3153
  static callable = ['setTheme'];
3153
3154
  async render() {
@@ -3159,32 +3160,76 @@ export class SimpleUserMenu extends Component {
3159
3160
  }
3160
3161
  setTheme(id) { applyTheme(this, id); this.skipRender(); }
3161
3162
  }
3162
- // ------------------------------------------------------------------ routage
3163
- /** Un runtime ne sert qu'un panel : chaque panel a son endpoint Livewire et ses composants. */
3163
+ /** `.ts`, `.mts`, `.js`, `.mjs` modules of a folder and its subfolders, in alphabetical path order. */
3164
+ function moduleFiles(directory) {
3165
+ const root = directory instanceof URL ? fileURLToPath(directory) : directory;
3166
+ if (!existsSync(root))
3167
+ return [];
3168
+ const files = [];
3169
+ const walk = (dir) => {
3170
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
3171
+ const full = join(dir, entry.name);
3172
+ if (entry.isDirectory())
3173
+ walk(full);
3174
+ else if (/\.(m?ts|m?js)$/.test(entry.name) && !/\.(d|test|spec)\.m?[tj]s$/.test(entry.name))
3175
+ files.push(full);
3176
+ }
3177
+ };
3178
+ walk(root);
3179
+ return files;
3180
+ }
3181
+ /** Classes exported by a folder's modules that extend `base` (the base class itself, abstract or imported, is skipped). */
3182
+ async function discoverClasses(directory, base) {
3183
+ const found = [];
3184
+ for (const file of moduleFiles(directory)) {
3185
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
3186
+ for (const value of Object.values(module))
3187
+ if (typeof value === 'function' && value.prototype instanceof base && !found.includes(value))
3188
+ found.push(value);
3189
+ }
3190
+ return found;
3191
+ }
3192
+ /** Resources defined in a folder: `.resources(await discoverResources(new URL('./resources/', import.meta.url)))`. New files are picked up on the next start. */
3193
+ export const discoverResources = (directory) => discoverClasses(directory, Resource);
3194
+ /** Custom pages (and dashboards) defined in a folder. Resource and authentication pages are excluded. */
3195
+ export const discoverPages = async (directory) => (await discoverClasses(directory, Page)).filter((page) => !page.resource && !(page.prototype instanceof EditTenantProfile));
3196
+ /** Widgets defined in a folder. */
3197
+ export const discoverWidgets = (directory) => discoverClasses(directory, Widget);
3198
+ /** Panels default-exported (`export default Panel.make()…`) by a folder's modules. */
3199
+ export async function discoverPanels(directory) {
3200
+ const panels = [];
3201
+ for (const file of moduleFiles(directory)) {
3202
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
3203
+ if (module.default instanceof Panel)
3204
+ panels.push(module.default);
3205
+ }
3206
+ return panels;
3207
+ }
3208
+ /** A runtime serves a single panel: each panel has its own Livewire endpoint and components. */
3164
3209
  const runtimePanels = new WeakMap();
3165
- /** Segments réservés juste après le chemin d'un panel à locataires (ils ne sont pas des clés de locataire). */
3210
+ /** Reserved segments right after a multi-tenant panel's path (they are not tenant keys). */
3166
3211
  const RESERVED_SEGMENTS = new Set(['login', 'logout', 'register', 'password-reset', 'email-verification', 'profile', 'livewire']);
3167
- /** Routeur Express d'un panel : assets, endpoint du runtime, authentification, locataires, pages protégées. */
3212
+ /** Express router for a panel: assets, runtime endpoint, authentication, tenants, protected pages. */
3168
3213
  export function panelRouter(panel, runtime) {
3169
3214
  const owner = runtimePanels.get(runtime);
3170
3215
  if (owner && owner !== panel)
3171
- throw new Error(`Ce WireRuntime sert déjà le panel « ${owner.getId()} » : créez un WireRuntime par panel.`);
3216
+ throw new Error(`This WireRuntime already serves panel "${owner.getId()}": create one WireRuntime per panel.`);
3172
3217
  runtimePanels.set(runtime, panel);
3173
- // Les envois de fichiers passent par l'endpoint de ce panel (session et utilisateur du panel).
3218
+ // File uploads go through this panel's endpoint (panel session and user).
3174
3219
  const runtimeOptions = runtime.options;
3175
3220
  runtimeOptions.uploadPrefix ??= panel.getLivewirePrefix();
3176
- // Un seul gestionnaire de couleurs pour tous les panels : chaque requête y pose les couleurs du sien.
3221
+ // One color manager for all panels: each request sets its own panel's colors on it.
3177
3222
  useColorManager(sharedColors);
3178
3223
  useFormsColorManager(sharedColors);
3179
3224
  useTableColorManager(sharedColors);
3180
3225
  useInfolistsColorManager(sharedColors);
3181
- // HTML affiché tel quel par une entrée d'infolist : repassé par le schéma de l'éditeur de texte enrichi, qui ne garde que ses balises.
3226
+ // HTML rendered by an infolist entry is sanitized through the rich editor schema, which only keeps its own tags.
3182
3227
  useHtmlSanitizer((html) => richDocumentToHtml(richHtmlToDocument(html)) ?? '');
3183
3228
  views.share({ assetUrl: (file) => panel.assetUrl(file) });
3184
3229
  for (const plugin of panel.getPlugins())
3185
3230
  plugin.boot?.(panel);
3186
3231
  assertAuthCapabilities(panel);
3187
- // Locataires : les resources cloisonnées ne voient que les enregistrements du locataire de la requête.
3232
+ // Tenancy: scoped resources only see the records of the request's tenant.
3188
3233
  const ownership = panel.getTenantOwnershipColumn();
3189
3234
  if (panel.hasTenancy() && ownership) {
3190
3235
  for (const resource of panel.getResources()) {
@@ -3192,16 +3237,16 @@ export function panelRouter(panel, runtime) {
3192
3237
  continue;
3193
3238
  const adapter = resource.adapter;
3194
3239
  if (typeof adapter?.scopeToTenant !== 'function')
3195
- throw new Error(`Resource ${resource.name} : son adaptateur ne sait pas se cloisonner par locataire (scopeToTenant).`);
3240
+ throw new Error(`Resource ${resource.name}: its adapter does not support tenant scoping (scopeToTenant).`);
3196
3241
  adapter.scopeToTenant(resource.tenantOwnershipColumn ?? ownership, () => { const tenant = panel.getTenant(); return tenant ? panel.getTenantUrlKey(tenant) : null; });
3197
3242
  }
3198
3243
  }
3199
3244
  const router = express.Router();
3200
3245
  const name = (suffix) => `navette.${panel.getId()}.${suffix}`;
3201
3246
  const livewire = panel.getLivewirePrefix();
3202
- // La boîte aux lettres des notifications est propre au navigateur : on la rattache à son jeton CSRF.
3247
+ // The notifications mailbox is per browser: it is keyed by the CSRF token.
3203
3248
  const context = async (req, res) => ({ req, res, panel, user: await panel.user(req), mailbox: csrfToken(req, res), tenant: null });
3204
- /** Locataire désigné par une adresse du panel, si l'utilisateur en est membre. */
3249
+ /** Tenant designated by a panel URL, if the user is a member. */
3205
3250
  const tenantFromPath = async (path, user) => {
3206
3251
  if (!panel.hasTenancy() || !user)
3207
3252
  return null;
@@ -3223,7 +3268,7 @@ export function panelRouter(panel, runtime) {
3223
3268
  const tenant = await provider.find(key);
3224
3269
  return tenant && (await provider.canAccess(user, tenant)) ? tenant : null;
3225
3270
  };
3226
- /** Accueil de l'utilisateur : la racine du panel, ou dans un panel à locataires celle de son premier locataire (sinon la création d'un locataire). */
3271
+ /** User's home: the panel root or, in a multi-tenant panel, their first tenant's root (otherwise tenant registration). */
3227
3272
  const homeUrl = async (user) => {
3228
3273
  if (!panel.hasTenancy())
3229
3274
  return panel.getUrl();
@@ -3271,9 +3316,9 @@ export function panelRouter(panel, runtime) {
3271
3316
  for (const manager of resource.relations)
3272
3317
  runtime.register(name(`relation-managers.${resource.slug.replace(/\//g, '.')}.${manager.relationship}`), manager);
3273
3318
  router.use(express.static(dir('../dist/filament'), { maxAge: '1y' }));
3274
- // Fichiers propres à Navette (hors balisage et styles de Filament).
3319
+ // Navette's own front-end files.
3275
3320
  router.use(express.static(dir('../dist/navette')));
3276
- // Le locataire d'une mise à jour est celui de la page qui l'envoie (adresse du snapshot, signé), si l'utilisateur en est membre.
3321
+ // An update's tenant is the one of the page sending it (from the signed snapshot URL), if the user is a member.
3277
3322
  const wireContext = async (req, res) => {
3278
3323
  const ctx = await context(req, res);
3279
3324
  let path = '';
@@ -3286,7 +3331,7 @@ export function panelRouter(panel, runtime) {
3286
3331
  ctx.tenant = await tenantFromPath(path, ctx.user);
3287
3332
  return ctx;
3288
3333
  };
3289
- // Port du middleware PublishNotifications : les notifications envoyées pendant la requête partent dans un lot annoncé au navigateur.
3334
+ // Notifications sent during the request go into a batch announced to the browser.
3290
3335
  router.use(wireRouter(runtime, livewire, wireContext, async (ctx, _req, res, handle) => {
3291
3336
  const { result, pending } = await runInRequest(ctx.mailbox, () => panel.withTheme(ctx.req, () => panel.withTenant(ctx.tenant ?? null, handle)));
3292
3337
  if (pending.length) {
@@ -3298,23 +3343,23 @@ export function panelRouter(panel, runtime) {
3298
3343
  res.setHeader('X-Filament-Notifications-Redirect', 'true');
3299
3344
  }
3300
3345
  return result;
3301
- // Seul un utilisateur connecté peut envoyer des fichiers.
3346
+ // Only a signed-in user can upload files.
3302
3347
  }, (ctx) => !!ctx.user));
3303
- // Tout le rendu d'une page se fait avec les couleurs du thème de la requête, et pour son locataire.
3348
+ // A page renders entirely with the request theme's colors, for the request's tenant.
3304
3349
  const document = (req, res, ctx, options) => panel.withTheme(req, () => panel.withTenant(ctx.tenant ?? null, () => renderDocument(req, res, ctx, options)));
3305
3350
  const renderDocument = async (req, res, ctx, options) => {
3306
3351
  const request = { path: req.path, method: req.method, ctx };
3307
3352
  const mounted = await runtime.mountWithInstance(options.component, options.params ?? {}, request);
3308
- // Une page qui redirige dès son montage (déjà connecté, adresse déjà vérifiée…) : redirection HTTP.
3353
+ // A page that redirects on mount (already logged in, email already verified…): HTTP redirect.
3309
3354
  const redirect = mounted.component._effects?.redirect;
3310
3355
  if (typeof redirect === 'string')
3311
3356
  return void res.redirect(redirect);
3312
3357
  const slot = mounted.html;
3313
- // Le titre du document vient de l'instance (il peut dépendre de l'enregistrement).
3358
+ // The document title comes from the instance (it may depend on the record).
3314
3359
  const instanceTitle = mounted.component.getTitle?.();
3315
3360
  if (typeof instanceTitle === 'string')
3316
3361
  options.title = instanceTitle;
3317
- // Les emplacements de la mise en page portent les portées de la page affichée.
3362
+ // Layout render hooks use the scopes of the displayed page.
3318
3363
  const renderHookScopes = mounted.component.getRenderHookScopes?.() ?? [];
3319
3364
  const hasNavigation = panel.hasNavigation();
3320
3365
  const body = options.layout === 'simple'
@@ -3336,7 +3381,7 @@ export function panelRouter(panel, runtime) {
3336
3381
  notifications: (await runtime.mount('navette.notifications', {}, request)).replace(/^<div /, '<div wire:key="lw-1-0-0" '),
3337
3382
  }));
3338
3383
  };
3339
- /** Erreurs d'accès d'une page : 404 et 403 en clair, le reste remonte. */
3384
+ /** Page access errors: 404 and 403 are answered directly, anything else is rethrown. */
3340
3385
  const guarded = async (res, render) => {
3341
3386
  try {
3342
3387
  await render();
@@ -3349,7 +3394,7 @@ export function panelRouter(panel, runtime) {
3349
3394
  throw error;
3350
3395
  }
3351
3396
  };
3352
- /** Page simple réservée aux invités (connexion, inscription, mot de passe oublié). */
3397
+ /** Guest-only simple page (login, registration, password reset). */
3353
3398
  const guestPage = (path, component, title, params = () => ({})) => {
3354
3399
  router.get(panel.getBaseUrl(path), async (req, res) => {
3355
3400
  const ctx = await context(req, res);
@@ -3358,7 +3403,7 @@ export function panelRouter(panel, runtime) {
3358
3403
  await guarded(res, () => document(req, res, ctx, { title, component, layout: 'simple', params: params(req) }));
3359
3404
  });
3360
3405
  };
3361
- /** Page simple d'un utilisateur connecté, hors locataire (profil, vérification, création d'un locataire). */
3406
+ /** Simple page for a signed-in user, outside any tenant (profile, email verification, tenant registration). */
3362
3407
  const userPage = (path, component, title, layout = 'simple') => {
3363
3408
  router.get(panel.getBaseUrl(path), async (req, res) => {
3364
3409
  const ctx = await context(req, res);
@@ -3371,7 +3416,7 @@ export function panelRouter(panel, runtime) {
3371
3416
  guestPage('login', name('auth.login'), t('filament-panels::auth/pages/login.title'));
3372
3417
  router.post(panel.getLogoutUrl(), express.urlencoded({ extended: false }), (req, res) => {
3373
3418
  if (!verifyCsrf(req, req.body?._token))
3374
- return res.status(419).send('Jeton CSRF invalide.');
3419
+ return res.status(419).send('Invalid CSRF token.');
3375
3420
  panel.logOut(res);
3376
3421
  res.redirect(panel.getLoginUrl());
3377
3422
  });
@@ -3384,7 +3429,7 @@ export function panelRouter(panel, runtime) {
3384
3429
  }
3385
3430
  if (VerificationPrompt) {
3386
3431
  userPage('email-verification/prompt', name('auth.email-verification.prompt'), t('filament-panels::auth/pages/email-verification/email-verification-prompt.title'));
3387
- // Lien reçu par e-mail : signé, limité dans le temps, et valable seulement pour l'utilisateur connecté qu'il désigne.
3432
+ // Emailed link: signed, time-limited, and only valid for the signed-in user it designates.
3388
3433
  router.get(panel.getBaseUrl('email-verification/verify/:id/:hash'), async (req, res) => {
3389
3434
  const ctx = await context(req, res);
3390
3435
  if (!ctx.user)
@@ -3401,7 +3446,7 @@ export function panelRouter(panel, runtime) {
3401
3446
  userPage(profile.page.slug, name('auth.profile'), profile.page.getLabel(), profile.isSimple || panel.hasTenancy() ? 'simple' : 'index');
3402
3447
  if (TenantRegistration)
3403
3448
  userPage(TenantRegistration.slug, name('tenancy.registration'), TenantRegistration.getLabel());
3404
- /** Contexte d'une page protégée : connexion, vérification de l'adresse, locataire. Rend null après avoir répondu. */
3449
+ /** Context of a protected page: login, email verification, tenant. Returns `null` after responding. */
3405
3450
  const pageContext = async (req, res) => {
3406
3451
  const ctx = await context(req, res);
3407
3452
  if (!ctx.user) {
@@ -3423,7 +3468,7 @@ export function panelRouter(panel, runtime) {
3423
3468
  };
3424
3469
  const tenantSegment = panel.hasTenancy() ? ':tenant/' : '';
3425
3470
  if (panel.hasTenancy()) {
3426
- // Racine du panel : le premier locataire de l'utilisateur, sinon la création d'un locataire.
3471
+ // Panel root: the user's first tenant, otherwise tenant registration.
3427
3472
  router.get(panel.getBaseUrl(), async (req, res) => {
3428
3473
  const ctx = await context(req, res);
3429
3474
  if (!ctx.user)
@@ -3435,7 +3480,7 @@ export function panelRouter(panel, runtime) {
3435
3480
  return void res.status(404).send('Not Found');
3436
3481
  res.redirect(url);
3437
3482
  });
3438
- // Racine d'un locataire sans tableau de bord : la première page du menu (comme Filament).
3483
+ // Tenant root without a dashboard: the first menu page.
3439
3484
  if (!panel.getPages().some((page) => page.slug === '')) {
3440
3485
  router.get(panel.getBaseUrl(':tenant'), async (req, res, next) => {
3441
3486
  if (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug)
@@ -3453,7 +3498,7 @@ export function panelRouter(panel, runtime) {
3453
3498
  for (const PageClass of panel.getPages()) {
3454
3499
  const component = name(`pages.${(PageClass.slug || 'dashboard').replace(/[{}]/g, '')}`);
3455
3500
  runtime.register(component, PageClass);
3456
- // `{record}` dans le slug devient un paramètre de route, passé à mount().
3501
+ // `{record}` in the slug becomes a route parameter, passed to mount().
3457
3502
  router.get(panel.getBaseUrl(tenantSegment + PageClass.slug).replace(/\{(\w+)\}/g, ':$1'), async (req, res, next) => {
3458
3503
  if (panel.hasTenancy() && (RESERVED_SEGMENTS.has(req.params.tenant) || req.params.tenant === TenantRegistration?.slug))
3459
3504
  return next();
@@ -3466,7 +3511,7 @@ export function panelRouter(panel, runtime) {
3466
3511
  }
3467
3512
  return router;
3468
3513
  }
3469
- /** Une option d'authentification exige certaines méthodes du fournisseur d'utilisateurs : on le dit au démarrage plutôt qu'au premier clic. */
3514
+ /** Each auth feature requires some auth provider methods: fail at startup rather than on first click. */
3470
3515
  function assertAuthCapabilities(panel) {
3471
3516
  if (!panel.hasRegistration() && !panel.hasPasswordReset() && !panel.hasEmailVerification() && !panel.hasProfile())
3472
3517
  return;
@@ -3474,14 +3519,14 @@ function assertAuthCapabilities(panel) {
3474
3519
  const missing = (feature, methods) => {
3475
3520
  const absent = methods.filter((method) => typeof auth[method] !== 'function');
3476
3521
  if (absent.length)
3477
- throw new Error(`${feature} : le fournisseur d'utilisateurs doit définir ${absent.join(', ')} (knexAuthProvider le fait).`);
3522
+ throw new Error(`${feature}: the auth provider must implement ${absent.join(', ')} (knexAuthProvider does).`);
3478
3523
  };
3479
3524
  if (panel.hasRegistration())
3480
- missing('Inscription', ['create', 'findByEmail']);
3525
+ missing('Registration', ['create', 'findByEmail']);
3481
3526
  if (panel.hasPasswordReset())
3482
- missing('Mot de passe oublié', ['findByEmail', 'update', 'getPasswordFingerprint']);
3527
+ missing('Password reset', ['findByEmail', 'update', 'getPasswordFingerprint']);
3483
3528
  if (panel.hasEmailVerification())
3484
- missing('Vérification de l\'adresse', ['hasVerifiedEmail', 'markEmailAsVerified']);
3529
+ missing('Email verification', ['hasVerifiedEmail', 'markEmailAsVerified']);
3485
3530
  if (panel.hasProfile())
3486
- missing('Profil', ['update']);
3531
+ missing('Profile', ['update']);
3487
3532
  }