@hartl-services/medusa-base 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.medusa/server/src/admin/index.js +741 -36
  2. package/.medusa/server/src/admin/index.mjs +743 -38
  3. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/actions/[action_key]/route.js +31 -0
  4. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/actions/[action_key]/runs/route.js +16 -0
  5. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/route.js +14 -0
  6. package/.medusa/server/src/api/admin/plugin-features/[plugin_key]/settings/route.js +22 -0
  7. package/.medusa/server/src/api/admin/plugin-features/route.js +6 -4
  8. package/.medusa/server/src/api/middlewares.js +22 -8
  9. package/.medusa/server/src/api/plugin-actions.js +221 -0
  10. package/.medusa/server/src/api/plugin-features-http.js +67 -8
  11. package/.medusa/server/src/modules/plugin-features/domain/plugin-action-runs.js +92 -0
  12. package/.medusa/server/src/modules/plugin-features/domain/plugin-settings.js +213 -0
  13. package/.medusa/server/src/modules/plugin-features/domain/secret-box.js +56 -0
  14. package/.medusa/server/src/modules/plugin-features/migrations/Migration20260928205031.js +16 -0
  15. package/.medusa/server/src/modules/plugin-features/migrations/Migration20260928215428.js +17 -0
  16. package/.medusa/server/src/modules/plugin-features/models/plugin-action-run.js +43 -0
  17. package/.medusa/server/src/modules/plugin-features/models/plugin-setting-state.js +32 -0
  18. package/.medusa/server/src/modules/plugin-features/service.js +419 -17
  19. package/.medusa/server/src/public/action-progress.js +67 -0
  20. package/.medusa/server/src/public/descriptor.js +199 -2
  21. package/.medusa/server/src/public/effective-features.js +9 -5
  22. package/.medusa/server/src/public/enforcement.js +18 -8
  23. package/.medusa/server/src/public/index.js +10 -2
  24. package/.medusa/server/src/public/module-access.js +14 -0
  25. package/.medusa/server/src/public/settings.js +17 -0
  26. package/.medusa/server/src/public/shop-master-data.js +95 -0
  27. package/.medusa/server/src/utils/discover-complete-cart-participants.js +9 -2
  28. package/.medusa/server/src/utils/discover-plugin-features.js +47 -9
  29. package/.medusa/server/src/workflows/hooks/complete-cart-participants.js +28 -16
  30. package/README.md +393 -12
  31. package/dist/admin-components/features/plugin-features/components/PluginActionsSection.d.ts +6 -0
  32. package/dist/admin-components/features/plugin-features/components/PluginActionsSection.js +66 -0
  33. package/dist/admin-components/features/plugin-features/components/PluginDetailPage.d.ts +2 -0
  34. package/dist/admin-components/features/plugin-features/components/PluginDetailPage.js +9 -0
  35. package/dist/admin-components/features/plugin-features/components/PluginFeatureGate.d.ts +22 -0
  36. package/dist/admin-components/features/plugin-features/components/PluginFeatureGate.js +18 -0
  37. package/dist/admin-components/features/plugin-features/components/PluginFeatureSections.d.ts +11 -0
  38. package/dist/admin-components/features/plugin-features/components/PluginFeatureSections.js +19 -0
  39. package/dist/admin-components/features/plugin-features/components/PluginFeaturesCard.d.ts +12 -0
  40. package/dist/admin-components/features/plugin-features/components/PluginFeaturesCard.js +34 -0
  41. package/dist/admin-components/features/plugin-features/components/PluginFeaturesSettingsPage.d.ts +5 -0
  42. package/dist/admin-components/features/plugin-features/components/PluginFeaturesSettingsPage.js +16 -0
  43. package/dist/admin-components/features/plugin-features/components/PluginHubCard.d.ts +7 -0
  44. package/dist/admin-components/features/plugin-features/components/PluginHubCard.js +21 -0
  45. package/dist/admin-components/features/plugin-features/components/PluginPageShell.d.ts +13 -0
  46. package/dist/admin-components/features/plugin-features/components/PluginPageShell.js +20 -0
  47. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.d.ts +7 -0
  48. package/dist/admin-components/features/plugin-features/components/PluginSettingsForm.js +39 -0
  49. package/dist/admin-components/features/plugin-features/components/PluginStatusChips.d.ts +5 -0
  50. package/dist/admin-components/features/plugin-features/components/PluginStatusChips.js +8 -0
  51. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.d.ts +114 -0
  52. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatureEnabled.js +40 -0
  53. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.d.ts +392 -0
  54. package/dist/admin-components/features/plugin-features/hooks/usePluginFeatures.js +128 -0
  55. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.d.ts +93 -0
  56. package/dist/admin-components/features/plugin-features/hooks/usePluginSettingsForm.js +44 -0
  57. package/dist/admin-components/features/plugin-features/lib/action-runs.d.ts +19 -0
  58. package/dist/admin-components/features/plugin-features/lib/action-runs.js +74 -0
  59. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.d.ts +363 -0
  60. package/dist/admin-components/features/plugin-features/lib/plugin-features-admin-client.js +9 -0
  61. package/dist/admin-components/features/plugin-features/lib/plugin-paths.d.ts +4 -0
  62. package/dist/admin-components/features/plugin-features/lib/plugin-paths.js +4 -0
  63. package/dist/admin-components/features/plugin-features/lib/settings-draft.d.ts +28 -0
  64. package/dist/admin-components/features/plugin-features/lib/settings-draft.js +86 -0
  65. package/dist/admin-components/features/plugin-features/queries/pluginFeatureKeys.d.ts +7 -0
  66. package/dist/admin-components/features/plugin-features/queries/pluginFeatureKeys.js +7 -0
  67. package/dist/admin-components/i18n/texts.d.ts +90 -0
  68. package/dist/admin-components/i18n/texts.js +90 -0
  69. package/dist/admin-components/index.d.ts +14 -0
  70. package/dist/admin-components/index.js +14 -0
  71. package/dist/admin-components/lib/errors.d.ts +6 -0
  72. package/dist/admin-components/lib/errors.js +27 -0
  73. package/dist/admin-components/lib/sdk.d.ts +2 -0
  74. package/dist/admin-components/lib/sdk.js +13 -0
  75. package/dist/public/action-progress.d.ts +25 -0
  76. package/dist/public/action-progress.js +66 -0
  77. package/dist/public/descriptor.d.ts +3 -2
  78. package/dist/public/descriptor.js +198 -1
  79. package/dist/public/effective-features.d.ts +2 -1
  80. package/dist/public/effective-features.js +8 -4
  81. package/dist/public/enforcement.d.ts +7 -0
  82. package/dist/public/enforcement.js +17 -7
  83. package/dist/public/index.d.ts +5 -2
  84. package/dist/public/index.js +9 -1
  85. package/dist/public/module-access.d.ts +3 -0
  86. package/dist/public/module-access.js +13 -0
  87. package/dist/public/settings.d.ts +11 -0
  88. package/dist/public/settings.js +16 -0
  89. package/dist/public/shop-master-data.d.ts +25 -0
  90. package/dist/public/shop-master-data.js +94 -0
  91. package/dist/public/types.d.ts +97 -1
  92. package/dist/utils/discover-plugin-features.d.ts +28 -0
  93. package/dist/utils/discover-plugin-features.js +96 -0
  94. package/package.json +7 -3
@@ -1,35 +1,91 @@
1
1
  import { defineRouteConfig } from "@medusajs/admin-sdk";
2
- import { jsxs, jsx } from "react/jsx-runtime";
3
- import { toast, Container, Heading, Text, Switch, Label, Button } from "@medusajs/ui";
2
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
3
+ import { toast, Container, Heading, Text, Alert, Button, Label, Switch, Input, Select, Hint, StatusBadge, usePrompt } from "@medusajs/ui";
4
4
  import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
5
5
  import { pluginFeaturesAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/plugin-features";
6
6
  import Medusa from "@medusajs/js-sdk";
7
+ import { Link, useParams } from "react-router-dom";
8
+ import { useState, useEffect, useMemo, useRef } from "react";
7
9
  const texts = {
8
10
  menu: {
9
11
  label: "Hartl Services Plugins"
10
12
  },
11
13
  page: {
12
14
  title: "Hartl Services Plugins",
13
- description: "Plugins und einzelne Funktionen ein- oder ausschalten. Änderungen gelten sofort; abgeschaltete Funktionen sind im Shop nicht erreichbar."
15
+ description: "Plugins ein- oder ausschalten und öffnen, um Funktionen, Einstellungen und Aktionen zu verwalten. Änderungen gelten sofort; abgeschaltete Funktionen sind im Shop nicht erreichbar."
14
16
  },
15
17
  states: {
16
- loading: "Funktionsschalter werden geladen…",
17
- error: "Funktionsschalter konnten nicht geladen werden.",
18
+ loading: "Plugins werden geladen…",
19
+ error: "Plugins konnten nicht geladen werden.",
18
20
  empty: "Keine Plugins mit Funktionsschaltern installiert.",
19
- retry: "Erneut laden"
21
+ retry: "Erneut laden",
22
+ notFound: "Dieses Plugin ist nicht installiert."
20
23
  },
21
24
  plugin: {
22
- switchLabel: (label) => `${label} aktiv`
25
+ switchLabel: (label) => `${label} aktiv`,
26
+ open: "Öffnen",
27
+ openLabel: (label) => `${label} öffnen`
28
+ },
29
+ chips: {
30
+ enabled: "aktiv",
31
+ disabled: "aus",
32
+ configured: "konfiguriert",
33
+ notConfigured: (labels) => `nicht konfiguriert: ${labels}`
34
+ },
35
+ detail: {
36
+ back: "Zurück zu Hartl Services Plugins"
37
+ },
38
+ sections: {
39
+ features: "Funktionen",
40
+ settings: "Einstellungen",
41
+ actions: "Aktionen"
23
42
  },
24
43
  hints: {
25
44
  pluginDisabled: "Das Plugin ist ausgeschaltet. Alle Funktionen sind inaktiv, ihre Einstellungen bleiben erhalten.",
26
45
  parentDisabled: (parentLabel) => `Inaktiv, solange „${parentLabel}“ ausgeschaltet ist.`,
27
46
  saving: "Wird gespeichert…"
28
47
  },
48
+ settings: {
49
+ missing: (labels) => `Nicht konfiguriert: ${labels}`,
50
+ save: "Einstellungen speichern",
51
+ secretPlaceholder: "gesetzt",
52
+ secretClear: "Löschen",
53
+ secretClearPending: "Wird beim Speichern gelöscht; danach gilt der Wert aus der Konfiguration, falls vorhanden.",
54
+ secretClearUndo: "Nicht löschen",
55
+ secretClearLabel: (label) => `${label} löschen`,
56
+ secretClearUndoLabel: (label) => `${label} nicht löschen`,
57
+ invalidates: (labels) => `Beim Ändern muss ${labels} neu eingegeben werden.`,
58
+ required: "Pflichtfeld",
59
+ invalidNumber: "Bitte eine gültige Zahl eingeben.",
60
+ outOfRange: "Der Wert liegt außerhalb des erlaubten Bereichs.",
61
+ numberMin: (min) => `Mindestens ${min}.`,
62
+ numberMax: (max) => `Höchstens ${max}.`,
63
+ numberRange: (min, max) => `Zwischen ${min} und ${max}.`,
64
+ selectPlaceholder: "Bitte auswählen",
65
+ clearHint: "Ein leeres Feld speichern entfernt den gespeicherten Wert (dann gilt Konfiguration oder Standard).",
66
+ source: {
67
+ stored: "gespeichert",
68
+ config: "aus Konfiguration",
69
+ default: "Standard",
70
+ none: "nicht gesetzt"
71
+ }
72
+ },
73
+ actions: {
74
+ confirmTitle: (label) => `${label}?`,
75
+ confirm: "Ausführen",
76
+ cancel: "Abbrechen",
77
+ runningSince: (time) => `läuft seit ${time}`,
78
+ lastCompleted: (time) => `Zuletzt erfolgreich: ${time}`,
79
+ lastFailed: (time) => `Zuletzt fehlgeschlagen: ${time}`
80
+ },
29
81
  toasts: {
30
82
  saved: "Einstellung wurde gespeichert.",
31
83
  saveFailed: "Einstellung konnte nicht gespeichert werden.",
32
- conflict: "Die Einstellungen wurden zwischenzeitlich geändert und neu geladen."
84
+ conflict: "Die Einstellungen wurden zwischenzeitlich geändert und neu geladen.",
85
+ actionCompleted: (label) => `„${label}“ wurde ausgeführt.`,
86
+ actionStarted: (label) => `„${label}“ wurde gestartet und läuft im Hintergrund.`,
87
+ actionFailed: (label) => `„${label}“ ist fehlgeschlagen.`,
88
+ actionAlreadyRunning: (label) => `„${label}“ läuft bereits; der Fortschritt wird angezeigt.`
33
89
  },
34
90
  errors: {
35
91
  unknown: "Unbekannter Fehler"
@@ -58,62 +114,679 @@ const getErrorMessage = (error) => {
58
114
  return void 0;
59
115
  };
60
116
  const sdk = new Medusa({
61
- baseUrl: __BACKEND_URL__ ?? "/",
117
+ baseUrl: (typeof __BACKEND_URL__ === "undefined" ? void 0 : __BACKEND_URL__) ?? "/",
62
118
  auth: {
63
- type: __AUTH_TYPE__ ?? "session",
64
- jwtTokenStorageKey: __JWT_TOKEN_STORAGE_KEY__ || void 0
119
+ type: (typeof __AUTH_TYPE__ === "undefined" ? void 0 : __AUTH_TYPE__) ?? "session",
120
+ jwtTokenStorageKey: (typeof __JWT_TOKEN_STORAGE_KEY__ === "undefined" ? void 0 : __JWT_TOKEN_STORAGE_KEY__) || void 0
65
121
  }
66
122
  });
67
123
  const listPluginFeatures = () => sdk.client.fetch(
68
124
  pluginFeaturesAdminPaths.get.list,
69
125
  { method: "GET" }
70
126
  );
127
+ const retrievePluginFeatures = (pluginKey) => sdk.client.fetch(
128
+ pluginFeaturesAdminPaths.get.retrieve(pluginKey),
129
+ { method: "GET" }
130
+ );
71
131
  const updatePluginFeatures = (payload) => sdk.client.fetch(
72
132
  pluginFeaturesAdminPaths.post.update,
73
133
  { method: "POST", body: payload }
74
134
  );
135
+ const updatePluginSettings = (pluginKey, payload) => sdk.client.fetch(
136
+ pluginFeaturesAdminPaths.post.updateSettings(pluginKey),
137
+ { method: "POST", body: payload }
138
+ );
139
+ const runPluginAction = (pluginKey, actionKey) => sdk.client.fetch(
140
+ pluginFeaturesAdminPaths.post.runAction(pluginKey, actionKey),
141
+ { method: "POST", body: {} }
142
+ );
143
+ const listPluginActionRuns = (pluginKey, actionKey, limit = 1) => sdk.client.fetch(
144
+ pluginFeaturesAdminPaths.get.actionRuns(pluginKey, actionKey),
145
+ { method: "GET", query: { limit } }
146
+ );
75
147
  const pluginFeatureKeys = {
76
148
  all: ["plugin-features"],
77
- list: () => [...pluginFeatureKeys.all, "list"]
149
+ list: () => [...pluginFeatureKeys.all, "list"],
150
+ details: () => [...pluginFeatureKeys.all, "detail"],
151
+ detail: (pluginKey) => [...pluginFeatureKeys.details(), pluginKey],
152
+ actionRuns: (pluginKey, actionKey) => [...pluginFeatureKeys.all, "action-runs", pluginKey, actionKey]
78
153
  };
79
154
  const isConflict = (error) => typeof error === "object" && error !== null && "status" in error && error.status === 409;
155
+ const storePlugin = (queryClient, plugin2) => {
156
+ queryClient.setQueryData(
157
+ pluginFeatureKeys.list(),
158
+ (current) => current && {
159
+ plugins: current.plugins.map(
160
+ (entry) => entry.plugin_key === plugin2.plugin_key ? plugin2 : entry
161
+ )
162
+ }
163
+ );
164
+ queryClient.setQueryData(
165
+ pluginFeatureKeys.detail(plugin2.plugin_key),
166
+ { plugin: plugin2 }
167
+ );
168
+ };
169
+ const onSaveError = async (queryClient, error) => {
170
+ if (isConflict(error)) {
171
+ toast.error(texts.toasts.conflict);
172
+ } else {
173
+ toast.error(texts.toasts.saveFailed, {
174
+ description: getErrorMessage(error) ?? texts.errors.unknown
175
+ });
176
+ }
177
+ await queryClient.invalidateQueries({ queryKey: pluginFeatureKeys.all });
178
+ };
80
179
  function usePluginFeatures() {
81
180
  return useQuery({
82
181
  queryKey: pluginFeatureKeys.list(),
83
182
  queryFn: listPluginFeatures
84
183
  });
85
184
  }
185
+ function usePluginFeature(pluginKey) {
186
+ const queryClient = useQueryClient();
187
+ return useQuery({
188
+ queryKey: pluginFeatureKeys.detail(pluginKey ?? ""),
189
+ queryFn: () => retrievePluginFeatures(pluginKey),
190
+ enabled: Boolean(pluginKey),
191
+ placeholderData: () => {
192
+ const plugin2 = queryClient.getQueryData(pluginFeatureKeys.list())?.plugins.find((entry) => entry.plugin_key === pluginKey);
193
+ return plugin2 ? { plugin: plugin2 } : void 0;
194
+ }
195
+ });
196
+ }
86
197
  function useUpdatePluginFeatures() {
87
198
  const queryClient = useQueryClient();
88
199
  return useMutation({
89
200
  mutationFn: updatePluginFeatures,
90
201
  onSuccess: ({ plugin: plugin2 }) => {
202
+ storePlugin(queryClient, plugin2);
203
+ toast.success(texts.toasts.saved);
204
+ },
205
+ onError: (error) => onSaveError(queryClient, error)
206
+ });
207
+ }
208
+ function useUpdatePluginSettings(pluginKey) {
209
+ const queryClient = useQueryClient();
210
+ return useMutation({
211
+ mutationFn: (payload) => updatePluginSettings(pluginKey, payload),
212
+ onSuccess: ({ plugin: plugin2 }) => {
213
+ storePlugin(queryClient, plugin2);
214
+ toast.success(texts.toasts.saved);
215
+ },
216
+ onError: (error) => onSaveError(queryClient, error)
217
+ });
218
+ }
219
+ const PLUGIN_ACTION_RUN_POLL_MS = 2e3;
220
+ function useLatestPluginActionRun(pluginKey, actionKey) {
221
+ return useQuery({
222
+ queryKey: pluginFeatureKeys.actionRuns(pluginKey, actionKey),
223
+ queryFn: () => listPluginActionRuns(pluginKey, actionKey, 1),
224
+ select: (data) => data.runs[0],
225
+ refetchInterval: (query) => query.state.data?.runs[0]?.status === "running" ? PLUGIN_ACTION_RUN_POLL_MS : false
226
+ });
227
+ }
228
+ function useRunPluginAction(pluginKey) {
229
+ const queryClient = useQueryClient();
230
+ return useMutation({
231
+ mutationFn: (action) => runPluginAction(pluginKey, action.key),
232
+ onSuccess: ({ action: run }, action) => {
91
233
  queryClient.setQueryData(
92
- pluginFeatureKeys.list(),
93
- (current) => current && {
94
- plugins: current.plugins.map(
95
- (entry) => entry.plugin_key === plugin2.plugin_key ? plugin2 : entry
96
- )
97
- }
234
+ pluginFeatureKeys.actionRuns(pluginKey, action.key),
235
+ { runs: [run.run] }
98
236
  );
99
- toast.success(texts.toasts.saved);
237
+ if (run.status === "started") {
238
+ toast.info(texts.toasts.actionStarted(action.label));
239
+ } else {
240
+ toast.success(texts.toasts.actionCompleted(action.label));
241
+ }
100
242
  },
101
- onError: async (error) => {
243
+ onError: async (error, action) => {
102
244
  if (isConflict(error)) {
103
- toast.error(texts.toasts.conflict);
245
+ toast.info(texts.toasts.actionAlreadyRunning(action.label));
104
246
  } else {
105
- toast.error(texts.toasts.saveFailed, {
247
+ toast.error(texts.toasts.actionFailed(action.label), {
106
248
  description: getErrorMessage(error) ?? texts.errors.unknown
107
249
  });
108
250
  }
109
- await queryClient.invalidateQueries({ queryKey: pluginFeatureKeys.list() });
251
+ await queryClient.invalidateQueries({
252
+ queryKey: pluginFeatureKeys.actionRuns(pluginKey, action.key)
253
+ });
110
254
  }
111
255
  });
112
256
  }
257
+ const PLUGIN_HUB_PATH = "/settings/hartl-services";
258
+ const pluginDetailPath = (pluginKey) => `${PLUGIN_HUB_PATH}/plugin/${encodeURIComponent(pluginKey)}`;
259
+ const initialEntry = (setting) => {
260
+ if (setting.type === "secret") {
261
+ return "";
262
+ }
263
+ if (setting.type === "boolean") {
264
+ return setting.value === true;
265
+ }
266
+ return setting.value === null ? "" : String(setting.value);
267
+ };
268
+ const initialSettingsDraft = (settings) => Object.fromEntries(
269
+ settings.map((setting) => [setting.key, initialEntry(setting)])
270
+ );
271
+ const outsideBounds = (setting, value) => setting.min !== null && value < setting.min || setting.max !== null && value > setting.max;
272
+ const numberEntryValue = (entry) => {
273
+ const value = Number(entry.trim());
274
+ return Number.isFinite(value) ? value : null;
275
+ };
276
+ const invalidDraftKeys = (settings, draft, unparsable = []) => settings.filter((setting) => {
277
+ if (setting.type !== "number") {
278
+ return false;
279
+ }
280
+ if (unparsable.includes(setting.key)) {
281
+ return true;
282
+ }
283
+ const entry = draft[setting.key];
284
+ if (typeof entry !== "string" || entry.trim() === "") {
285
+ return false;
286
+ }
287
+ const value = numberEntryValue(entry);
288
+ return value === null || outsideBounds(setting, value);
289
+ }).map((setting) => setting.key);
290
+ const changedSettingValues = (settings, draft, unparsable = []) => {
291
+ const initial = initialSettingsDraft(settings);
292
+ const values = {};
293
+ for (const setting of settings) {
294
+ const entry = draft[setting.key];
295
+ if (entry === void 0 || entry === initial[setting.key]) {
296
+ continue;
297
+ }
298
+ if (setting.type === "secret") {
299
+ if (entry === null) {
300
+ if (setting.source === "stored") {
301
+ values[setting.key] = null;
302
+ }
303
+ } else if (typeof entry === "string" && entry !== "") {
304
+ values[setting.key] = entry;
305
+ }
306
+ continue;
307
+ }
308
+ if (setting.type === "boolean") {
309
+ values[setting.key] = entry === true;
310
+ continue;
311
+ }
312
+ if (unparsable.includes(setting.key)) {
313
+ continue;
314
+ }
315
+ const text = typeof entry === "string" ? entry.trim() : "";
316
+ if (text === "") {
317
+ if (setting.source === "stored") {
318
+ values[setting.key] = null;
319
+ }
320
+ } else if (setting.type === "number") {
321
+ const value = numberEntryValue(text);
322
+ if (value !== null) {
323
+ values[setting.key] = value;
324
+ }
325
+ } else {
326
+ values[setting.key] = text;
327
+ }
328
+ }
329
+ return values;
330
+ };
331
+ function usePluginSettingsForm(plugin2) {
332
+ const mutation = useUpdatePluginSettings(plugin2.plugin_key);
333
+ const [draft, setDraft] = useState(
334
+ () => initialSettingsDraft(plugin2.settings)
335
+ );
336
+ const [unparsable, setUnparsable] = useState([]);
337
+ useEffect(() => {
338
+ setDraft(initialSettingsDraft(plugin2.settings));
339
+ setUnparsable([]);
340
+ }, [plugin2.settings, plugin2.settings_version]);
341
+ const changes = useMemo(
342
+ () => changedSettingValues(plugin2.settings, draft, unparsable),
343
+ [plugin2.settings, draft, unparsable]
344
+ );
345
+ const invalid = useMemo(
346
+ () => invalidDraftKeys(plugin2.settings, draft, unparsable),
347
+ [plugin2.settings, draft, unparsable]
348
+ );
349
+ const dirty = Object.keys(changes).length > 0;
350
+ const setValue = (key, value, badInput = false) => {
351
+ setDraft((current) => ({ ...current, [key]: value }));
352
+ setUnparsable(
353
+ (current) => badInput ? current.includes(key) ? current : [...current, key] : current.filter((entry) => entry !== key)
354
+ );
355
+ };
356
+ const submit = (event) => {
357
+ event.preventDefault();
358
+ if (!dirty || invalid.length > 0 || mutation.isPending) {
359
+ return;
360
+ }
361
+ mutation.mutate({
362
+ expected_version: plugin2.settings_version,
363
+ values: changes
364
+ });
365
+ };
366
+ return {
367
+ draft,
368
+ setValue,
369
+ dirty,
370
+ invalid,
371
+ submit,
372
+ mutation
373
+ };
374
+ }
375
+ const fieldId = (pluginKey, key) => `plugin-setting-${pluginKey}-${key}`;
376
+ const missingLabels = (plugin2) => plugin2.missing.map(
377
+ (key) => plugin2.settings.find((setting) => setting.key === key)?.label ?? key
378
+ ).join(", ");
379
+ const invalidatesLabels = (plugin2, setting) => setting.invalidates.map(
380
+ (key) => plugin2.settings.find((candidate) => candidate.key === key)?.label ?? key
381
+ ).join(", ");
382
+ const boundsText = (setting) => setting.min !== null && setting.max !== null ? texts.settings.numberRange(setting.min, setting.max) : setting.min !== null ? texts.settings.numberMin(setting.min) : setting.max !== null ? texts.settings.numberMax(setting.max) : void 0;
383
+ const SettingField = ({
384
+ pluginKey,
385
+ setting,
386
+ invalidatesLabels: invalidatesLabels2,
387
+ value,
388
+ invalid,
389
+ disabled,
390
+ onChange
391
+ }) => {
392
+ const id = fieldId(pluginKey, setting.key);
393
+ const hintId = `${id}-hint`;
394
+ const clearPending = setting.type === "secret" && value === null;
395
+ const bounds = setting.type === "number" ? boundsText(setting) : void 0;
396
+ return /* @__PURE__ */ jsxs("div", { className: "grid gap-2", children: [
397
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
398
+ /* @__PURE__ */ jsxs(Label, { htmlFor: id, size: "small", weight: "plus", children: [
399
+ setting.label,
400
+ setting.required && /* @__PURE__ */ jsxs("span", { className: "text-ui-fg-muted", children: [
401
+ " (",
402
+ texts.settings.required,
403
+ ")"
404
+ ] })
405
+ ] }),
406
+ /* @__PURE__ */ jsx(Text, { size: "xsmall", className: "text-ui-fg-muted shrink-0", children: texts.settings.source[setting.source] })
407
+ ] }),
408
+ setting.type === "boolean" ? /* @__PURE__ */ jsx(
409
+ Switch,
410
+ {
411
+ id,
412
+ "aria-describedby": hintId,
413
+ checked: value === true,
414
+ disabled,
415
+ onCheckedChange: (checked) => onChange(checked === true)
416
+ }
417
+ ) : setting.type === "secret" ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
418
+ /* @__PURE__ */ jsx(
419
+ Input,
420
+ {
421
+ id,
422
+ "aria-describedby": hintId,
423
+ className: "flex-1",
424
+ type: "password",
425
+ autoComplete: "new-password",
426
+ placeholder: setting.is_set ? texts.settings.secretPlaceholder : "",
427
+ value: typeof value === "string" ? value : "",
428
+ disabled: disabled || clearPending,
429
+ onChange: (event) => onChange(event.target.value)
430
+ }
431
+ ),
432
+ setting.source === "stored" && /* @__PURE__ */ jsx(
433
+ Button,
434
+ {
435
+ type: "button",
436
+ size: "small",
437
+ variant: "secondary",
438
+ disabled,
439
+ "aria-label": clearPending ? texts.settings.secretClearUndoLabel(setting.label) : texts.settings.secretClearLabel(setting.label),
440
+ onClick: () => onChange(clearPending ? "" : null),
441
+ children: clearPending ? texts.settings.secretClearUndo : texts.settings.secretClear
442
+ }
443
+ )
444
+ ] }) : setting.type === "select" ? /* @__PURE__ */ jsxs(
445
+ Select,
446
+ {
447
+ value: typeof value === "string" ? value : "",
448
+ disabled,
449
+ onValueChange: (next) => onChange(next),
450
+ children: [
451
+ /* @__PURE__ */ jsx(Select.Trigger, { id, "aria-describedby": hintId, children: /* @__PURE__ */ jsx(Select.Value, { placeholder: texts.settings.selectPlaceholder }) }),
452
+ /* @__PURE__ */ jsx(Select.Content, { children: setting.options.map((option) => /* @__PURE__ */ jsx(Select.Item, { value: option.value, children: option.label }, option.value)) })
453
+ ]
454
+ }
455
+ ) : setting.type === "number" ? /* @__PURE__ */ jsx(
456
+ Input,
457
+ {
458
+ id,
459
+ "aria-describedby": hintId,
460
+ "aria-invalid": invalid,
461
+ type: "number",
462
+ step: "any",
463
+ min: setting.min ?? void 0,
464
+ max: setting.max ?? void 0,
465
+ value: typeof value === "string" ? value : "",
466
+ disabled,
467
+ onChange: (event) => onChange(event.target.value, event.target.validity.badInput)
468
+ }
469
+ ) : /* @__PURE__ */ jsx(
470
+ Input,
471
+ {
472
+ id,
473
+ "aria-describedby": hintId,
474
+ "aria-invalid": invalid,
475
+ type: setting.type === "url" ? "url" : "text",
476
+ value: typeof value === "string" ? value : "",
477
+ disabled,
478
+ onChange: (event) => onChange(event.target.value)
479
+ }
480
+ ),
481
+ /* @__PURE__ */ jsxs("div", { id: hintId, className: "grid gap-1", children: [
482
+ setting.description && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: setting.description }),
483
+ clearPending && /* @__PURE__ */ jsx(Hint, { variant: "info", children: texts.settings.secretClearPending }),
484
+ invalidatesLabels2 && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted", children: texts.settings.invalidates(invalidatesLabels2) }),
485
+ bounds && !invalid && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted", children: bounds }),
486
+ invalid && /* @__PURE__ */ jsx(Hint, { variant: "error", children: bounds ?? texts.settings.invalidNumber })
487
+ ] })
488
+ ] });
489
+ };
490
+ const PluginSettingsForm = ({ plugin: plugin2 }) => {
491
+ const form = usePluginSettingsForm(plugin2);
492
+ const pending = form.mutation.isPending;
493
+ const hasTextFields = plugin2.settings.some(
494
+ (setting) => setting.type !== "boolean" && setting.type !== "secret" && setting.type !== "select"
495
+ );
496
+ return /* @__PURE__ */ jsxs(Container, { className: "divide-y p-0", "aria-busy": pending, children: [
497
+ /* @__PURE__ */ jsxs("div", { className: "px-6 py-4", children: [
498
+ /* @__PURE__ */ jsx(Heading, { level: "h2", children: texts.sections.settings }),
499
+ hasTextFields && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: texts.settings.clearHint })
500
+ ] }),
501
+ /* @__PURE__ */ jsxs("form", { onSubmit: form.submit, className: "grid gap-5 px-6 py-5", children: [
502
+ !plugin2.configured && /* @__PURE__ */ jsx(Alert, { variant: "warning", children: texts.settings.missing(missingLabels(plugin2)) }),
503
+ plugin2.settings.map((setting) => /* @__PURE__ */ jsx(
504
+ SettingField,
505
+ {
506
+ pluginKey: plugin2.plugin_key,
507
+ setting,
508
+ invalidatesLabels: invalidatesLabels(plugin2, setting),
509
+ value: form.draft[setting.key],
510
+ invalid: form.invalid.includes(setting.key),
511
+ disabled: pending,
512
+ onChange: (value, badInput) => form.setValue(setting.key, value, badInput)
513
+ },
514
+ setting.key
515
+ )),
516
+ /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
517
+ Button,
518
+ {
519
+ type: "submit",
520
+ size: "small",
521
+ isLoading: pending,
522
+ disabled: !form.dirty || form.invalid.length > 0 || pending,
523
+ children: texts.settings.save
524
+ }
525
+ ) })
526
+ ] })
527
+ ] });
528
+ };
529
+ const PluginStatusChips = ({ plugin: plugin2 }) => /* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
530
+ plugin2.switchable && /* @__PURE__ */ jsx(StatusBadge, { color: plugin2.enabled ? "green" : "grey", children: plugin2.enabled ? texts.chips.enabled : texts.chips.disabled }),
531
+ plugin2.settings.length > 0 && /* @__PURE__ */ jsx(StatusBadge, { color: plugin2.configured ? "green" : "orange", children: plugin2.configured ? texts.chips.configured : texts.chips.notConfigured(missingLabels(plugin2)) })
532
+ ] });
533
+ const PluginHubCard = ({ plugin: plugin2 }) => {
534
+ const mutation = useUpdatePluginFeatures();
535
+ const pending = mutation.isPending;
536
+ const toggle = (enabled) => {
537
+ mutation.mutate({
538
+ plugin_key: plugin2.plugin_key,
539
+ expected_version: plugin2.version,
540
+ enabled,
541
+ features: Object.fromEntries(
542
+ plugin2.features.map((feature) => [feature.key, feature.enabled])
543
+ )
544
+ });
545
+ };
546
+ return /* @__PURE__ */ jsxs(
547
+ Container,
548
+ {
549
+ className: "flex flex-wrap items-start justify-between gap-4 px-6 py-4",
550
+ "aria-busy": pending,
551
+ children: [
552
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
553
+ /* @__PURE__ */ jsx(Heading, { level: "h2", children: plugin2.label }),
554
+ plugin2.description && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: plugin2.description }),
555
+ /* @__PURE__ */ jsx(PluginStatusChips, { plugin: plugin2 }),
556
+ pending && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted mt-1", children: texts.hints.saving })
557
+ ] }),
558
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [
559
+ plugin2.switchable && /* @__PURE__ */ jsx(
560
+ Switch,
561
+ {
562
+ "aria-label": texts.plugin.switchLabel(plugin2.label),
563
+ checked: plugin2.enabled,
564
+ disabled: pending,
565
+ onCheckedChange: (checked) => toggle(checked === true)
566
+ }
567
+ ),
568
+ /* @__PURE__ */ jsx(Button, { size: "small", variant: "secondary", asChild: true, children: /* @__PURE__ */ jsx(
569
+ Link,
570
+ {
571
+ to: plugin2.admin_page ?? pluginDetailPath(plugin2.plugin_key),
572
+ "aria-label": texts.plugin.openLabel(plugin2.label),
573
+ children: texts.plugin.open
574
+ }
575
+ ) })
576
+ ] })
577
+ ]
578
+ }
579
+ );
580
+ };
581
+ const PluginFeaturesSettingsPage = () => {
582
+ const query = usePluginFeatures();
583
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-3", children: [
584
+ /* @__PURE__ */ jsxs(Container, { className: "px-6 py-4", children: [
585
+ /* @__PURE__ */ jsx(Heading, { level: "h1", children: texts.page.title }),
586
+ /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.page.description })
587
+ ] }),
588
+ query.isPending ? /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.states.loading }) }) : query.isError ? /* @__PURE__ */ jsxs(Container, { className: "flex flex-wrap items-center justify-between gap-3", children: [
589
+ /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-error", children: texts.states.error }),
590
+ /* @__PURE__ */ jsx(
591
+ Button,
592
+ {
593
+ size: "small",
594
+ variant: "secondary",
595
+ isLoading: query.isFetching,
596
+ onClick: () => void query.refetch(),
597
+ children: texts.states.retry
598
+ }
599
+ )
600
+ ] }) : query.data.plugins.length === 0 ? /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.states.empty }) }) : [
601
+ ...query.data.plugins.filter((plugin2) => !plugin2.switchable),
602
+ ...query.data.plugins.filter((plugin2) => plugin2.switchable)
603
+ ].map((plugin2) => /* @__PURE__ */ jsx(PluginHubCard, { plugin: plugin2 }, plugin2.plugin_key))
604
+ ] });
605
+ };
606
+ const config = defineRouteConfig({
607
+ label: texts.menu.label
608
+ });
609
+ const SUMMARY_MAX_LENGTH = 200;
610
+ const cut = (text) => text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH - 1)}…` : text;
611
+ const isPrimitive = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
612
+ const summarizeActionResult = (result) => {
613
+ if (result === null || result === void 0) {
614
+ return null;
615
+ }
616
+ if (isPrimitive(result)) {
617
+ return cut(String(result));
618
+ }
619
+ if (typeof result !== "object" || Array.isArray(result)) {
620
+ return null;
621
+ }
622
+ const record = result;
623
+ for (const key of ["summary", "message"]) {
624
+ const value = record[key];
625
+ if (typeof value === "string" && value.trim()) {
626
+ return cut(value.trim());
627
+ }
628
+ }
629
+ const parts = Object.entries(record).filter(
630
+ (entry) => isPrimitive(entry[1])
631
+ ).map(([key, value]) => `${key}: ${String(value)}`);
632
+ return parts.length > 0 ? cut(parts.join(", ")) : null;
633
+ };
634
+ const progressPercent = (progress) => {
635
+ if (!progress || progress.total === null || progress.total <= 0) {
636
+ return null;
637
+ }
638
+ return Math.max(
639
+ 0,
640
+ Math.min(100, Math.round(progress.current / progress.total * 100))
641
+ );
642
+ };
643
+ const progressCount = (progress) => {
644
+ if (!progress) {
645
+ return null;
646
+ }
647
+ return progress.total === null ? String(progress.current) : `${progress.current} / ${progress.total}`;
648
+ };
649
+ const formatRunTime = (iso) => {
650
+ if (!iso) {
651
+ return "";
652
+ }
653
+ const date = new Date(iso);
654
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString("de-AT", { dateStyle: "short", timeStyle: "short" });
655
+ };
656
+ const runTransition = (previous, current) => {
657
+ if (!previous || !current || previous.id !== current.id || previous.status !== "running" || current.status === "running") {
658
+ return null;
659
+ }
660
+ return current.status;
661
+ };
662
+ const RunProgress = ({ run }) => {
663
+ const percent = progressPercent(run.progress);
664
+ const count = progressCount(run.progress);
665
+ const label = run.progress?.label;
666
+ return /* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-col gap-y-1", role: "status", children: [
667
+ (label || count) && /* @__PURE__ */ jsx(Text, { size: "small", children: [label, count].filter(Boolean).join(" · ") }),
668
+ percent !== null && /* @__PURE__ */ jsx(
669
+ "div",
670
+ {
671
+ className: "bg-ui-border-base w-full overflow-hidden rounded-full",
672
+ style: { height: 6 },
673
+ role: "progressbar",
674
+ "aria-valuemin": 0,
675
+ "aria-valuemax": 100,
676
+ "aria-valuenow": percent,
677
+ children: /* @__PURE__ */ jsx(
678
+ "div",
679
+ {
680
+ className: "bg-ui-fg-interactive h-full rounded-full",
681
+ style: { width: `${percent}%`, height: "100%" }
682
+ }
683
+ )
684
+ }
685
+ ),
686
+ /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted", children: texts.actions.runningSince(formatRunTime(run.started_at)) })
687
+ ] });
688
+ };
689
+ const RunStatusLine = ({ run }) => {
690
+ if (run.status === "completed") {
691
+ const summary = summarizeActionResult(run.result);
692
+ return /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
693
+ /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: texts.actions.lastCompleted(
694
+ formatRunTime(run.finished_at ?? run.started_at)
695
+ ) }),
696
+ summary && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: summary })
697
+ ] });
698
+ }
699
+ return /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
700
+ /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-error", children: texts.actions.lastFailed(
701
+ formatRunTime(run.finished_at ?? run.started_at)
702
+ ) }),
703
+ run.message && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-error", children: run.message })
704
+ ] });
705
+ };
706
+ const PluginActionRow = ({
707
+ pluginKey,
708
+ action,
709
+ starting,
710
+ blocked,
711
+ onRun
712
+ }) => {
713
+ const { data: run } = useLatestPluginActionRun(pluginKey, action.key);
714
+ const previous = useRef(void 0);
715
+ const running = run?.status === "running";
716
+ useEffect(() => {
717
+ const transition = runTransition(previous.current, run);
718
+ previous.current = run;
719
+ if (transition === "completed") {
720
+ toast.success(texts.toasts.actionCompleted(action.label), {
721
+ description: summarizeActionResult(run?.result) ?? void 0
722
+ });
723
+ } else if (transition === "failed") {
724
+ toast.error(texts.toasts.actionFailed(action.label), {
725
+ description: run?.message ?? texts.errors.unknown
726
+ });
727
+ }
728
+ }, [run, action.label]);
729
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-4 px-6 py-4", children: [
730
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
731
+ /* @__PURE__ */ jsx(Text, { size: "small", weight: "plus", children: action.label }),
732
+ action.description && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: action.description }),
733
+ run && (running ? /* @__PURE__ */ jsx(RunProgress, { run }) : /* @__PURE__ */ jsx(RunStatusLine, { run }))
734
+ ] }),
735
+ /* @__PURE__ */ jsx(
736
+ Button,
737
+ {
738
+ size: "small",
739
+ variant: "secondary",
740
+ className: "shrink-0",
741
+ isLoading: starting || running,
742
+ disabled: blocked || starting || running,
743
+ onClick: () => onRun(action),
744
+ children: action.label
745
+ }
746
+ )
747
+ ] });
748
+ };
749
+ const PluginActionsSection = ({ plugin: plugin2 }) => {
750
+ const mutation = useRunPluginAction(plugin2.plugin_key);
751
+ const prompt = usePrompt();
752
+ const startingKey = mutation.isPending ? mutation.variables?.key : void 0;
753
+ const run = async (action) => {
754
+ if (mutation.isPending) {
755
+ return;
756
+ }
757
+ if (action.confirm) {
758
+ const confirmed = await prompt({
759
+ title: texts.actions.confirmTitle(action.label),
760
+ description: action.confirm,
761
+ confirmText: texts.actions.confirm,
762
+ cancelText: texts.actions.cancel,
763
+ variant: "confirmation"
764
+ });
765
+ if (!confirmed) {
766
+ return;
767
+ }
768
+ }
769
+ mutation.mutate(action);
770
+ };
771
+ return /* @__PURE__ */ jsxs(Container, { className: "divide-y p-0", children: [
772
+ /* @__PURE__ */ jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsx(Heading, { level: "h2", children: texts.sections.actions }) }),
773
+ plugin2.actions.map((action) => /* @__PURE__ */ jsx(
774
+ PluginActionRow,
775
+ {
776
+ pluginKey: plugin2.plugin_key,
777
+ action,
778
+ starting: startingKey === action.key,
779
+ blocked: mutation.isPending && startingKey !== action.key,
780
+ onRun: (entry) => void run(entry)
781
+ },
782
+ action.key
783
+ ))
784
+ ] });
785
+ };
113
786
  const configuredFeatures = (plugin2) => Object.fromEntries(
114
787
  plugin2.features.map((feature) => [feature.key, feature.enabled])
115
788
  );
116
- const PluginFeaturesCard = ({ plugin: plugin2 }) => {
789
+ const PluginFeaturesCard = ({ plugin: plugin2, title }) => {
117
790
  const mutation = useUpdatePluginFeatures();
118
791
  const pending = mutation.isPending;
119
792
  const labels = new Map(
@@ -132,12 +805,12 @@ const PluginFeaturesCard = ({ plugin: plugin2 }) => {
132
805
  return /* @__PURE__ */ jsxs(Container, { className: "divide-y p-0", "aria-busy": pending, children: [
133
806
  /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-4 px-6 py-4", children: [
134
807
  /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
135
- /* @__PURE__ */ jsx(Heading, { level: "h2", children: plugin2.label }),
136
- plugin2.description && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: plugin2.description }),
808
+ /* @__PURE__ */ jsx(Heading, { level: "h2", children: title ?? plugin2.label }),
809
+ title === void 0 && plugin2.description && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-subtle", children: plugin2.description }),
137
810
  !plugin2.enabled && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted mt-1", children: texts.hints.pluginDisabled }),
138
811
  pending && /* @__PURE__ */ jsx(Text, { size: "small", className: "text-ui-fg-muted mt-1", children: texts.hints.saving })
139
812
  ] }),
140
- /* @__PURE__ */ jsx(
813
+ plugin2.switchable && /* @__PURE__ */ jsx(
141
814
  Switch,
142
815
  {
143
816
  id: pluginSwitchId,
@@ -197,14 +870,41 @@ const PluginFeaturesCard = ({ plugin: plugin2 }) => {
197
870
  })
198
871
  ] });
199
872
  };
200
- const PluginFeaturesSettingsPage = () => {
201
- const query = usePluginFeatures();
873
+ const PluginFeatureSections = ({ pluginKey }) => {
874
+ const plugin2 = usePluginFeature(pluginKey).data?.plugin;
875
+ if (!plugin2) {
876
+ return null;
877
+ }
878
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
879
+ (plugin2.switchable || plugin2.features.length > 0) && /* @__PURE__ */ jsx(PluginFeaturesCard, { plugin: plugin2, title: texts.sections.features }),
880
+ plugin2.settings.length > 0 && /* @__PURE__ */ jsx(PluginSettingsForm, { plugin: plugin2 }),
881
+ plugin2.actions.length > 0 && /* @__PURE__ */ jsx(PluginActionsSection, { plugin: plugin2 })
882
+ ] });
883
+ };
884
+ const isNotFound = (error) => typeof error === "object" && error !== null && error.status === 404;
885
+ const PluginPageShell = ({ pluginKey, children }) => {
886
+ const query = usePluginFeature(pluginKey);
887
+ const plugin2 = query.data?.plugin;
202
888
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-y-3", children: [
203
- /* @__PURE__ */ jsxs(Container, { className: "px-6 py-4", children: [
204
- /* @__PURE__ */ jsx(Heading, { level: "h1", children: texts.page.title }),
205
- /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.page.description })
206
- ] }),
207
- query.isPending ? /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.states.loading }) }) : query.isError ? /* @__PURE__ */ jsxs(Container, { className: "flex flex-wrap items-center justify-between gap-3", children: [
889
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsxs(
890
+ Link,
891
+ {
892
+ to: PLUGIN_HUB_PATH,
893
+ className: "text-ui-fg-subtle hover:text-ui-fg-base txt-compact-small-plus",
894
+ children: [
895
+ "← ",
896
+ texts.detail.back
897
+ ]
898
+ }
899
+ ) }),
900
+ plugin2 ? /* @__PURE__ */ jsxs(Fragment, { children: [
901
+ /* @__PURE__ */ jsxs(Container, { className: "px-6 py-4", children: [
902
+ /* @__PURE__ */ jsx(Heading, { level: "h1", children: plugin2.label }),
903
+ plugin2.description && /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: plugin2.description }),
904
+ /* @__PURE__ */ jsx(PluginStatusChips, { plugin: plugin2 })
905
+ ] }),
906
+ children
907
+ ] }) : query.isPending ? /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.states.loading }) }) : isNotFound(query.error) ? /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.states.notFound }) }) : /* @__PURE__ */ jsxs(Container, { className: "flex flex-wrap items-center justify-between gap-3", children: [
208
908
  /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-error", children: texts.states.error }),
209
909
  /* @__PURE__ */ jsx(
210
910
  Button,
@@ -216,12 +916,13 @@ const PluginFeaturesSettingsPage = () => {
216
916
  children: texts.states.retry
217
917
  }
218
918
  )
219
- ] }) : query.data.plugins.length === 0 ? /* @__PURE__ */ jsx(Container, { children: /* @__PURE__ */ jsx(Text, { className: "text-ui-fg-subtle", children: texts.states.empty }) }) : query.data.plugins.map((plugin2) => /* @__PURE__ */ jsx(PluginFeaturesCard, { plugin: plugin2 }, plugin2.plugin_key))
919
+ ] })
220
920
  ] });
221
921
  };
222
- const config = defineRouteConfig({
223
- label: texts.menu.label
224
- });
922
+ const PluginDetailPage = () => {
923
+ const { plugin_key: pluginKey = "" } = useParams();
924
+ return /* @__PURE__ */ jsx(PluginPageShell, { pluginKey, children: /* @__PURE__ */ jsx(PluginFeatureSections, { pluginKey }) });
925
+ };
225
926
  const widgetModule = { widgets: [] };
226
927
  const routeModule = {
227
928
  routes: [
@@ -229,6 +930,10 @@ const routeModule = {
229
930
  Component: PluginFeaturesSettingsPage,
230
931
  path: "/settings/hartl-services",
231
932
  handle: { label: config.label, translationNs: config.translationNs }
933
+ },
934
+ {
935
+ Component: PluginDetailPage,
936
+ path: "/settings/hartl-services/plugin/:plugin_key"
232
937
  }
233
938
  ]
234
939
  };