@revenexx/studio-integrations 0.6.0 → 1.0.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 (137) hide show
  1. package/CHANGELOG.md +643 -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 +12 -3
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +32 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +45 -38
  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 +21 -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.d.vue.ts +3 -0
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +30 -16
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  18. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -8
  19. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue +45 -42
  21. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  23. package/dist/runtime/components/Integrations/NodeInspector.vue +315 -221
  24. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  25. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  26. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +37 -10
  27. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  28. package/dist/runtime/components/Integrations/NodePalette.vue +40 -35
  29. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  30. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  31. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  32. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  33. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  34. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  35. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  36. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  37. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  38. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  39. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  40. package/dist/runtime/components/Integrations/StickyNoteView.vue +82 -34
  41. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  42. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  43. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  44. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  45. package/dist/runtime/components/Integrations/TemplateCard.vue +18 -8
  46. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +22 -12
  47. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  48. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +41 -39
  49. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  50. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  51. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  52. package/dist/runtime/components/Integrations/WorkflowEditor.vue +131 -87
  53. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  54. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  55. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +27 -24
  56. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  57. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  58. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +76 -46
  59. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +55 -43
  60. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  61. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +55 -43
  62. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  63. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  64. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -13
  65. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +79 -39
  66. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  67. package/dist/runtime/composables/integrationsApi.js +4 -2
  68. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  69. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  70. package/dist/runtime/composables/useIntegrationsCredentials.js +8 -6
  71. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  72. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  73. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  74. package/dist/runtime/composables/useIntegrationsNodes.js +14 -2
  75. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  76. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  77. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  78. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  79. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  80. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
  81. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  82. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  83. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  84. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  85. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  86. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
  87. package/dist/runtime/composables/useIntegrationsTriggers.js +21 -8
  88. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  89. package/dist/runtime/composables/useIntegrationsWorkflows.js +30 -7
  90. package/dist/runtime/composables/useNodeConfigResolve.js +4 -2
  91. package/dist/runtime/data/integrationNodeCatalog.d.ts +63 -1
  92. package/dist/runtime/data/integrationNodeCatalog.js +22 -4
  93. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  94. package/dist/runtime/data/integrationTemplates.js +7 -1
  95. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  96. package/dist/runtime/data/nodeInputData.js +30 -5
  97. package/dist/runtime/data/payloadForm.js +2 -1
  98. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  99. package/dist/runtime/data/triggerGlyphs.js +11 -0
  100. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  101. package/dist/runtime/i18n/messages/de.json +1224 -0
  102. package/dist/runtime/i18n/messages/en.json +1224 -0
  103. package/dist/runtime/i18n/translate.d.ts +83 -0
  104. package/dist/runtime/i18n/translate.js +41 -0
  105. package/dist/runtime/pages/integrations/audit.vue +52 -34
  106. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  107. package/dist/runtime/pages/integrations/credentials.vue +73 -52
  108. package/dist/runtime/pages/integrations/index.vue +66 -73
  109. package/dist/runtime/pages/integrations/runs.vue +33 -21
  110. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  111. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  112. package/dist/runtime/pages/integrations/templates/[slug].vue +89 -72
  113. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  114. package/dist/runtime/pages/integrations/workflows/[id].vue +351 -197
  115. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  116. package/dist/runtime/plugins/locale.d.ts +10 -0
  117. package/dist/runtime/plugins/locale.js +6 -0
  118. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  119. package/dist/runtime/plugins/realtime.client.js +4 -0
  120. package/dist/runtime/types/integrations.d.ts +25 -1
  121. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  122. package/dist/runtime/utils/credentialState.d.ts +30 -2
  123. package/dist/runtime/utils/credentialState.js +26 -11
  124. package/dist/runtime/utils/localize.d.ts +52 -5
  125. package/dist/runtime/utils/localize.js +47 -9
  126. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  127. package/dist/runtime/utils/pollBudget.js +17 -0
  128. package/dist/runtime/utils/schedule.d.ts +0 -25
  129. package/dist/runtime/utils/schedule.js +37 -25
  130. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  131. package/dist/runtime/utils/servedPaths.js +3 -0
  132. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  133. package/dist/runtime/utils/statusStyle.js +9 -1
  134. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  135. package/dist/runtime/utils/validationFindings.js +4 -3
  136. package/dist/types.d.mts +2 -6
  137. package/package.json +3 -2
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
2
  import { computed, reactive, ref } from "vue";
3
3
  import { marked } from "marked";
4
- import { AltArrowRight as Chevron, Bolt as Zap, CheckCircle as CheckCircle2, ClockCircle as Clock, Global as Webhook, Key, Play, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
4
+ import { AltArrowRight as Chevron, CheckCircle as CheckCircle2, Key, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
5
+ import { triggerGlyph as glyphForTrigger } from "../../../data/triggerGlyphs";
5
6
  import {
6
7
  Badge,
7
8
  Button,
@@ -24,6 +25,7 @@ import NodeMark from "../../../components/Integrations/NodeMark.vue";
24
25
  import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
25
26
  import { describeCron } from "../../../utils/schedule";
26
27
  const SELECT_LATER = "__later__";
28
+ const t = useIntegrationsT();
27
29
  const route = useRoute();
28
30
  const router = useRouter();
29
31
  const { get, load, loaded, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
@@ -32,7 +34,7 @@ const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
32
34
  const { ofType, load: loadCredentials } = useIntegrationsCredentials();
33
35
  const slug = computed(() => String(route.params.slug));
34
36
  const tpl = computed(() => get(slug.value));
35
- useHead({ title: () => `${tpl.value?.name ?? "Template"} \xB7 Integration Studio` });
37
+ useHead({ title: () => t("integration.templates.detail.head.title", { name: tpl.value?.name ?? t("integration.templates.detail.head.fallbackName") }) });
36
38
  const reqs = ref(null);
37
39
  onMounted(async () => {
38
40
  await load();
@@ -50,7 +52,7 @@ const steps = computed(() => {
50
52
  if (!blob?.nodes.length) return [];
51
53
  const nodeById = new Map(blob.nodes.map((n) => [n.id, n]));
52
54
  const happyEdges = (blob.edges ?? []).filter((e) => e.from.output !== "error");
53
- const triggerHandles = new Set((blob.triggers ?? []).map((t) => t.id));
55
+ const triggerHandles = new Set((blob.triggers ?? []).map((t2) => t2.id));
54
56
  const incoming = /* @__PURE__ */ new Map();
55
57
  for (const e of happyEdges) {
56
58
  if (!triggerHandles.has(e.from.nodeId)) incoming.set(e.to.nodeId, (incoming.get(e.to.nodeId) ?? 0) + 1);
@@ -89,29 +91,28 @@ function firstSentence(text) {
89
91
  const idx = text.indexOf(". ");
90
92
  return idx === -1 ? text : text.slice(0, idx + 1);
91
93
  }
92
- 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: glyphForTrigger(trig.type), 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,21 +193,29 @@ 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>
198
207
 
199
208
  <div class="p-4 space-y-4">
200
- <!-- Hero: the live workflow preview on the editor's dotted canvas -->
201
- <div class="relative overflow-hidden rounded-lg border border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]">
209
+ <!-- Hero: the live workflow preview on the editor's dotted canvas.
210
+ Named like the two panels below it, with `aria-label` because it
211
+ carries no heading to point at: it was the largest thing on the page
212
+ and the only one with no name at all, which also left it the one
213
+ surface drawing the miniature that nothing could address (PO-247). -->
214
+ <div
215
+ role="region"
216
+ :aria-label="t('integration.templates.detail.flow.ariaLabel')"
217
+ class="relative overflow-hidden rounded-lg border border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]"
218
+ >
202
219
  <TemplateFlowPreview v-if="tpl.detailLoaded" :blob="tpl.blob" :height="400" class="px-4" />
203
220
  <div v-else class="flex h-[400px] items-center justify-center">
204
221
  <Loader2 :size="18" class="animate-spin text-muted-foreground/50" />
@@ -236,7 +253,7 @@ function levelDot(level) {
236
253
  aria-labelledby="tpl-about-heading"
237
254
  class="rounded-lg border border-black/[0.06] bg-white p-4"
238
255
  >
239
- <h3 id="tpl-about-heading" class="text-sm font-semibold mb-2">About this template</h3>
256
+ <h3 id="tpl-about-heading" class="text-sm font-semibold mb-2">{{ t("integration.templates.detail.about.title") }}</h3>
240
257
  <div
241
258
  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
259
  v-html="descriptionHtml"
@@ -249,7 +266,7 @@ function levelDot(level) {
249
266
  aria-labelledby="tpl-steps-heading"
250
267
  class="rounded-lg border border-black/[0.06] bg-white p-4"
251
268
  >
252
- <h3 id="tpl-steps-heading" class="text-sm font-semibold mb-3">Step by step</h3>
269
+ <h3 id="tpl-steps-heading" class="text-sm font-semibold mb-3">{{ t("integration.templates.detail.steps.title") }}</h3>
253
270
  <ol v-if="steps.length" class="space-y-2.5">
254
271
  <li
255
272
  v-for="(step, i) in steps"
@@ -269,7 +286,7 @@ function levelDot(level) {
269
286
  </li>
270
287
  </ol>
271
288
  <p v-else class="text-sm text-muted-foreground italic">
272
- No step-by-step outline for this template yet.
289
+ {{ t("integration.templates.detail.steps.empty") }}
273
290
  </p>
274
291
  </div>
275
292
 
@@ -284,8 +301,8 @@ function levelDot(level) {
284
301
  aria-labelledby="tpl-variables-heading"
285
302
  class="rounded-lg border border-black/[0.06] bg-white p-4"
286
303
  >
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>
304
+ <h3 id="tpl-variables-heading" class="text-sm font-semibold mb-1">{{ t("integration.templates.detail.variables.title") }}</h3>
305
+ <p class="text-sm text-muted-foreground mb-3">{{ t("integration.templates.detail.variables.description") }}</p>
289
306
  <DataTable
290
307
  :data="variables"
291
308
  :columns="variableColumns"
@@ -315,51 +332,51 @@ function levelDot(level) {
315
332
  shows no heading of its own, so there is no visible string to
316
333
  point `aria-labelledby` at. Copy this pattern only for a panel
317
334
  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">
335
+ <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
336
  <div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
320
337
  <CheckCircle2 :size="14" />
321
- Everything is ready to install.
338
+ {{ t("integration.templates.detail.setup.ready") }}
322
339
  </div>
323
340
  <div v-else class="text-sm text-amber-700 space-y-0.5">
324
341
  <div v-if="missingCredentials" class="flex items-center gap-1.5">
325
342
  <Key :size="12" />
326
- {{ missingCredentials }} credential{{ missingCredentials === 1 ? "" : "s" }} to connect
343
+ {{ t("integration.templates.detail.setup.missingCredentials", missingCredentials) }}
327
344
  </div>
328
345
  <div v-if="missingSecrets" class="flex items-center gap-1.5">
329
346
  <ShieldAlert :size="12" />
330
- {{ missingSecrets }} secret{{ missingSecrets === 1 ? "" : "s" }} missing
347
+ {{ t("integration.templates.detail.setup.missingSecrets", missingSecrets) }}
331
348
  </div>
332
349
  </div>
333
350
  <Button class="w-full h-8 text-sm" @click="openInstall">
334
- Use template
351
+ {{ t("integration.templates.detail.action.useTemplate") }}
335
352
  </Button>
336
353
  <p class="text-xs text-muted-foreground">
337
- Creates a paused copy in your workflows. Nothing runs until you activate it.
354
+ {{ t("integration.templates.detail.setup.hint") }}
338
355
  </p>
339
356
  </div>
340
357
 
341
358
  <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>
359
+ <div class="text-mono text-muted-foreground">{{ t("integration.templates.detail.publisher.title") }}</div>
343
360
  <div class="flex items-center gap-2">
344
361
  <span class="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground">
345
362
  <User :size="12" />
346
363
  </span>
347
364
  <div class="text-sm font-medium truncate">{{ tpl.publisher.name }}</div>
348
365
  <Badge variant="outline" :class="['text-sm border', publisherBadgeClass(tpl.publisher.kind)]">
349
- {{ PUBLISHER_KIND_LABEL[tpl.publisher.kind] ?? tpl.publisher.kind }}
366
+ {{ publisherKindLabel(tpl.publisher.kind) }}
350
367
  </Badge>
351
368
  </div>
352
369
  <div v-if="packageLabel" class="border-t border-black/[0.06] pt-2 text-sm">
353
- <div class="text-muted-foreground">Ships with</div>
370
+ <div class="text-muted-foreground">{{ t("integration.templates.detail.publisher.shipsWith") }}</div>
354
371
  <div class="font-medium">{{ packageLabel }}</div>
355
372
  </div>
356
373
  <div class="border-t border-black/[0.06] pt-2 grid grid-cols-2 gap-2 text-sm">
357
374
  <div>
358
- <div class="text-muted-foreground">Steps</div>
375
+ <div class="text-muted-foreground">{{ t("integration.templates.detail.rail.stepsLabel") }}</div>
359
376
  <div class="font-medium tabular-nums">{{ steps.length || tpl.nodeCount }}</div>
360
377
  </div>
361
378
  <div>
362
- <div class="text-muted-foreground">Level</div>
379
+ <div class="text-muted-foreground">{{ t("integration.templates.detail.rail.levelLabel") }}</div>
363
380
  <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
381
  <span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
365
382
  {{ tpl.level }}
@@ -373,7 +390,7 @@ function levelDot(level) {
373
390
  what a screen reader hears instead of what is written. -->
374
391
  <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
392
  <div id="tpl-credentials-label" class="flex items-center gap-1.5 text-mono text-muted-foreground">
376
- <Key :size="11" /> Credentials
393
+ <Key :size="11" /> {{ t("integration.templates.detail.credentials.title") }}
377
394
  </div>
378
395
  <ul class="space-y-1">
379
396
  <li
@@ -390,22 +407,22 @@ function levelDot(level) {
390
407
  class="shrink-0 bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
391
408
  >
392
409
  <CheckCircle2 :size="10" class="mr-0.5" />
393
- {{ ofType(c.slug).length }} ready
410
+ {{ t("integration.templates.detail.credentials.readyCount", ofType(c.slug).length) }}
394
411
  </Badge>
395
412
  <Badge v-else variant="outline" class="shrink-0 bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
396
- Connect first
413
+ {{ t("integration.templates.detail.credentials.connectFirst") }}
397
414
  </Badge>
398
415
  </li>
399
416
  </ul>
400
- <NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">Manage credentials →</NuxtLink>
417
+ <NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">{{ t("integration.templates.detail.credentials.manageLink") }} →</NuxtLink>
401
418
  </div>
402
419
 
403
420
  <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
421
  <div class="flex items-center gap-2">
405
- <span id="tpl-secrets-label" class="text-mono text-muted-foreground">Required secrets</span>
422
+ <span id="tpl-secrets-label" class="text-mono text-muted-foreground">{{ t("integration.templates.detail.secrets.title") }}</span>
406
423
  <span v-if="missingSecrets > 0" class="flex items-center gap-1 text-sm text-amber-700">
407
424
  <ShieldAlert :size="11" />
408
- {{ missingSecrets }} missing
425
+ {{ t("integration.templates.detail.secrets.missingBadge", missingSecrets) }}
409
426
  </span>
410
427
  </div>
411
428
  <ul class="space-y-1">
@@ -421,20 +438,20 @@ function levelDot(level) {
421
438
  class="bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
422
439
  >
423
440
  <CheckCircle2 :size="10" class="mr-0.5" />
424
- Set
441
+ {{ t("integration.templates.detail.secrets.set") }}
425
442
  </Badge>
426
443
  <Badge v-else variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
427
- Missing
444
+ {{ t("integration.templates.detail.secrets.missing") }}
428
445
  </Badge>
429
446
  </li>
430
447
  </ul>
431
448
  </div>
432
449
 
433
450
  <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>
451
+ <div class="text-mono text-muted-foreground">{{ t("integration.templates.detail.fit.title") }}</div>
435
452
  <div class="flex flex-wrap gap-1">
436
453
  <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 }}
454
+ {{ ind === "any" ? t("integration.templates.detail.fit.anyIndustry") : ind }}
438
455
  </span>
439
456
  <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
457
  {{ v }}
@@ -447,54 +464,54 @@ function levelDot(level) {
447
464
  </template>
448
465
  <div v-else-if="!loaded" class="p-12 flex items-center justify-center gap-2 text-sm text-muted-foreground">
449
466
  <Loader2 :size="14" class="animate-spin" />
450
- Loading template…
467
+ {{ t("integration.templates.detail.state.loading") }}
451
468
  </div>
452
469
  <div v-else class="p-12 text-center text-sm text-muted-foreground">
453
- Template not found.
470
+ {{ t("integration.templates.detail.state.notFound") }}
454
471
  </div>
455
472
 
456
473
  <!-- Install: name + per-node credential binding ------------------------- -->
457
474
  <Dialog v-model:open="installOpen">
458
475
  <DialogContent class="sm:max-w-lg">
459
476
  <DialogHeader>
460
- <DialogTitle>Use template</DialogTitle>
477
+ <DialogTitle>{{ t("integration.templates.detail.action.useTemplate") }}</DialogTitle>
461
478
  <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.
479
+ {{ t("integration.templates.detail.install.description") }}
463
480
  </DialogDescription>
464
481
  </DialogHeader>
465
482
 
466
483
  <div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
467
484
  <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" />
485
+ <Label for="wf-name">{{ t("integration.templates.detail.install.nameLabel") }}</Label>
486
+ <Input id="wf-name" v-model="name" class="bg-white" :placeholder="t('integration.templates.detail.install.namePlaceholder')" />
470
487
  </div>
471
488
 
472
489
  <!-- Credentials -->
473
490
  <div class="space-y-2">
474
491
  <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
475
- Credentials
492
+ {{ t("integration.templates.detail.credentials.title") }}
476
493
  </div>
477
494
  <p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
478
- This template needs no credentials.
495
+ {{ t("integration.templates.detail.install.noCredentials") }}
479
496
  </p>
480
497
  <div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
481
498
  <div class="flex items-center gap-2">
482
499
  <span class="text-sm font-medium">{{ req.name ?? req.slug }}</span>
483
500
  <Badge v-if="!req.registered" variant="outline" class="bg-rose-500/10 border-rose-500/20 text-rose-700 text-sm">
484
- not registered
501
+ {{ t("integration.templates.detail.install.notRegistered") }}
485
502
  </Badge>
486
503
  <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
504
+ {{ t("integration.templates.detail.install.noneYet") }}
488
505
  </Badge>
489
506
  </div>
490
507
 
491
508
  <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.
509
+ {{ t("integration.templates.detail.install.noCredentialsOfType") }}
510
+ <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">{{ t("integration.templates.detail.install.createOneLink") }}</NuxtLink>
511
+ {{ t("integration.templates.detail.install.orInstallLater") }}
495
512
  </p>
496
513
  <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>
514
+ <Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">{{ t("integration.templates.detail.install.nodeLabel", { id: nodeId }) }}</Label>
498
515
  <Select
499
516
  :model-value="bindings[nodeId] ?? SELECT_LATER"
500
517
  @update:model-value="(v2) => {
@@ -502,10 +519,10 @@ function levelDot(level) {
502
519
  }"
503
520
  >
504
521
  <SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
505
- <SelectValue placeholder="Select later" />
522
+ <SelectValue :placeholder="t('integration.templates.detail.install.selectLater')" />
506
523
  </SelectTrigger>
507
524
  <SelectContent>
508
- <SelectItem :value="SELECT_LATER">Select later</SelectItem>
525
+ <SelectItem :value="SELECT_LATER">{{ t("integration.templates.detail.install.selectLater") }}</SelectItem>
509
526
  <SelectItem v-for="c in ofType(req.slug)" :key="c.id" :value="c.id">
510
527
  {{ c.name }}
511
528
  </SelectItem>
@@ -518,11 +535,11 @@ function levelDot(level) {
518
535
  <!-- Secrets -->
519
536
  <div v-if="secretChecks.length" class="space-y-2">
520
537
  <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
521
- Secrets
538
+ {{ t("integration.templates.detail.install.secretsHeader") }}
522
539
  </div>
523
540
  <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.
541
+ {{ t("integration.templates.detail.install.secretsDescription") }}
542
+ <NuxtLink to="/integrations/secrets" class="text-primary hover:underline">{{ t("integration.nav.item.secrets") }}</NuxtLink> {{ t("integration.templates.detail.install.secretsDescriptionSuffix") }}
526
543
  </p>
527
544
  <ul class="space-y-1">
528
545
  <li
@@ -532,7 +549,7 @@ function levelDot(level) {
532
549
  >
533
550
  <code class="font-mono">{{ s.label }}</code>
534
551
  <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" }}
552
+ {{ s.present ? t("integration.templates.detail.install.secretStored") : t("integration.templates.detail.secrets.missing") }}
536
553
  </Badge>
537
554
  </li>
538
555
  </ul>
@@ -545,11 +562,11 @@ function levelDot(level) {
545
562
 
546
563
  <DialogFooter>
547
564
  <Button variant="outline" :disabled="installing" @click="installOpen = false">
548
- Cancel
565
+ {{ t("integration.common.action.cancel") }}
549
566
  </Button>
550
567
  <Button :disabled="installing || !name.trim()" @click="confirmInstall">
551
568
  <Loader2 v-if="installing" :size="14" class="mr-1 animate-spin" />
552
- {{ installing ? "Setting up\u2026" : "Install" }}
569
+ {{ installing ? t("integration.templates.detail.install.submitting") : t("integration.templates.detail.install.submit") }}
553
570
  </Button>
554
571
  </DialogFooter>
555
572
  </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>