@revenexx/studio-integrations 0.6.0 → 0.7.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 (110) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +31 -0
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +29 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +38 -37
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -58
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +10 -9
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -3
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +16 -15
  13. package/dist/runtime/components/Integrations/GroupFrameView.vue +29 -19
  14. package/dist/runtime/components/Integrations/Layout.vue +28 -3
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +15 -14
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +9 -8
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +43 -42
  18. package/dist/runtime/components/Integrations/NodeInspector.vue +225 -216
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +1 -1
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +3 -2
  21. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +1 -1
  22. package/dist/runtime/components/Integrations/NodePalette.vue +30 -29
  23. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  24. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  25. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  26. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  27. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  28. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  29. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  30. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  31. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  32. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  33. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  34. package/dist/runtime/components/Integrations/StickyNoteView.vue +37 -23
  35. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  36. package/dist/runtime/components/Integrations/TemplateCard.vue +16 -6
  37. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +10 -9
  38. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  39. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +36 -35
  40. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  41. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  42. package/dist/runtime/components/Integrations/WorkflowEditor.vue +82 -79
  43. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +24 -23
  44. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  45. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +19 -20
  46. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +52 -39
  47. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +20 -19
  48. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  49. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +12 -9
  50. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +27 -26
  51. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  52. package/dist/runtime/composables/integrationsApi.js +4 -2
  53. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  54. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -4
  55. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  56. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  57. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  58. package/dist/runtime/composables/useIntegrationsNodes.js +3 -1
  59. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  60. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  61. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  62. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  63. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  64. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  65. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  66. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  67. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  68. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  69. package/dist/runtime/composables/useIntegrationsTriggers.js +4 -2
  70. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -3
  71. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  72. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  73. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  74. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  75. package/dist/runtime/data/integrationTemplates.js +7 -1
  76. package/dist/runtime/data/payloadForm.js +2 -1
  77. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  78. package/dist/runtime/i18n/messages/de.json +1207 -0
  79. package/dist/runtime/i18n/messages/en.json +1207 -0
  80. package/dist/runtime/i18n/translate.d.ts +83 -0
  81. package/dist/runtime/i18n/translate.js +41 -0
  82. package/dist/runtime/pages/integrations/audit.vue +52 -34
  83. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  84. package/dist/runtime/pages/integrations/credentials.vue +70 -52
  85. package/dist/runtime/pages/integrations/index.vue +50 -56
  86. package/dist/runtime/pages/integrations/runs.vue +33 -21
  87. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  88. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +77 -68
  90. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +164 -99
  92. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  93. package/dist/runtime/plugins/locale.d.ts +10 -0
  94. package/dist/runtime/plugins/locale.js +6 -0
  95. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  96. package/dist/runtime/plugins/realtime.client.js +4 -0
  97. package/dist/runtime/utils/credentialState.d.ts +24 -1
  98. package/dist/runtime/utils/credentialState.js +26 -11
  99. package/dist/runtime/utils/localize.d.ts +18 -5
  100. package/dist/runtime/utils/localize.js +32 -9
  101. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  102. package/dist/runtime/utils/pollBudget.js +17 -0
  103. package/dist/runtime/utils/schedule.d.ts +0 -25
  104. package/dist/runtime/utils/schedule.js +37 -25
  105. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  106. package/dist/runtime/utils/statusStyle.js +9 -1
  107. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  108. package/dist/runtime/utils/validationFindings.js +4 -3
  109. package/dist/types.d.mts +2 -6
  110. package/package.json +3 -2
@@ -24,6 +24,7 @@ import NodeMark from "../../../components/Integrations/NodeMark.vue";
24
24
  import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
25
25
  import { describeCron } from "../../../utils/schedule";
26
26
  const SELECT_LATER = "__later__";
27
+ const t = useIntegrationsT();
27
28
  const route = useRoute();
28
29
  const router = useRouter();
29
30
  const { get, load, loaded, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
@@ -32,7 +33,7 @@ const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
32
33
  const { ofType, load: loadCredentials } = useIntegrationsCredentials();
33
34
  const slug = computed(() => String(route.params.slug));
34
35
  const tpl = computed(() => get(slug.value));
35
- useHead({ title: () => `${tpl.value?.name ?? "Template"} \xB7 Integration Studio` });
36
+ useHead({ title: () => t("integration.templates.detail.head.title", { name: tpl.value?.name ?? t("integration.templates.detail.head.fallbackName") }) });
36
37
  const reqs = ref(null);
37
38
  onMounted(async () => {
38
39
  await load();
@@ -50,7 +51,7 @@ const steps = computed(() => {
50
51
  if (!blob?.nodes.length) return [];
51
52
  const nodeById = new Map(blob.nodes.map((n) => [n.id, n]));
52
53
  const happyEdges = (blob.edges ?? []).filter((e) => e.from.output !== "error");
53
- const triggerHandles = new Set((blob.triggers ?? []).map((t) => t.id));
54
+ const triggerHandles = new Set((blob.triggers ?? []).map((t2) => t2.id));
54
55
  const incoming = /* @__PURE__ */ new Map();
55
56
  for (const e of happyEdges) {
56
57
  if (!triggerHandles.has(e.from.nodeId)) incoming.set(e.to.nodeId, (incoming.get(e.to.nodeId) ?? 0) + 1);
@@ -90,28 +91,28 @@ function firstSentence(text) {
90
91
  return idx === -1 ? text : text.slice(0, idx + 1);
91
92
  }
92
93
  const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
93
- const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((t) => {
94
- let label = t.type.charAt(0).toUpperCase() + t.type.slice(1);
95
- if (t.type === "schedule" && t.config?.cron) {
96
- const when = describeCron(String(t.config.cron)) ?? String(t.config.cron);
97
- label = `Schedule \xB7 ${when}${t.config.timezone ? ` (${t.config.timezone})` : ""}`;
94
+ const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((trig) => {
95
+ let label = trig.type.charAt(0).toUpperCase() + trig.type.slice(1);
96
+ if (trig.type === "schedule" && trig.config?.cron) {
97
+ const when = describeCron(String(trig.config.cron)) ?? String(trig.config.cron);
98
+ label = `${t("integration.templates.detail.trigger.schedule")} \xB7 ${when}${trig.config.timezone ? ` (${trig.config.timezone})` : ""}`;
98
99
  }
99
- if (t.type === "webhook" && t.config?.path) label = `Webhook \xB7 ${t.config.path}`;
100
- if (t.type === "manual") label = "Run on demand";
101
- return { key: t.id, icon: TRIGGER_ICON[t.type] ?? Zap, label };
100
+ if (trig.type === "webhook" && trig.config?.path) label = `${t("integration.templates.trigger.webhook")} \xB7 ${trig.config.path}`;
101
+ if (trig.type === "manual") label = t("integration.templates.trigger.manual");
102
+ return { key: trig.id, icon: TRIGGER_ICON[trig.type] ?? Zap, label };
102
103
  }));
103
104
  const variables = computed(() => Object.entries(tpl.value?.blob.variables ?? {}).map(([key, v]) => ({
104
105
  key,
105
106
  default: String(v?.default ?? ""),
106
107
  description: resolveLocalized(v?.description ?? "")
107
108
  })));
108
- const variableColumns = [
109
- { key: "key", label: "Variable", width: "minmax(0,180px)", sortable: true },
110
- { key: "description", label: "Description" },
109
+ const variableColumns = computed(() => [
110
+ { key: "key", label: t("integration.templates.detail.variables.column.key"), width: "minmax(0,180px)", sortable: true },
111
+ { key: "description", label: t("integration.templates.detail.variables.column.description") },
111
112
  // Stored as `default` in the blob, but it *is* the value the workflow runs
112
113
  // with — see `specs/workflow-variables.md`.
113
- { key: "default", label: "Value", width: "minmax(0,200px)" }
114
- ];
114
+ { key: "default", label: t("integration.templates.detail.variables.column.value"), width: "minmax(0,200px)" }
115
+ ]);
115
116
  const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
116
117
  const secretReqs = computed(() => reqs.value?.secrets ?? []);
117
118
  const secretChecks = computed(() => {
@@ -119,7 +120,7 @@ const secretChecks = computed(() => {
119
120
  if (fromApi.length) {
120
121
  return fromApi.map((s) => ({
121
122
  key: s.key,
122
- label: s.key ?? "(chosen at install)",
123
+ label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
123
124
  present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
124
125
  }));
125
126
  }
@@ -164,12 +165,20 @@ async function confirmInstall() {
164
165
  });
165
166
  if (id) await router.push(`/integrations/workflows/${id}`);
166
167
  } catch (e) {
167
- installError.value = e.message || "Install failed.";
168
+ installError.value = e.message || t("integration.templates.detail.install.error.fallback");
168
169
  } finally {
169
170
  installing.value = false;
170
171
  }
171
172
  }
172
- const PUBLISHER_KIND_LABEL = { revenexx: "Official", partner: "Partner", community: "Community" };
173
+ const PUBLISHER_KIND_LABEL_KEY = {
174
+ revenexx: "integration.templates.detail.publisher.kind.revenexx",
175
+ partner: "integration.templates.detail.publisher.kind.partner",
176
+ community: "integration.templates.detail.publisher.kind.community"
177
+ };
178
+ function publisherKindLabel(kind) {
179
+ const key = PUBLISHER_KIND_LABEL_KEY[kind];
180
+ return key ? t(key) : kind;
181
+ }
173
182
  function publisherBadgeClass(kind) {
174
183
  return kind === "revenexx" ? "bg-violet-500/10 text-violet-700 border-violet-500/20" : kind === "partner" ? "bg-amber-500/10 text-amber-700 border-amber-500/20" : "bg-slate-500/10 text-slate-700 border-slate-500/20";
175
184
  }
@@ -184,14 +193,14 @@ function levelDot(level) {
184
193
  <PageHeader :title="tpl.name">
185
194
  <template #title>
186
195
  <span class="inline-flex items-center gap-1.5">
187
- <NuxtLink to="/integrations/templates" class="font-normal text-muted-foreground/80 hover:text-primary transition-colors">Templates</NuxtLink>
196
+ <NuxtLink to="/integrations/templates" class="font-normal text-muted-foreground/80 hover:text-primary transition-colors">{{ t("integration.templates.detail.breadcrumb.templates") }}</NuxtLink>
188
197
  <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
189
198
  <span class="truncate">{{ tpl.name }}</span>
190
199
  </span>
191
200
  </template>
192
201
  <template #actions>
193
202
  <Button class="h-7 text-sm" @click="openInstall">
194
- Use template
203
+ {{ t("integration.templates.detail.action.useTemplate") }}
195
204
  </Button>
196
205
  </template>
197
206
  </PageHeader>
@@ -236,7 +245,7 @@ function levelDot(level) {
236
245
  aria-labelledby="tpl-about-heading"
237
246
  class="rounded-lg border border-black/[0.06] bg-white p-4"
238
247
  >
239
- <h3 id="tpl-about-heading" class="text-sm font-semibold mb-2">About this template</h3>
248
+ <h3 id="tpl-about-heading" class="text-sm font-semibold mb-2">{{ t("integration.templates.detail.about.title") }}</h3>
240
249
  <div
241
250
  class="text-sm leading-relaxed text-foreground/90 space-y-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mt-1 [&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[13px] [&_a]:text-primary [&_a]:underline [&_strong]:font-semibold"
242
251
  v-html="descriptionHtml"
@@ -249,7 +258,7 @@ function levelDot(level) {
249
258
  aria-labelledby="tpl-steps-heading"
250
259
  class="rounded-lg border border-black/[0.06] bg-white p-4"
251
260
  >
252
- <h3 id="tpl-steps-heading" class="text-sm font-semibold mb-3">Step by step</h3>
261
+ <h3 id="tpl-steps-heading" class="text-sm font-semibold mb-3">{{ t("integration.templates.detail.steps.title") }}</h3>
253
262
  <ol v-if="steps.length" class="space-y-2.5">
254
263
  <li
255
264
  v-for="(step, i) in steps"
@@ -269,7 +278,7 @@ function levelDot(level) {
269
278
  </li>
270
279
  </ol>
271
280
  <p v-else class="text-sm text-muted-foreground italic">
272
- No step-by-step outline for this template yet.
281
+ {{ t("integration.templates.detail.steps.empty") }}
273
282
  </p>
274
283
  </div>
275
284
 
@@ -284,8 +293,8 @@ function levelDot(level) {
284
293
  aria-labelledby="tpl-variables-heading"
285
294
  class="rounded-lg border border-black/[0.06] bg-white p-4"
286
295
  >
287
- <h3 id="tpl-variables-heading" class="text-sm font-semibold mb-1">Variables you can tune</h3>
288
- <p class="text-sm text-muted-foreground mb-3">Set once after install, used across the workflow.</p>
296
+ <h3 id="tpl-variables-heading" class="text-sm font-semibold mb-1">{{ t("integration.templates.detail.variables.title") }}</h3>
297
+ <p class="text-sm text-muted-foreground mb-3">{{ t("integration.templates.detail.variables.description") }}</p>
289
298
  <DataTable
290
299
  :data="variables"
291
300
  :columns="variableColumns"
@@ -315,51 +324,51 @@ function levelDot(level) {
315
324
  shows no heading of its own, so there is no visible string to
316
325
  point `aria-labelledby` at. Copy this pattern only for a panel
317
326
  that genuinely has none. -->
318
- <div role="group" aria-label="Setup readiness" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
327
+ <div role="group" :aria-label="t('integration.templates.detail.setup.ariaLabel')" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
319
328
  <div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
320
329
  <CheckCircle2 :size="14" />
321
- Everything is ready to install.
330
+ {{ t("integration.templates.detail.setup.ready") }}
322
331
  </div>
323
332
  <div v-else class="text-sm text-amber-700 space-y-0.5">
324
333
  <div v-if="missingCredentials" class="flex items-center gap-1.5">
325
334
  <Key :size="12" />
326
- {{ missingCredentials }} credential{{ missingCredentials === 1 ? "" : "s" }} to connect
335
+ {{ t("integration.templates.detail.setup.missingCredentials", missingCredentials) }}
327
336
  </div>
328
337
  <div v-if="missingSecrets" class="flex items-center gap-1.5">
329
338
  <ShieldAlert :size="12" />
330
- {{ missingSecrets }} secret{{ missingSecrets === 1 ? "" : "s" }} missing
339
+ {{ t("integration.templates.detail.setup.missingSecrets", missingSecrets) }}
331
340
  </div>
332
341
  </div>
333
342
  <Button class="w-full h-8 text-sm" @click="openInstall">
334
- Use template
343
+ {{ t("integration.templates.detail.action.useTemplate") }}
335
344
  </Button>
336
345
  <p class="text-xs text-muted-foreground">
337
- Creates a paused copy in your workflows. Nothing runs until you activate it.
346
+ {{ t("integration.templates.detail.setup.hint") }}
338
347
  </p>
339
348
  </div>
340
349
 
341
350
  <div class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
342
- <div class="text-mono text-muted-foreground">Publisher</div>
351
+ <div class="text-mono text-muted-foreground">{{ t("integration.templates.detail.publisher.title") }}</div>
343
352
  <div class="flex items-center gap-2">
344
353
  <span class="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground">
345
354
  <User :size="12" />
346
355
  </span>
347
356
  <div class="text-sm font-medium truncate">{{ tpl.publisher.name }}</div>
348
357
  <Badge variant="outline" :class="['text-sm border', publisherBadgeClass(tpl.publisher.kind)]">
349
- {{ PUBLISHER_KIND_LABEL[tpl.publisher.kind] ?? tpl.publisher.kind }}
358
+ {{ publisherKindLabel(tpl.publisher.kind) }}
350
359
  </Badge>
351
360
  </div>
352
361
  <div v-if="packageLabel" class="border-t border-black/[0.06] pt-2 text-sm">
353
- <div class="text-muted-foreground">Ships with</div>
362
+ <div class="text-muted-foreground">{{ t("integration.templates.detail.publisher.shipsWith") }}</div>
354
363
  <div class="font-medium">{{ packageLabel }}</div>
355
364
  </div>
356
365
  <div class="border-t border-black/[0.06] pt-2 grid grid-cols-2 gap-2 text-sm">
357
366
  <div>
358
- <div class="text-muted-foreground">Steps</div>
367
+ <div class="text-muted-foreground">{{ t("integration.templates.detail.rail.stepsLabel") }}</div>
359
368
  <div class="font-medium tabular-nums">{{ steps.length || tpl.nodeCount }}</div>
360
369
  </div>
361
370
  <div>
362
- <div class="text-muted-foreground">Level</div>
371
+ <div class="text-muted-foreground">{{ t("integration.templates.detail.rail.levelLabel") }}</div>
363
372
  <span class="mt-0.5 inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
364
373
  <span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
365
374
  {{ tpl.level }}
@@ -373,7 +382,7 @@ function levelDot(level) {
373
382
  what a screen reader hears instead of what is written. -->
374
383
  <div v-if="credentialReqs.length" role="group" aria-labelledby="tpl-credentials-label" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
375
384
  <div id="tpl-credentials-label" class="flex items-center gap-1.5 text-mono text-muted-foreground">
376
- <Key :size="11" /> Credentials
385
+ <Key :size="11" /> {{ t("integration.templates.detail.credentials.title") }}
377
386
  </div>
378
387
  <ul class="space-y-1">
379
388
  <li
@@ -390,22 +399,22 @@ function levelDot(level) {
390
399
  class="shrink-0 bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
391
400
  >
392
401
  <CheckCircle2 :size="10" class="mr-0.5" />
393
- {{ ofType(c.slug).length }} ready
402
+ {{ t("integration.templates.detail.credentials.readyCount", ofType(c.slug).length) }}
394
403
  </Badge>
395
404
  <Badge v-else variant="outline" class="shrink-0 bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
396
- Connect first
405
+ {{ t("integration.templates.detail.credentials.connectFirst") }}
397
406
  </Badge>
398
407
  </li>
399
408
  </ul>
400
- <NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">Manage credentials →</NuxtLink>
409
+ <NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">{{ t("integration.templates.detail.credentials.manageLink") }} →</NuxtLink>
401
410
  </div>
402
411
 
403
412
  <div v-if="secretChecks.length" role="group" aria-labelledby="tpl-secrets-label" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
404
413
  <div class="flex items-center gap-2">
405
- <span id="tpl-secrets-label" class="text-mono text-muted-foreground">Required secrets</span>
414
+ <span id="tpl-secrets-label" class="text-mono text-muted-foreground">{{ t("integration.templates.detail.secrets.title") }}</span>
406
415
  <span v-if="missingSecrets > 0" class="flex items-center gap-1 text-sm text-amber-700">
407
416
  <ShieldAlert :size="11" />
408
- {{ missingSecrets }} missing
417
+ {{ t("integration.templates.detail.secrets.missingBadge", missingSecrets) }}
409
418
  </span>
410
419
  </div>
411
420
  <ul class="space-y-1">
@@ -421,20 +430,20 @@ function levelDot(level) {
421
430
  class="bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
422
431
  >
423
432
  <CheckCircle2 :size="10" class="mr-0.5" />
424
- Set
433
+ {{ t("integration.templates.detail.secrets.set") }}
425
434
  </Badge>
426
435
  <Badge v-else variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
427
- Missing
436
+ {{ t("integration.templates.detail.secrets.missing") }}
428
437
  </Badge>
429
438
  </li>
430
439
  </ul>
431
440
  </div>
432
441
 
433
442
  <div v-if="tpl.industries.length || tpl.vendors.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
434
- <div class="text-mono text-muted-foreground">Good fit for</div>
443
+ <div class="text-mono text-muted-foreground">{{ t("integration.templates.detail.fit.title") }}</div>
435
444
  <div class="flex flex-wrap gap-1">
436
445
  <span v-for="ind in tpl.industries" :key="`i-${ind}`" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
437
- {{ ind === "any" ? "Any industry" : ind }}
446
+ {{ ind === "any" ? t("integration.templates.detail.fit.anyIndustry") : ind }}
438
447
  </span>
439
448
  <span v-for="v in tpl.vendors" :key="`v-${v}`" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
440
449
  {{ v }}
@@ -447,54 +456,54 @@ function levelDot(level) {
447
456
  </template>
448
457
  <div v-else-if="!loaded" class="p-12 flex items-center justify-center gap-2 text-sm text-muted-foreground">
449
458
  <Loader2 :size="14" class="animate-spin" />
450
- Loading template…
459
+ {{ t("integration.templates.detail.state.loading") }}
451
460
  </div>
452
461
  <div v-else class="p-12 text-center text-sm text-muted-foreground">
453
- Template not found.
462
+ {{ t("integration.templates.detail.state.notFound") }}
454
463
  </div>
455
464
 
456
465
  <!-- Install: name + per-node credential binding ------------------------- -->
457
466
  <Dialog v-model:open="installOpen">
458
467
  <DialogContent class="sm:max-w-lg">
459
468
  <DialogHeader>
460
- <DialogTitle>Use template</DialogTitle>
469
+ <DialogTitle>{{ t("integration.templates.detail.action.useTemplate") }}</DialogTitle>
461
470
  <DialogDescription class="text-sm">
462
- Pre-wire credentials, then we create the workflow. It starts inactive, so you can finish wiring in the editor and activate when ready.
471
+ {{ t("integration.templates.detail.install.description") }}
463
472
  </DialogDescription>
464
473
  </DialogHeader>
465
474
 
466
475
  <div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
467
476
  <div class="space-y-1.5">
468
- <Label for="wf-name">Workflow name</Label>
469
- <Input id="wf-name" v-model="name" class="bg-white" placeholder="Workflow name" />
477
+ <Label for="wf-name">{{ t("integration.templates.detail.install.nameLabel") }}</Label>
478
+ <Input id="wf-name" v-model="name" class="bg-white" :placeholder="t('integration.templates.detail.install.namePlaceholder')" />
470
479
  </div>
471
480
 
472
481
  <!-- Credentials -->
473
482
  <div class="space-y-2">
474
483
  <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
475
- Credentials
484
+ {{ t("integration.templates.detail.credentials.title") }}
476
485
  </div>
477
486
  <p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
478
- This template needs no credentials.
487
+ {{ t("integration.templates.detail.install.noCredentials") }}
479
488
  </p>
480
489
  <div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
481
490
  <div class="flex items-center gap-2">
482
491
  <span class="text-sm font-medium">{{ req.name ?? req.slug }}</span>
483
492
  <Badge v-if="!req.registered" variant="outline" class="bg-rose-500/10 border-rose-500/20 text-rose-700 text-sm">
484
- not registered
493
+ {{ t("integration.templates.detail.install.notRegistered") }}
485
494
  </Badge>
486
495
  <Badge v-else-if="!ofType(req.slug).length" variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
487
- none yet
496
+ {{ t("integration.templates.detail.install.noneYet") }}
488
497
  </Badge>
489
498
  </div>
490
499
 
491
500
  <p v-if="!ofType(req.slug).length" class="rounded-md border-l-2 border-black/[0.08] bg-muted/30 px-2.5 py-1.5 text-sm text-muted-foreground">
492
- No credentials of this type yet.
493
- <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">Create one</NuxtLink>
494
- first, or install now and wire it up in the editor.
501
+ {{ t("integration.templates.detail.install.noCredentialsOfType") }}
502
+ <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">{{ t("integration.templates.detail.install.createOneLink") }}</NuxtLink>
503
+ {{ t("integration.templates.detail.install.orInstallLater") }}
495
504
  </p>
496
505
  <div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
497
- <Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">node {{ nodeId }}</Label>
506
+ <Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">{{ t("integration.templates.detail.install.nodeLabel", { id: nodeId }) }}</Label>
498
507
  <Select
499
508
  :model-value="bindings[nodeId] ?? SELECT_LATER"
500
509
  @update:model-value="(v2) => {
@@ -502,10 +511,10 @@ function levelDot(level) {
502
511
  }"
503
512
  >
504
513
  <SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
505
- <SelectValue placeholder="Select later" />
514
+ <SelectValue :placeholder="t('integration.templates.detail.install.selectLater')" />
506
515
  </SelectTrigger>
507
516
  <SelectContent>
508
- <SelectItem :value="SELECT_LATER">Select later</SelectItem>
517
+ <SelectItem :value="SELECT_LATER">{{ t("integration.templates.detail.install.selectLater") }}</SelectItem>
509
518
  <SelectItem v-for="c in ofType(req.slug)" :key="c.id" :value="c.id">
510
519
  {{ c.name }}
511
520
  </SelectItem>
@@ -518,11 +527,11 @@ function levelDot(level) {
518
527
  <!-- Secrets -->
519
528
  <div v-if="secretChecks.length" class="space-y-2">
520
529
  <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
521
- Secrets
530
+ {{ t("integration.templates.detail.install.secretsHeader") }}
522
531
  </div>
523
532
  <p class="text-sm text-muted-foreground">
524
- Referenced secret keys must exist before the workflow runs. Manage them on the
525
- <NuxtLink to="/integrations/secrets" class="text-primary hover:underline">Secrets</NuxtLink> page.
533
+ {{ t("integration.templates.detail.install.secretsDescription") }}
534
+ <NuxtLink to="/integrations/secrets" class="text-primary hover:underline">{{ t("integration.nav.item.secrets") }}</NuxtLink> {{ t("integration.templates.detail.install.secretsDescriptionSuffix") }}
526
535
  </p>
527
536
  <ul class="space-y-1">
528
537
  <li
@@ -532,7 +541,7 @@ function levelDot(level) {
532
541
  >
533
542
  <code class="font-mono">{{ s.label }}</code>
534
543
  <Badge variant="outline" :class="s.present ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm' : 'bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm'">
535
- {{ s.present ? "Stored" : "Missing" }}
544
+ {{ s.present ? t("integration.templates.detail.install.secretStored") : t("integration.templates.detail.secrets.missing") }}
536
545
  </Badge>
537
546
  </li>
538
547
  </ul>
@@ -545,11 +554,11 @@ function levelDot(level) {
545
554
 
546
555
  <DialogFooter>
547
556
  <Button variant="outline" :disabled="installing" @click="installOpen = false">
548
- Cancel
557
+ {{ t("integration.common.action.cancel") }}
549
558
  </Button>
550
559
  <Button :disabled="installing || !name.trim()" @click="confirmInstall">
551
560
  <Loader2 v-if="installing" :size="14" class="mr-1 animate-spin" />
552
- {{ installing ? "Setting up\u2026" : "Install" }}
561
+ {{ installing ? t("integration.templates.detail.install.submitting") : t("integration.templates.detail.install.submit") }}
553
562
  </Button>
554
563
  </DialogFooter>
555
564
  </DialogContent>
@@ -4,12 +4,13 @@ import { Magnifier as Search, Layers, DangerTriangle as AlertTriangle } from "#s
4
4
  import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@revenexx/studio";
5
5
  import TemplateCard from "../../../components/Integrations/TemplateCard.vue";
6
6
  import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
7
- useHead({ title: "Templates \xB7 Integration Studio" });
7
+ const t = useIntegrationsT();
8
+ useHead({ title: () => t("integration.templates.gallery.head.title") });
8
9
  const { templates, loading, error, loaded, load, ensureDetails } = useIntegrationsTemplates();
9
10
  const { load: loadNodeCatalog } = useIntegrationsNodes();
10
11
  onMounted(async () => {
11
12
  await Promise.all([load(true), loadNodeCatalog()]);
12
- void ensureDetails(templates.value.map((t) => t.slug));
13
+ void ensureDetails(templates.value.map((t2) => t2.slug));
13
14
  });
14
15
  const search = ref("");
15
16
  const activeCategory = ref("all");
@@ -17,22 +18,31 @@ const activeLevel = ref("all");
17
18
  const sortBy = ref("name");
18
19
  const categories = computed(() => {
19
20
  const counts = /* @__PURE__ */ new Map();
20
- for (const t of templates.value) {
21
- if (!t.category) continue;
22
- counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
21
+ for (const t2 of templates.value) {
22
+ if (!t2.category) continue;
23
+ counts.set(t2.category, (counts.get(t2.category) ?? 0) + 1);
23
24
  }
24
25
  return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([key, count]) => ({ key, label: templateCategoryLabel(key), count }));
25
26
  });
26
27
  const LEVELS = ["beginner", "intermediate", "advanced"];
27
28
  const LEVEL_RANK = { beginner: 0, intermediate: 1, advanced: 2 };
29
+ const levelOptions = computed(() => LEVELS.map((l) => ({
30
+ value: l,
31
+ label: t(`integration.templates.gallery.filter.level.${l}`)
32
+ })));
33
+ const sortOptions = computed(() => [
34
+ { value: "name", label: t("integration.templates.gallery.sort.name") },
35
+ { value: "level", label: t("integration.templates.gallery.sort.level") },
36
+ { value: "nodes", label: t("integration.templates.gallery.sort.nodes") }
37
+ ]);
28
38
  const filtered = computed(() => {
29
39
  const q = search.value.trim().toLowerCase();
30
40
  let items = templates.value;
31
- if (activeCategory.value !== "all") items = items.filter((t) => t.category === activeCategory.value);
32
- if (activeLevel.value !== "all") items = items.filter((t) => t.level === activeLevel.value);
41
+ if (activeCategory.value !== "all") items = items.filter((t2) => t2.category === activeCategory.value);
42
+ if (activeLevel.value !== "all") items = items.filter((t2) => t2.level === activeLevel.value);
33
43
  if (q) {
34
44
  items = items.filter(
35
- (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.vendors.some((v) => v.toLowerCase().includes(q)) || (templatePackageLabel(t) ?? "").toLowerCase().includes(q) || t.industries.some((i) => i.toLowerCase().includes(q))
45
+ (t2) => t2.name.toLowerCase().includes(q) || t2.description.toLowerCase().includes(q) || t2.vendors.some((v) => v.toLowerCase().includes(q)) || (templatePackageLabel(t2) ?? "").toLowerCase().includes(q) || t2.industries.some((i) => i.toLowerCase().includes(q))
36
46
  );
37
47
  }
38
48
  const sorted = [...items];
@@ -42,10 +52,10 @@ const filtered = computed(() => {
42
52
  return sorted;
43
53
  });
44
54
  watch(() => templates.value.length, () => {
45
- if (templates.value.length) void ensureDetails(templates.value.map((t) => t.slug));
55
+ if (templates.value.length) void ensureDetails(templates.value.map((t2) => t2.slug));
46
56
  });
47
57
  const awaitingCatalogue = computed(() => loading.value && !templates.value.length);
48
- const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u2026" : "");
58
+ const statusText = computed(() => awaitingCatalogue.value ? t("integration.templates.gallery.state.loading") : "");
49
59
  </script>
50
60
 
51
61
  <template>
@@ -53,8 +63,8 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
53
63
  <!-- The toolbar lives in the header's #tabs slot: PageHeader is the
54
64
  sticky element (top-0 z-20), so filters + search stay pinned. -->
55
65
  <PageHeader
56
- title="Templates"
57
- description="Pre-built workflows you can clone, then tweak. Wired by revenexx and our partners."
66
+ :title="t('integration.templates.gallery.header.title')"
67
+ :description="t('integration.templates.gallery.header.description')"
58
68
  >
59
69
  <template #tabs>
60
70
  <div class="-mx-3 w-[calc(100%+1.5rem)] flex items-center gap-3 border-t border-black/[0.06] px-4 pt-2.5 pb-1">
@@ -65,7 +75,7 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
65
75
  :class="activeCategory === 'all' ? 'border-primary/30 bg-primary/10 text-primary font-medium' : 'border-black/[0.08] text-muted-foreground hover:border-black/20'"
66
76
  @click="activeCategory = 'all'"
67
77
  >
68
- All <span class="opacity-70">({{ templates.length }})</span>
78
+ {{ t("integration.templates.gallery.filter.all") }} <span class="opacity-70">({{ templates.length }})</span>
69
79
  </button>
70
80
  <button
71
81
  v-for="cat in categories"
@@ -80,27 +90,25 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
80
90
  </div>
81
91
 
82
92
  <Select v-model="activeLevel">
83
- <SelectTrigger aria-label="Filter by level" class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-36 rounded-full bg-white text-sm">
93
+ <SelectTrigger :aria-label="t('integration.templates.gallery.filter.levelAria')" class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-36 rounded-full bg-white text-sm">
84
94
  <SelectValue />
85
95
  </SelectTrigger>
86
96
  <SelectContent>
87
- <SelectItem value="all">All levels</SelectItem>
88
- <SelectItem v-for="l in LEVELS" :key="l" :value="l">{{ l.charAt(0).toUpperCase() + l.slice(1) }}</SelectItem>
97
+ <SelectItem value="all">{{ t("integration.templates.gallery.filter.levelAllOption") }}</SelectItem>
98
+ <SelectItem v-for="opt in levelOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
89
99
  </SelectContent>
90
100
  </Select>
91
101
  <Select v-model="sortBy">
92
- <SelectTrigger aria-label="Sort templates" class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-40 rounded-full bg-white text-sm">
102
+ <SelectTrigger :aria-label="t('integration.templates.gallery.sort.aria')" class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-40 rounded-full bg-white text-sm">
93
103
  <SelectValue />
94
104
  </SelectTrigger>
95
105
  <SelectContent>
96
- <SelectItem value="name">Sort: A–Z</SelectItem>
97
- <SelectItem value="level">Sort: Simplest first</SelectItem>
98
- <SelectItem value="nodes">Sort: Most steps</SelectItem>
106
+ <SelectItem v-for="opt in sortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
99
107
  </SelectContent>
100
108
  </Select>
101
109
  <div class="relative w-60">
102
110
  <Search :size="12" class="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
103
- <Input v-model="search" aria-label="Search templates and vendors" placeholder="Search templates, vendors…" class="h-7 min-h-0 py-0 rounded-full pl-7 text-sm bg-white" />
111
+ <Input v-model="search" :aria-label="t('integration.templates.gallery.search.aria')" :placeholder="t('integration.templates.gallery.search.placeholder')" class="h-7 min-h-0 py-0 rounded-full pl-7 text-sm bg-white" />
104
112
  </div>
105
113
  </div>
106
114
  </template>
@@ -122,7 +130,7 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
122
130
  card draws one while its flow miniature loads ("Loading the flow"). Two
123
131
  unnamed status regions on one page are two things a reader — and a test —
124
132
  cannot tell apart, and this one is about the gallery rather than a card. -->
125
- <p role="status" aria-live="polite" aria-label="Template gallery" class="sr-only">{{ statusText }}</p>
133
+ <p role="status" aria-live="polite" :aria-label="t('integration.templates.gallery.state.regionLabel')" class="sr-only">{{ statusText }}</p>
126
134
 
127
135
  <!-- The placeholders are taken out of the accessibility tree rather than
128
136
  marked busy. `aria-busy` only asks assistive technology to defer
@@ -155,7 +163,7 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
155
163
  <div v-else-if="error && !templates.length" role="alert" class="rounded-lg border border-rose-500/20 bg-rose-500/5 p-4 text-sm text-rose-700 flex items-start gap-2">
156
164
  <AlertTriangle :size="15" class="mt-0.5 shrink-0" />
157
165
  <div>
158
- <div class="font-medium">Couldn't load templates.</div>
166
+ <div class="font-medium">{{ t("integration.templates.gallery.error.title") }}</div>
159
167
  <div class="text-rose-700/80">{{ error }}</div>
160
168
  </div>
161
169
  </div>
@@ -165,15 +173,15 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
165
173
  <span class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
166
174
  <Layers :size="18" />
167
175
  </span>
168
- <div class="text-sm font-semibold text-foreground">No templates registered yet</div>
176
+ <div class="text-sm font-semibold text-foreground">{{ t("integration.templates.gallery.empty.registered.title") }}</div>
169
177
  <p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
170
- Templates ship with node packages. Once a package that publishes templates is installed, they show up here.
178
+ {{ t("integration.templates.gallery.empty.registered.description") }}
171
179
  </p>
172
180
  </div>
173
181
 
174
182
  <!-- The group is named so the cards can be addressed as a set; a card names
175
183
  itself from what it shows, which TemplateCard says more about. -->
176
- <div v-else role="group" aria-label="Templates" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
184
+ <div v-else role="group" :aria-label="t('integration.templates.gallery.cards.ariaLabel')" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
177
185
  <TemplateCard
178
186
  v-for="tpl in filtered"
179
187
  :key="tpl.slug"
@@ -184,7 +192,7 @@ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u
184
192
  </div>
185
193
 
186
194
  <div v-if="templates.length && !filtered.length" class="mt-4 rounded-lg border border-black/[0.06] bg-white p-8 text-center text-sm text-muted-foreground">
187
- No templates match these filters.
195
+ {{ t("integration.templates.gallery.empty.filtered") }}
188
196
  </div>
189
197
  </div>
190
198
  </IntegrationsLayout>