@revenexx/studio-integrations 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.
package/README.md CHANGED
@@ -19,8 +19,9 @@ pnpm add @revenexx/studio-integrations @revenexx/studio-shared @revenexx/studio
19
19
  ```ts
20
20
  // nuxt.config.ts
21
21
  export default defineNuxtConfig({
22
- extends: ['@revenexx/studio-shared'], // shell primitives + platform provider
23
- modules: ['@solar-icons/nuxt', '@revenexx/studio-integrations'],
22
+ // studio-shared is a Nuxt MODULE since 0.2.0 (shell primitives by their
23
+ // bare names + the platform provider) — do NOT `extends` it anymore.
24
+ modules: ['@solar-icons/nuxt', '@revenexx/studio-shared', '@revenexx/studio-integrations'],
24
25
  })
25
26
  ```
26
27
 
@@ -39,14 +40,14 @@ component styling silently breaks:
39
40
  @import "tailwindcss";
40
41
  @import "@revenexx/studio/tokens";
41
42
  @import "@revenexx/studio/fonts";
42
- @source "../node_modules/@revenexx/studio-shared/components";
43
+ @source "../node_modules/@revenexx/studio-shared/dist/runtime";
43
44
  @source "../node_modules/@revenexx/studio-integrations/dist/runtime";
44
45
  ```
45
46
 
46
47
  Adjust the relative paths to reach `node_modules` from your CSS file.
47
48
 
48
49
  ## Peers
49
- `@revenexx/studio`, `@revenexx/studio-shared`, `nuxt` ^4, `vue` ^3.5,
50
+ `@revenexx/studio`, `@revenexx/studio-shared` ^0.2 (module form), `nuxt` ^4, `vue` ^3.5,
50
51
  `@vue-flow/*` + `@dagrejs/dagre` (workflow editor), `lucide-vue-next`; register
51
52
  `@solar-icons/nuxt` in the host.
52
53
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -5,7 +5,8 @@ const props = defineProps({
5
5
  modelValue: { type: String, required: true }
6
6
  });
7
7
  const emit = defineEmits(["update:modelValue"]);
8
- const catalog = useDefinedEvents();
8
+ const catalog = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), listens: ref([]), loading: ref(false), load: async () => {
9
+ } };
9
10
  onMounted(() => {
10
11
  void catalog.load();
11
12
  });
@@ -5,7 +5,13 @@ import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogHeader,
5
5
  import { useIntegrationsCredentials } from "../../composables/useIntegrationsCredentials";
6
6
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
7
7
  const emit = defineEmits(["created"]);
8
- const sftp = useConsoleSftp();
8
+ const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
9
+ accounts: ref([]),
10
+ loading: ref(false),
11
+ activeTenantSlug: ref(null),
12
+ load: async () => null,
13
+ revealPassword: async () => null
14
+ };
9
15
  const { create } = useIntegrationsCredentials();
10
16
  const busyId = ref(null);
11
17
  const error = ref(null);
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
2
+ import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, Layers, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
3
3
  import SidebarNav from "@revenexx/studio-shared/components/Shared/SidebarNav.vue";
4
4
  const { workflows } = useIntegrationsWorkflows();
5
5
  const { runningCount } = useIntegrationsRuns();
@@ -18,6 +18,7 @@ const sections = computed(() => [
18
18
  label: "Integrations",
19
19
  items: [
20
20
  { label: "Workflows", to: "/integrations/workflows", icon: Workflow, badge: workflows.value.length || null },
21
+ { label: "Templates", to: "/integrations/templates", icon: Layers },
21
22
  { label: "Runs", to: "/integrations/runs", icon: Activity, badge: runningCount.value || null },
22
23
  { label: "Schedules", to: "/integrations/schedules", icon: Clock, badge: schedulesActive.value || null },
23
24
  { label: "Credentials", to: "/integrations/credentials", icon: Credentials, badge: credentialsTotal.value || null },
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ export default _default;
@@ -0,0 +1,536 @@
1
+ <script setup>
2
+ import { computed, reactive, ref } from "vue";
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";
5
+ import {
6
+ Badge,
7
+ Button,
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ Input,
15
+ Label,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue
21
+ } from "@revenexx/studio";
22
+ import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
23
+ import NodeLogo from "../../../components/Integrations/NodeLogo.vue";
24
+ import { TEMPLATE_CATEGORIES } from "../../../data/integrationTemplates";
25
+ const SELECT_LATER = "__later__";
26
+ const route = useRoute();
27
+ const router = useRouter();
28
+ const { get, load, loaded, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
29
+ const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
30
+ const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
31
+ const { ofType, load: loadCredentials } = useIntegrationsCredentials();
32
+ const slug = computed(() => String(route.params.slug));
33
+ const tpl = computed(() => get(slug.value));
34
+ useHead({ title: () => `${tpl.value?.name ?? "Template"} \xB7 Integration Studio` });
35
+ const reqs = ref(null);
36
+ onMounted(async () => {
37
+ await load();
38
+ await Promise.all([fetchDetail(slug.value), loadNodeCatalog(), loadCredentials(), loadSecrets()]);
39
+ reqs.value = await requirements(slug.value);
40
+ });
41
+ const descriptionHtml = computed(() => {
42
+ const md = tpl.value?.descriptionMd ?? "";
43
+ if (!md.trim()) return "";
44
+ const withoutH1 = md.replace(/^#\s[^\n]*\n+/, "");
45
+ return marked.parse(withoutH1);
46
+ });
47
+ const steps = computed(() => {
48
+ const blob = tpl.value?.blob;
49
+ if (!blob?.nodes.length) return [];
50
+ const nodeById = new Map(blob.nodes.map((n) => [n.id, n]));
51
+ const happyEdges = (blob.edges ?? []).filter((e) => e.from.output !== "error");
52
+ const triggerHandles = new Set((blob.triggers ?? []).map((t) => t.id));
53
+ const incoming = /* @__PURE__ */ new Map();
54
+ for (const e of happyEdges) {
55
+ if (!triggerHandles.has(e.from.nodeId)) incoming.set(e.to.nodeId, (incoming.get(e.to.nodeId) ?? 0) + 1);
56
+ }
57
+ const ordered = [];
58
+ const seen = /* @__PURE__ */ new Set();
59
+ const queue = happyEdges.filter((e) => triggerHandles.has(e.from.nodeId)).map((e) => e.to.nodeId);
60
+ while (queue.length) {
61
+ const id = queue.shift();
62
+ if (seen.has(id)) continue;
63
+ seen.add(id);
64
+ const node = nodeById.get(id);
65
+ if (node) ordered.push(node);
66
+ for (const e of happyEdges) {
67
+ if (e.from.nodeId === id) queue.push(e.to.nodeId);
68
+ }
69
+ }
70
+ for (const n of blob.nodes) {
71
+ if (!seen.has(n.id) && n.nodeSlug !== "revenexx:stop-and-error") ordered.push(n);
72
+ }
73
+ return ordered.filter((n) => n.nodeSlug !== "revenexx:stop-and-error").map((node) => {
74
+ const manifest = findManifest(node.nodeSlug, node.nodeVersion);
75
+ return {
76
+ node,
77
+ manifest,
78
+ label: manifest ? resolveLocalized(manifest.name) : prettify(node.nodeSlug),
79
+ blurb: manifest ? firstSentence(resolveLocalized(manifest.description)) : ""
80
+ };
81
+ });
82
+ });
83
+ function prettify(s) {
84
+ const tail = s.includes(":") ? s.split(":").pop() : s;
85
+ return tail.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
86
+ }
87
+ function firstSentence(text) {
88
+ const idx = text.indexOf(". ");
89
+ return idx === -1 ? text : text.slice(0, idx + 1);
90
+ }
91
+ const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
92
+ const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((t) => {
93
+ let label = t.type.charAt(0).toUpperCase() + t.type.slice(1);
94
+ if (t.type === "schedule" && t.config?.cron) label = `Schedule \xB7 ${t.config.cron}${t.config.timezone ? ` (${t.config.timezone})` : ""}`;
95
+ if (t.type === "webhook" && t.config?.path) label = `Webhook \xB7 ${t.config.path}`;
96
+ if (t.type === "manual") label = "Run on demand";
97
+ return { key: t.id, icon: TRIGGER_ICON[t.type] ?? Zap, label };
98
+ }));
99
+ const variables = computed(() => Object.entries(tpl.value?.blob.variables ?? {}).map(([key, v]) => ({
100
+ key,
101
+ default: String(v?.default ?? ""),
102
+ description: resolveLocalized(v?.description ?? "")
103
+ })));
104
+ const variableColumns = [
105
+ { key: "key", label: "Variable", width: "minmax(0,180px)", sortable: true },
106
+ { key: "description", label: "Description" },
107
+ { key: "default", label: "Default", width: "minmax(0,200px)" }
108
+ ];
109
+ const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
110
+ const secretReqs = computed(() => reqs.value?.secrets ?? []);
111
+ const secretChecks = computed(() => {
112
+ const fromApi = secretReqs.value;
113
+ if (fromApi.length) {
114
+ return fromApi.map((s) => ({
115
+ key: s.key,
116
+ label: s.key ?? "(chosen at install)",
117
+ present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
118
+ }));
119
+ }
120
+ return (tpl.value?.requiredSecrets ?? []).map((key) => ({
121
+ key,
122
+ label: key,
123
+ present: secretKeys.value.includes(key)
124
+ }));
125
+ });
126
+ const missingSecrets = computed(() => secretChecks.value.filter((s) => !s.present).length);
127
+ const missingCredentials = computed(() => credentialReqs.value.filter((c) => !ofType(c.slug).length).length);
128
+ const setupReady = computed(() => missingSecrets.value === 0 && missingCredentials.value === 0);
129
+ const packageLabel = computed(() => {
130
+ const pkg = tpl.value?.package;
131
+ if (!pkg) return null;
132
+ const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
133
+ if (tail === "core") return null;
134
+ return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
135
+ });
136
+ const categoryLabel = computed(() => {
137
+ const cat = tpl.value?.category;
138
+ return TEMPLATE_CATEGORIES.find((c) => c.key === cat)?.label ?? (cat ? cat.charAt(0).toUpperCase() + cat.slice(1) : "");
139
+ });
140
+ const installOpen = ref(false);
141
+ const installing = ref(false);
142
+ const installError = ref(null);
143
+ const name = ref("");
144
+ const bindings = reactive({});
145
+ function nodesFor(usedBy) {
146
+ return [...new Set(usedBy.map((u) => u.nodeId))];
147
+ }
148
+ function openInstall() {
149
+ if (!tpl.value) return;
150
+ name.value = tpl.value.name;
151
+ for (const k of Object.keys(bindings)) delete bindings[k];
152
+ installError.value = null;
153
+ installOpen.value = true;
154
+ }
155
+ async function confirmInstall() {
156
+ if (!tpl.value) return;
157
+ installing.value = true;
158
+ installError.value = null;
159
+ try {
160
+ const credentialBindings = {};
161
+ for (const [nodeId, credId] of Object.entries(bindings)) {
162
+ if (credId && credId !== SELECT_LATER) credentialBindings[nodeId] = credId;
163
+ }
164
+ const id = await instantiate(tpl.value.slug, {
165
+ name: name.value.trim() || void 0,
166
+ credentialBindings
167
+ });
168
+ if (id) await router.push(`/integrations/workflows/${id}`);
169
+ } catch (e) {
170
+ installError.value = e.message || "Install failed.";
171
+ } finally {
172
+ installing.value = false;
173
+ }
174
+ }
175
+ const PUBLISHER_KIND_LABEL = { revenexx: "Official", partner: "Partner", community: "Community" };
176
+ function publisherBadgeClass(kind) {
177
+ 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";
178
+ }
179
+ function levelDot(level) {
180
+ return level === "beginner" ? "bg-emerald-500" : level === "intermediate" ? "bg-amber-500" : "bg-rose-500";
181
+ }
182
+ </script>
183
+
184
+ <template>
185
+ <IntegrationsLayout>
186
+ <template v-if="tpl">
187
+ <PageHeader :title="tpl.name">
188
+ <template #title>
189
+ <span class="inline-flex items-center gap-1.5">
190
+ <NuxtLink to="/integrations/templates" class="font-normal text-muted-foreground/80 hover:text-primary transition-colors">Templates</NuxtLink>
191
+ <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
192
+ <span class="truncate">{{ tpl.name }}</span>
193
+ </span>
194
+ </template>
195
+ <template #actions>
196
+ <Button class="h-7 text-sm" @click="openInstall">
197
+ Use template
198
+ </Button>
199
+ </template>
200
+ </PageHeader>
201
+
202
+ <div class="p-4 space-y-4">
203
+ <!-- Hero: the live workflow preview on the editor's dotted canvas -->
204
+ <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]">
205
+ <TemplateFlowPreview v-if="tpl.detailLoaded" :blob="tpl.blob" :height="400" class="px-4" />
206
+ <div v-else class="flex h-[400px] items-center justify-center">
207
+ <Loader2 :size="18" class="animate-spin text-muted-foreground/50" />
208
+ </div>
209
+ <div class="absolute left-3 top-3 flex items-center gap-1.5">
210
+ <span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
211
+ {{ categoryLabel }}
212
+ </span>
213
+ <span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
214
+ <span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
215
+ {{ tpl.level }}
216
+ </span>
217
+ <span v-if="packageLabel" class="inline-flex items-center whitespace-nowrap rounded-full bg-violet-100 px-2 py-0.5 uppercase text-xs font-medium text-violet-700">
218
+ {{ packageLabel }}
219
+ </span>
220
+ </div>
221
+ <div class="absolute right-3 top-3 flex items-center gap-1.5">
222
+ <span
223
+ v-for="chip in triggerChips"
224
+ :key="chip.key"
225
+ class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700"
226
+ >
227
+ <component :is="chip.icon" :size="10" />
228
+ {{ chip.label }}
229
+ </span>
230
+ </div>
231
+ </div>
232
+
233
+ <div class="grid gap-4 lg:grid-cols-3">
234
+ <div class="lg:col-span-2 space-y-4">
235
+ <!-- About: the registry description, rendered -->
236
+ <div v-if="descriptionHtml" class="rounded-lg border border-black/[0.06] bg-white p-4">
237
+ <h3 class="text-sm font-semibold mb-2">About this template</h3>
238
+ <div
239
+ 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"
240
+ v-html="descriptionHtml"
241
+ />
242
+ </div>
243
+
244
+ <!-- Step by step, humanized from the actual nodes -->
245
+ <div class="rounded-lg border border-black/[0.06] bg-white p-4">
246
+ <h3 class="text-sm font-semibold mb-3">Step by step</h3>
247
+ <ol v-if="steps.length" class="space-y-2.5">
248
+ <li
249
+ v-for="(step, i) in steps"
250
+ :key="step.node.id"
251
+ class="flex items-start gap-3"
252
+ >
253
+ <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-slate-100 text-sm font-semibold tabular-nums text-slate-500">
254
+ {{ i + 1 }}
255
+ </span>
256
+ <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-white ring-1 ring-black/[0.08]">
257
+ <NodeLogo :manifest="step.manifest" :size="15" />
258
+ </span>
259
+ <span class="min-w-0 pt-0.5">
260
+ <span class="block text-sm font-medium text-foreground">{{ step.label }}</span>
261
+ <span v-if="step.blurb" class="block text-sm text-muted-foreground">{{ step.blurb }}</span>
262
+ </span>
263
+ </li>
264
+ </ol>
265
+ <p v-else class="text-sm text-muted-foreground italic">
266
+ No step-by-step outline for this template yet.
267
+ </p>
268
+ </div>
269
+
270
+ <!-- Variables the operator can tune after install -->
271
+ <div v-if="variables.length" class="rounded-lg border border-black/[0.06] bg-white p-4">
272
+ <h3 class="text-sm font-semibold mb-1">Variables you can tune</h3>
273
+ <p class="text-sm text-muted-foreground mb-3">Set once after install, used across the workflow.</p>
274
+ <DataTable
275
+ :data="variables"
276
+ :columns="variableColumns"
277
+ :row-key="(v2) => v2.key"
278
+ :default-sort="{ column: 'key', direction: 'asc' }"
279
+ :search="false"
280
+ :columns-menu="false"
281
+ :pagination="false"
282
+ >
283
+ <template #cell-key="{ row: v }">
284
+ <code class="font-mono text-[13px]">{{ v.key }}</code>
285
+ </template>
286
+ <template #cell-description="{ row: v }">
287
+ <span class="text-sm text-muted-foreground">{{ v.description }}</span>
288
+ </template>
289
+ <template #cell-default="{ row: v }">
290
+ <code v-if="v.default" class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-600">{{ v.default }}</code>
291
+ </template>
292
+ </DataTable>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Setup rail -->
297
+ <div class="space-y-3">
298
+ <!-- Readiness + CTA -->
299
+ <div class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
300
+ <div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
301
+ <CheckCircle2 :size="14" />
302
+ Everything is ready to install.
303
+ </div>
304
+ <div v-else class="text-sm text-amber-700 space-y-0.5">
305
+ <div v-if="missingCredentials" class="flex items-center gap-1.5">
306
+ <Key :size="12" />
307
+ {{ missingCredentials }} credential{{ missingCredentials === 1 ? "" : "s" }} to connect
308
+ </div>
309
+ <div v-if="missingSecrets" class="flex items-center gap-1.5">
310
+ <ShieldAlert :size="12" />
311
+ {{ missingSecrets }} secret{{ missingSecrets === 1 ? "" : "s" }} missing
312
+ </div>
313
+ </div>
314
+ <Button class="w-full h-8 text-sm" @click="openInstall">
315
+ Use template
316
+ </Button>
317
+ <p class="text-xs text-muted-foreground">
318
+ Creates a paused copy in your workflows. Nothing runs until you activate it.
319
+ </p>
320
+ </div>
321
+
322
+ <div class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
323
+ <div class="text-mono text-muted-foreground">Publisher</div>
324
+ <div class="flex items-center gap-2">
325
+ <span class="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground">
326
+ <User :size="12" />
327
+ </span>
328
+ <div class="text-sm font-medium truncate">{{ tpl.publisher.name }}</div>
329
+ <Badge variant="outline" :class="['text-sm border', publisherBadgeClass(tpl.publisher.kind)]">
330
+ {{ PUBLISHER_KIND_LABEL[tpl.publisher.kind] ?? tpl.publisher.kind }}
331
+ </Badge>
332
+ </div>
333
+ <div v-if="packageLabel" class="border-t border-black/[0.06] pt-2 text-sm">
334
+ <div class="text-muted-foreground">Ships with</div>
335
+ <div class="font-medium">{{ packageLabel }}</div>
336
+ </div>
337
+ <div class="border-t border-black/[0.06] pt-2 grid grid-cols-2 gap-2 text-sm">
338
+ <div>
339
+ <div class="text-muted-foreground">Steps</div>
340
+ <div class="font-medium tabular-nums">{{ steps.length || tpl.nodeCount }}</div>
341
+ </div>
342
+ <div>
343
+ <div class="text-muted-foreground">Level</div>
344
+ <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">
345
+ <span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
346
+ {{ tpl.level }}
347
+ </span>
348
+ </div>
349
+ </div>
350
+ </div>
351
+
352
+ <div v-if="credentialReqs.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
353
+ <div class="flex items-center gap-1.5 text-mono text-muted-foreground">
354
+ <Key :size="11" /> Credentials
355
+ </div>
356
+ <ul class="space-y-1">
357
+ <li
358
+ v-for="c in credentialReqs"
359
+ :key="c.slug"
360
+ class="flex items-center justify-between gap-2 rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
361
+ >
362
+ <span class="min-w-0">
363
+ <span class="block truncate font-medium">{{ c.name ?? c.slug }}</span>
364
+ </span>
365
+ <Badge
366
+ v-if="ofType(c.slug).length"
367
+ variant="outline"
368
+ class="shrink-0 bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
369
+ >
370
+ <CheckCircle2 :size="10" class="mr-0.5" />
371
+ {{ ofType(c.slug).length }} ready
372
+ </Badge>
373
+ <Badge v-else variant="outline" class="shrink-0 bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
374
+ Connect first
375
+ </Badge>
376
+ </li>
377
+ </ul>
378
+ <NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">Manage credentials →</NuxtLink>
379
+ </div>
380
+
381
+ <div v-if="secretChecks.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
382
+ <div class="flex items-center gap-2">
383
+ <span class="text-mono text-muted-foreground">Required secrets</span>
384
+ <span v-if="missingSecrets > 0" class="flex items-center gap-1 text-sm text-amber-700">
385
+ <ShieldAlert :size="11" />
386
+ {{ missingSecrets }} missing
387
+ </span>
388
+ </div>
389
+ <ul class="space-y-1">
390
+ <li
391
+ v-for="(s, i) in secretChecks"
392
+ :key="s.key ?? `secret-${i}`"
393
+ class="flex items-center justify-between rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
394
+ >
395
+ <code class="font-mono">{{ s.label }}</code>
396
+ <Badge
397
+ v-if="s.present"
398
+ variant="outline"
399
+ class="bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
400
+ >
401
+ <CheckCircle2 :size="10" class="mr-0.5" />
402
+ Set
403
+ </Badge>
404
+ <Badge v-else variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
405
+ Missing
406
+ </Badge>
407
+ </li>
408
+ </ul>
409
+ </div>
410
+
411
+ <div v-if="tpl.industries.length || tpl.vendors.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
412
+ <div class="text-mono text-muted-foreground">Good fit for</div>
413
+ <div class="flex flex-wrap gap-1">
414
+ <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">
415
+ {{ ind === "any" ? "Any industry" : ind }}
416
+ </span>
417
+ <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">
418
+ {{ v }}
419
+ </span>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ </div>
424
+ </div>
425
+ </template>
426
+ <div v-else-if="!loaded" class="p-12 flex items-center justify-center gap-2 text-sm text-muted-foreground">
427
+ <Loader2 :size="14" class="animate-spin" />
428
+ Loading template…
429
+ </div>
430
+ <div v-else class="p-12 text-center text-sm text-muted-foreground">
431
+ Template not found.
432
+ </div>
433
+
434
+ <!-- Install: name + per-node credential binding ------------------------- -->
435
+ <Dialog v-model:open="installOpen">
436
+ <DialogContent class="sm:max-w-lg">
437
+ <DialogHeader>
438
+ <DialogTitle>Use template</DialogTitle>
439
+ <DialogDescription class="text-sm">
440
+ Pre-wire credentials, then we create the workflow. It starts inactive, so you can finish wiring in the editor and activate when ready.
441
+ </DialogDescription>
442
+ </DialogHeader>
443
+
444
+ <div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
445
+ <div class="space-y-1.5">
446
+ <Label for="wf-name">Workflow name</Label>
447
+ <Input id="wf-name" v-model="name" class="bg-white" placeholder="Workflow name" />
448
+ </div>
449
+
450
+ <!-- Credentials -->
451
+ <div class="space-y-2">
452
+ <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
453
+ Credentials
454
+ </div>
455
+ <p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
456
+ This template needs no credentials.
457
+ </p>
458
+ <div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
459
+ <div class="flex items-center gap-2">
460
+ <span class="text-sm font-medium">{{ req.name ?? req.slug }}</span>
461
+ <Badge v-if="!req.registered" variant="outline" class="bg-rose-500/10 border-rose-500/20 text-rose-700 text-sm">
462
+ not registered
463
+ </Badge>
464
+ <Badge v-else-if="!ofType(req.slug).length" variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
465
+ none yet
466
+ </Badge>
467
+ </div>
468
+
469
+ <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">
470
+ No credentials of this type yet.
471
+ <NuxtLink to="/integrations/credentials" class="text-primary hover:underline">Create one</NuxtLink>
472
+ first, or install now and wire it up in the editor.
473
+ </p>
474
+ <div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
475
+ <Label class="text-xs text-muted-foreground font-mono">node {{ nodeId }}</Label>
476
+ <Select
477
+ :model-value="bindings[nodeId] ?? SELECT_LATER"
478
+ @update:model-value="(v2) => {
479
+ bindings[nodeId] = v2 ?? SELECT_LATER;
480
+ }"
481
+ >
482
+ <SelectTrigger class="bg-white">
483
+ <SelectValue placeholder="Select later" />
484
+ </SelectTrigger>
485
+ <SelectContent>
486
+ <SelectItem :value="SELECT_LATER">Select later</SelectItem>
487
+ <SelectItem v-for="c in ofType(req.slug)" :key="c.id" :value="c.id">
488
+ {{ c.name }}
489
+ </SelectItem>
490
+ </SelectContent>
491
+ </Select>
492
+ </div>
493
+ </div>
494
+ </div>
495
+
496
+ <!-- Secrets -->
497
+ <div v-if="secretChecks.length" class="space-y-2">
498
+ <div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
499
+ Secrets
500
+ </div>
501
+ <p class="text-sm text-muted-foreground">
502
+ Referenced secret keys must exist before the workflow runs. Manage them on the
503
+ <NuxtLink to="/integrations/secrets" class="text-primary hover:underline">Secrets</NuxtLink> page.
504
+ </p>
505
+ <ul class="space-y-1">
506
+ <li
507
+ v-for="(s, i) in secretChecks"
508
+ :key="s.key ?? `dlg-secret-${i}`"
509
+ class="flex items-center justify-between rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
510
+ >
511
+ <code class="font-mono">{{ s.label }}</code>
512
+ <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'">
513
+ {{ s.present ? "Stored" : "Missing" }}
514
+ </Badge>
515
+ </li>
516
+ </ul>
517
+ </div>
518
+
519
+ <div v-if="installError" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
520
+ {{ installError }}
521
+ </div>
522
+ </div>
523
+
524
+ <DialogFooter>
525
+ <Button variant="outline" :disabled="installing" @click="installOpen = false">
526
+ Cancel
527
+ </Button>
528
+ <Button :disabled="installing || !name.trim()" @click="confirmInstall">
529
+ <Loader2 v-if="installing" :size="14" class="mr-1 animate-spin" />
530
+ {{ installing ? "Setting up\u2026" : "Install" }}
531
+ </Button>
532
+ </DialogFooter>
533
+ </DialogContent>
534
+ </Dialog>
535
+ </IntegrationsLayout>
536
+ </template>
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ export default _default;
@@ -0,0 +1,220 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from "vue";
3
+ import { Magnifier as Search, RefreshCircle as Loader2, Layers, DangerTriangle as AlertTriangle, Bolt as Zap, ClockCircle as Clock, Global as Webhook, Play } from "#solar-icons";
4
+ import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@revenexx/studio";
5
+ import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
6
+ import { TEMPLATE_CATEGORIES } from "../../../data/integrationTemplates";
7
+ useHead({ title: "Templates \xB7 Integration Studio" });
8
+ const { templates, loading, error, loaded, load, ensureDetails } = useIntegrationsTemplates();
9
+ const { load: loadNodeCatalog } = useIntegrationsNodes();
10
+ onMounted(async () => {
11
+ await Promise.all([load(true), loadNodeCatalog()]);
12
+ void ensureDetails(templates.value.map((t) => t.slug));
13
+ });
14
+ const search = ref("");
15
+ const activeCategory = ref("all");
16
+ const activeLevel = ref("all");
17
+ const sortBy = ref("name");
18
+ const CATEGORY_LABELS = Object.fromEntries(
19
+ TEMPLATE_CATEGORIES.map((c) => [c.key, c.label])
20
+ );
21
+ function categoryLabel(key) {
22
+ return CATEGORY_LABELS[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
23
+ }
24
+ const categories = computed(() => {
25
+ const counts = /* @__PURE__ */ new Map();
26
+ for (const t of templates.value) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
27
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([key, count]) => ({ key, label: categoryLabel(key), count }));
28
+ });
29
+ const LEVELS = ["beginner", "intermediate", "advanced"];
30
+ const LEVEL_RANK = { beginner: 0, intermediate: 1, advanced: 2 };
31
+ const filtered = computed(() => {
32
+ const q = search.value.trim().toLowerCase();
33
+ let items = templates.value;
34
+ if (activeCategory.value !== "all") items = items.filter((t) => t.category === activeCategory.value);
35
+ if (activeLevel.value !== "all") items = items.filter((t) => t.level === activeLevel.value);
36
+ if (q) {
37
+ items = items.filter(
38
+ (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.vendors.some((v) => v.includes(q)) || (packageLabel(t) ?? "").toLowerCase().includes(q) || t.industries.some((i) => i.includes(q))
39
+ );
40
+ }
41
+ const sorted = [...items];
42
+ if (sortBy.value === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
43
+ else if (sortBy.value === "level") sorted.sort((a, b) => (LEVEL_RANK[a.level] ?? 0) - (LEVEL_RANK[b.level] ?? 0) || a.name.localeCompare(b.name));
44
+ else sorted.sort((a, b) => b.nodeCount - a.nodeCount || a.name.localeCompare(b.name));
45
+ return sorted;
46
+ });
47
+ watch(() => templates.value.length, () => {
48
+ if (templates.value.length) void ensureDetails(templates.value.map((t) => t.slug));
49
+ });
50
+ const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
51
+ const TRIGGER_LABEL = { schedule: "Scheduled", webhook: "Webhook", manual: "Run on demand", event: "Event-driven" };
52
+ function levelDot(level) {
53
+ return level === "beginner" ? "bg-emerald-500" : level === "intermediate" ? "bg-amber-500" : "bg-rose-500";
54
+ }
55
+ function bannerFor(tpl) {
56
+ return tpl.images.find((i) => i.category === "banner")?.src ?? null;
57
+ }
58
+ function packageLabel(tpl) {
59
+ const pkg = tpl.package;
60
+ if (!pkg) return null;
61
+ const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
62
+ if (tail === "core") return null;
63
+ return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <IntegrationsLayout>
69
+ <!-- The toolbar lives in the header's #tabs slot: PageHeader is the
70
+ sticky element (top-0 z-20), so filters + search stay pinned. -->
71
+ <PageHeader
72
+ title="Templates"
73
+ description="Pre-built workflows you can clone, then tweak. Wired by revenexx and our partners."
74
+ >
75
+ <template #tabs>
76
+ <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">
77
+ <div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
78
+ <button
79
+ type="button"
80
+ class="inline-flex h-7 shrink-0 items-center rounded-full border px-3 text-sm transition"
81
+ :class="activeCategory === 'all' ? 'border-primary/30 bg-primary/10 text-primary font-medium' : 'border-black/[0.08] text-muted-foreground hover:border-black/20'"
82
+ @click="activeCategory = 'all'"
83
+ >
84
+ All <span class="opacity-70">({{ templates.length }})</span>
85
+ </button>
86
+ <button
87
+ v-for="cat in categories"
88
+ :key="cat.key"
89
+ type="button"
90
+ class="inline-flex h-7 shrink-0 items-center rounded-full border px-3 text-sm transition"
91
+ :class="activeCategory === cat.key ? 'border-primary/30 bg-primary/10 text-primary font-medium' : 'border-black/[0.08] text-muted-foreground hover:border-black/20'"
92
+ @click="activeCategory = activeCategory === cat.key ? 'all' : cat.key"
93
+ >
94
+ {{ cat.label }} <span class="opacity-70">({{ cat.count }})</span>
95
+ </button>
96
+ </div>
97
+
98
+ <Select v-model="activeLevel">
99
+ <SelectTrigger class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-36 rounded-full bg-white text-sm">
100
+ <SelectValue />
101
+ </SelectTrigger>
102
+ <SelectContent>
103
+ <SelectItem value="all">All levels</SelectItem>
104
+ <SelectItem v-for="l in LEVELS" :key="l" :value="l">{{ l.charAt(0).toUpperCase() + l.slice(1) }}</SelectItem>
105
+ </SelectContent>
106
+ </Select>
107
+ <Select v-model="sortBy">
108
+ <SelectTrigger class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-40 rounded-full bg-white text-sm">
109
+ <SelectValue />
110
+ </SelectTrigger>
111
+ <SelectContent>
112
+ <SelectItem value="name">Sort: A–Z</SelectItem>
113
+ <SelectItem value="level">Sort: Simplest first</SelectItem>
114
+ <SelectItem value="nodes">Sort: Most steps</SelectItem>
115
+ </SelectContent>
116
+ </Select>
117
+ <div class="relative w-60">
118
+ <Search :size="12" class="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
119
+ <Input v-model="search" placeholder="Search templates, vendors…" class="h-7 min-h-0 py-0 rounded-full pl-7 text-sm bg-white" />
120
+ </div>
121
+ </div>
122
+ </template>
123
+ </PageHeader>
124
+
125
+ <div class="p-4">
126
+ <!-- Loading (first fetch) -->
127
+ <div v-if="loading && !templates.length" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
128
+ <div v-for="n in 6" :key="`sk-${n}`" class="rounded-lg border border-black/[0.06] bg-white p-0 overflow-hidden">
129
+ <div class="h-36 bg-black/[0.04] animate-pulse" />
130
+ <div class="p-4 space-y-2">
131
+ <div class="h-4 w-2/3 rounded bg-black/[0.07] animate-pulse" />
132
+ <div class="h-3 w-full rounded bg-black/[0.05] animate-pulse" />
133
+ <div class="h-3 w-1/2 rounded bg-black/[0.05] animate-pulse" />
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <!-- Fetch failed -->
139
+ <div v-else-if="error && !templates.length" class="rounded-lg border border-rose-500/20 bg-rose-500/5 p-4 text-sm text-rose-700 flex items-start gap-2">
140
+ <AlertTriangle :size="15" class="mt-0.5 shrink-0" />
141
+ <div>
142
+ <div class="font-medium">Couldn't load templates.</div>
143
+ <div class="text-rose-700/80">{{ error }}</div>
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Empty registry -->
148
+ <div v-else-if="loaded && !templates.length" class="rounded-lg border border-black/[0.06] bg-white p-10 text-center">
149
+ <span class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
150
+ <Layers :size="18" />
151
+ </span>
152
+ <div class="text-sm font-semibold text-foreground">No templates registered yet</div>
153
+ <p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
154
+ Templates ship with node packages. Once a package that publishes templates is installed, they show up here.
155
+ </p>
156
+ </div>
157
+
158
+ <!-- Cards -->
159
+ <div v-else class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
160
+ <NuxtLink
161
+ v-for="tpl in filtered"
162
+ :key="tpl.slug"
163
+ :to="`/integrations/templates/${tpl.slug}`"
164
+ class="group flex flex-col overflow-hidden rounded-lg border border-black/[0.06] bg-white transition hover:border-black/20 hover:shadow-sm"
165
+ >
166
+ <!-- Live workflow preview (or the registry banner, when one ships) -->
167
+ <div class="relative h-36 shrink-0 border-b 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]">
168
+ <img
169
+ v-if="bannerFor(tpl)"
170
+ :src="bannerFor(tpl)"
171
+ alt=""
172
+ class="h-full w-full object-cover"
173
+ >
174
+ <TemplateFlowPreview v-else-if="tpl.detailLoaded" :blob="tpl.blob" :height="144" class="px-2" />
175
+ <div v-else class="flex h-full items-center justify-center">
176
+ <Loader2 :size="16" class="animate-spin text-muted-foreground/50" />
177
+ </div>
178
+ <div class="absolute left-2 top-2 flex items-center gap-1.5">
179
+ <span class="inline-flex items-center whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
180
+ {{ categoryLabel(tpl.category) }}
181
+ </span>
182
+ <span
183
+ v-if="packageLabel(tpl)"
184
+ class="inline-flex items-center whitespace-nowrap rounded-full bg-violet-100 px-2 py-0.5 uppercase text-xs font-medium text-violet-700"
185
+ >
186
+ {{ packageLabel(tpl) }}
187
+ </span>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="flex min-h-0 flex-1 flex-col p-4">
192
+ <div class="text-sm font-semibold text-foreground line-clamp-1">{{ tpl.name }}</div>
193
+ <p class="mt-1 flex-1 text-sm text-muted-foreground line-clamp-2">{{ tpl.description }}</p>
194
+
195
+ <div class="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
196
+ <span
197
+ v-for="t in tpl.triggerTypes"
198
+ :key="t"
199
+ class="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase"
200
+ >
201
+ <component :is="TRIGGER_ICON[t] ?? Zap" :size="10" />
202
+ {{ TRIGGER_LABEL[t] ?? t }}
203
+ </span>
204
+ <span class="flex-1" />
205
+ <span class="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase">
206
+ <span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
207
+ {{ tpl.level }}
208
+ </span>
209
+ <span v-if="tpl.publisher.kind !== 'revenexx'" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase">{{ tpl.publisher.name }}</span>
210
+ </div>
211
+ </div>
212
+ </NuxtLink>
213
+ </div>
214
+
215
+ <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">
216
+ No templates match these filters.
217
+ </div>
218
+ </div>
219
+ </IntegrationsLayout>
220
+ </template>
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ export default _default;
@@ -182,8 +182,10 @@ async function runFromEditor() {
182
182
  await run(primaryMode.value);
183
183
  }
184
184
  const runPayloadOpen = ref(false);
185
- const definedEvents = useDefinedEvents();
186
- const eventLog = useIntegrationsEvents();
185
+ const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
186
+ } };
187
+ const eventLog = typeof useIntegrationsEvents === "function" ? useIntegrationsEvents() : { events: ref([]), loadEvents: async () => {
188
+ } };
187
189
  const prefillBusy = ref(null);
188
190
  const eventTriggerTopic = computed(() => {
189
191
  const trigger = (workflow.value?.blob.triggers ?? []).find((t) => t.type === "event");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {
@@ -24,12 +24,13 @@
24
24
  "access": "public"
25
25
  },
26
26
  "dependencies": {
27
- "@nuxt/kit": "^4.0.0"
27
+ "@nuxt/kit": "^4.0.0",
28
+ "marked": "^18.0.5"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "@dagrejs/dagre": "^3.0.0",
31
32
  "@revenexx/studio": "*",
32
- "@revenexx/studio-shared": "^0.1.2",
33
+ "@revenexx/studio-shared": "^0.2.0",
33
34
  "@solar-icons/nuxt": "^1.2.0",
34
35
  "@tiptap/core": "^3.28.0",
35
36
  "@tiptap/extension-document": "^3.28.0",
@@ -50,6 +51,7 @@
50
51
  },
51
52
  "devDependencies": {
52
53
  "@nuxt/module-builder": "^1.0.2",
54
+ "@playwright/test": "^1.57.0",
53
55
  "@nuxt/kit": "^4.0.0",
54
56
  "@nuxt/schema": "^4.0.0",
55
57
  "nuxt": "^4.2.2",
@@ -60,6 +62,7 @@
60
62
  "license": "SEE LICENSE IN LICENSE",
61
63
  "scripts": {
62
64
  "build": "nuxt-module-build build",
63
- "test": "vitest run"
65
+ "test": "vitest run",
66
+ "test:e2e": "playwright test"
64
67
  }
65
68
  }