@nwire/studio 0.9.1
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/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +892 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Filter input — shadcn-vue's `Input` with a Lucide search icon. Two-way
|
|
4
|
+
* bound via v-model. `compact` shrinks the wrapper to a sidebar-friendly
|
|
5
|
+
* 18rem.
|
|
6
|
+
*/
|
|
7
|
+
import { Search } from "lucide-vue-next";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
modelValue: string;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
compact?: boolean;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
defineEmits<{ "update:modelValue": [value: string] }>();
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div :class="cn('relative', compact ? 'w-72' : 'w-full')">
|
|
22
|
+
<Search
|
|
23
|
+
class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500 pointer-events-none"
|
|
24
|
+
/>
|
|
25
|
+
<Input
|
|
26
|
+
:model-value="modelValue"
|
|
27
|
+
:placeholder="placeholder ?? 'filter…'"
|
|
28
|
+
data-testid="filter-input"
|
|
29
|
+
class="pl-7 h-9 text-sm bg-zinc-900 border-zinc-800 placeholder:text-zinc-600 focus-visible:border-zinc-600 focus-visible:ring-0"
|
|
30
|
+
@update:model-value="(v: string | number) => $emit('update:modelValue', String(v))"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
import JsonView from "./JsonView.vue";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof JsonView> = {
|
|
5
|
+
title: "Components/JsonView",
|
|
6
|
+
component: JsonView,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof JsonView>;
|
|
12
|
+
|
|
13
|
+
export const ZodSchema: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
label: "Body schema",
|
|
16
|
+
value: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
postId: { type: "string" },
|
|
20
|
+
authorId: { type: "string" },
|
|
21
|
+
body: { type: "string", minLength: 1, maxLength: 5000 },
|
|
22
|
+
},
|
|
23
|
+
required: ["authorId", "body"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Empty: Story = {
|
|
29
|
+
args: { label: "Empty value", value: undefined },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Capped: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
label: "Capped at 8 lines",
|
|
35
|
+
maxLines: 8,
|
|
36
|
+
value: Array.from({ length: 30 }, (_, i) => ({ id: `t-${i}`, status: "open" })),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Pretty-printed JSON inside a card. Used for schemas, payloads, and any
|
|
4
|
+
* other small JSON payload Studio surfaces. Indentation locked at 2 spaces.
|
|
5
|
+
*/
|
|
6
|
+
import { computed } from "vue";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
/** Anything JSON-serializable. `undefined` renders an empty card. */
|
|
10
|
+
value: unknown;
|
|
11
|
+
/** Heading shown above the card. Optional. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Cap rendering at N lines (CSS overflow); 0 = unlimited. Default: 0. */
|
|
14
|
+
maxLines?: number;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
const text = computed(() =>
|
|
18
|
+
props.value === undefined ? "" : JSON.stringify(props.value, null, 2),
|
|
19
|
+
);
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="space-y-2">
|
|
24
|
+
<h3 v-if="label" class="text-xs uppercase tracking-wide text-zinc-500">
|
|
25
|
+
{{ label }}
|
|
26
|
+
</h3>
|
|
27
|
+
<pre
|
|
28
|
+
data-testid="json-view"
|
|
29
|
+
class="text-xs bg-zinc-950 border border-zinc-800 rounded p-3 overflow-auto"
|
|
30
|
+
:style="maxLines ? `max-height: ${maxLines * 1.5}em` : ''"
|
|
31
|
+
>{{ text }}</pre
|
|
32
|
+
>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
import KindBadge from "./KindBadge.vue";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof KindBadge> = {
|
|
5
|
+
title: "Components/KindBadge",
|
|
6
|
+
component: KindBadge,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
argTypes: {
|
|
9
|
+
variant: {
|
|
10
|
+
control: { type: "select" },
|
|
11
|
+
options: [
|
|
12
|
+
"neutral",
|
|
13
|
+
"info",
|
|
14
|
+
"success",
|
|
15
|
+
"warning",
|
|
16
|
+
"danger",
|
|
17
|
+
"muted",
|
|
18
|
+
"public",
|
|
19
|
+
"private",
|
|
20
|
+
"active",
|
|
21
|
+
"deprecated",
|
|
22
|
+
"sunset",
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
type Story = StoryObj<typeof KindBadge>;
|
|
30
|
+
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
args: { variant: "neutral" },
|
|
33
|
+
render: (args) => ({
|
|
34
|
+
components: { KindBadge },
|
|
35
|
+
setup: () => ({ args }),
|
|
36
|
+
template: `<KindBadge v-bind="args">submissions</KindBadge>`,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const AllVariants: Story = {
|
|
41
|
+
render: () => ({
|
|
42
|
+
components: { KindBadge },
|
|
43
|
+
template: `
|
|
44
|
+
<div class="flex flex-wrap gap-2">
|
|
45
|
+
<KindBadge variant="neutral">neutral</KindBadge>
|
|
46
|
+
<KindBadge variant="info">info</KindBadge>
|
|
47
|
+
<KindBadge variant="success">success</KindBadge>
|
|
48
|
+
<KindBadge variant="warning">warning</KindBadge>
|
|
49
|
+
<KindBadge variant="danger">danger</KindBadge>
|
|
50
|
+
<KindBadge variant="muted">muted</KindBadge>
|
|
51
|
+
<KindBadge variant="public">public</KindBadge>
|
|
52
|
+
<KindBadge variant="private">private</KindBadge>
|
|
53
|
+
<KindBadge variant="active">active</KindBadge>
|
|
54
|
+
<KindBadge variant="deprecated">deprecated</KindBadge>
|
|
55
|
+
<KindBadge variant="sunset">sunset</KindBadge>
|
|
56
|
+
</div>
|
|
57
|
+
`,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const ResolverStatuses: Story = {
|
|
62
|
+
render: () => ({
|
|
63
|
+
components: { KindBadge },
|
|
64
|
+
template: `
|
|
65
|
+
<div class="flex flex-wrap gap-2">
|
|
66
|
+
<KindBadge variant="active">active</KindBadge>
|
|
67
|
+
<KindBadge variant="deprecated">deprecated</KindBadge>
|
|
68
|
+
<KindBadge variant="sunset">sunset</KindBadge>
|
|
69
|
+
</div>
|
|
70
|
+
`,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Semantic badge — wraps shadcn-vue's `Badge` with the Nwire-specific
|
|
4
|
+
* palette (active/deprecated/sunset/public/private/etc.). shadcn's four
|
|
5
|
+
* built-in variants don't carry domain meaning, so we layer our colour
|
|
6
|
+
* tokens via `class` instead of forking the base component.
|
|
7
|
+
*/
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import { Badge } from "@/components/ui/badge";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
variant?:
|
|
14
|
+
| "neutral"
|
|
15
|
+
| "info"
|
|
16
|
+
| "success"
|
|
17
|
+
| "warning"
|
|
18
|
+
| "danger"
|
|
19
|
+
| "muted"
|
|
20
|
+
| "public"
|
|
21
|
+
| "private"
|
|
22
|
+
| "active"
|
|
23
|
+
| "deprecated"
|
|
24
|
+
| "sunset";
|
|
25
|
+
uppercase?: boolean;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const variant = computed(() => props.variant ?? "neutral");
|
|
29
|
+
|
|
30
|
+
const palette: Record<string, string> = {
|
|
31
|
+
neutral: "bg-zinc-900 border-zinc-800 text-zinc-300",
|
|
32
|
+
info: "bg-blue-950/50 border-blue-900 text-blue-300",
|
|
33
|
+
success: "bg-emerald-950/50 border-emerald-900 text-emerald-300",
|
|
34
|
+
warning: "bg-amber-950/50 border-amber-900 text-amber-300",
|
|
35
|
+
danger: "bg-rose-950/50 border-rose-900 text-rose-300",
|
|
36
|
+
muted: "bg-zinc-950 border-zinc-900 text-zinc-500",
|
|
37
|
+
public: "bg-emerald-950/50 border-emerald-900 text-emerald-300",
|
|
38
|
+
private: "bg-zinc-950 border-zinc-900 text-zinc-500",
|
|
39
|
+
active: "bg-emerald-950/50 border-emerald-900 text-emerald-300",
|
|
40
|
+
deprecated: "bg-amber-950/50 border-amber-900 text-amber-300",
|
|
41
|
+
sunset: "bg-rose-950/50 border-rose-900 text-rose-300",
|
|
42
|
+
};
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<Badge
|
|
47
|
+
variant="outline"
|
|
48
|
+
:class="
|
|
49
|
+
cn(
|
|
50
|
+
'rounded text-[10px] px-2 py-0.5 font-normal',
|
|
51
|
+
palette[variant],
|
|
52
|
+
(uppercase ?? true) ? 'uppercase tracking-wide' : '',
|
|
53
|
+
)
|
|
54
|
+
"
|
|
55
|
+
data-testid="kind-badge"
|
|
56
|
+
>
|
|
57
|
+
<slot />
|
|
58
|
+
</Badge>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
import { Network, Zap, Globe, Lock } from "lucide-vue-next";
|
|
3
|
+
import ListRow from "./ListRow.vue";
|
|
4
|
+
import KindBadge from "./KindBadge.vue";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ListRow> = {
|
|
7
|
+
title: "Components/ListRow",
|
|
8
|
+
component: ListRow,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
};
|
|
11
|
+
export default meta;
|
|
12
|
+
|
|
13
|
+
type Story = StoryObj<typeof ListRow>;
|
|
14
|
+
|
|
15
|
+
export const Resolver: Story = {
|
|
16
|
+
render: () => ({
|
|
17
|
+
components: { ListRow, KindBadge, Network },
|
|
18
|
+
template: `
|
|
19
|
+
<div class="bg-zinc-950 border border-zinc-800 rounded w-[480px]">
|
|
20
|
+
<ListRow>
|
|
21
|
+
<template #title>
|
|
22
|
+
<Network class="w-3 h-3 text-rose-400 shrink-0" />
|
|
23
|
+
<span class="font-mono text-sm">moderation.SubmitPost</span>
|
|
24
|
+
<span class="text-[10px] text-zinc-500 font-mono">v1</span>
|
|
25
|
+
</template>
|
|
26
|
+
<template #meta>
|
|
27
|
+
<KindBadge variant="active">active</KindBadge>
|
|
28
|
+
<span class="text-[10px] text-zinc-500">moderation-queue</span>
|
|
29
|
+
</template>
|
|
30
|
+
<template #description>Marta submits a post for review.</template>
|
|
31
|
+
</ListRow>
|
|
32
|
+
</div>
|
|
33
|
+
`,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const ActionWithVisibility: Story = {
|
|
38
|
+
render: () => ({
|
|
39
|
+
components: { ListRow, Zap, Globe, Lock },
|
|
40
|
+
template: `
|
|
41
|
+
<div class="bg-zinc-950 border border-zinc-800 rounded w-[480px]">
|
|
42
|
+
<ListRow selected>
|
|
43
|
+
<template #title>
|
|
44
|
+
<Zap class="w-3 h-3 text-amber-400 shrink-0" />
|
|
45
|
+
<span class="font-mono text-sm">moderation.submit-post</span>
|
|
46
|
+
</template>
|
|
47
|
+
<template #meta>
|
|
48
|
+
<Globe class="w-3 h-3 text-emerald-400" />
|
|
49
|
+
<span class="text-[10px] text-zinc-500">moderation-queue</span>
|
|
50
|
+
</template>
|
|
51
|
+
<template #description>Marta sends a freshly-written post for human review.</template>
|
|
52
|
+
</ListRow>
|
|
53
|
+
</div>
|
|
54
|
+
`,
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One row in a master list — shadcn-vue's `Button` with the `ghost`
|
|
4
|
+
* variant gives us the focus ring + keyboard accessibility for free, and
|
|
5
|
+
* we just override its layout to be a full-width left-aligned card.
|
|
6
|
+
*
|
|
7
|
+
* <ListRow :selected="…" @click="…">
|
|
8
|
+
* <template #title>...</template>
|
|
9
|
+
* <template #meta>...badges...</template>
|
|
10
|
+
* <template #description>...</template>
|
|
11
|
+
* </ListRow>
|
|
12
|
+
*/
|
|
13
|
+
import { Button } from "@/components/ui/button";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
defineProps<{
|
|
17
|
+
selected?: boolean;
|
|
18
|
+
}>();
|
|
19
|
+
defineEmits<{ click: [] }>();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<Button
|
|
24
|
+
variant="ghost"
|
|
25
|
+
:class="
|
|
26
|
+
cn(
|
|
27
|
+
'w-full h-auto justify-start text-left px-4 py-2.5 rounded-none border-b border-zinc-900 hover:bg-zinc-900/50 focus-visible:ring-1 focus-visible:ring-zinc-700 focus-visible:ring-offset-0',
|
|
28
|
+
selected ? 'bg-zinc-900' : '',
|
|
29
|
+
)
|
|
30
|
+
"
|
|
31
|
+
data-testid="list-row"
|
|
32
|
+
@click="$emit('click')"
|
|
33
|
+
>
|
|
34
|
+
<div class="w-full flex flex-col gap-1 min-w-0">
|
|
35
|
+
<div class="w-full flex items-center justify-between gap-2">
|
|
36
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
37
|
+
<slot name="title" />
|
|
38
|
+
</div>
|
|
39
|
+
<div class="flex items-center gap-1 shrink-0">
|
|
40
|
+
<slot name="meta" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div v-if="$slots.description" class="text-xs text-zinc-500 line-clamp-2 w-full font-normal">
|
|
44
|
+
<slot name="description" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</Button>
|
|
48
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { Network } from "lucide-vue-next";
|
|
4
|
+
import MasterDetail from "./MasterDetail.vue";
|
|
5
|
+
import ListRow from "./ListRow.vue";
|
|
6
|
+
import FilterInput from "./FilterInput.vue";
|
|
7
|
+
import KindBadge from "./KindBadge.vue";
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof MasterDetail> = {
|
|
10
|
+
title: "Components/MasterDetail",
|
|
11
|
+
component: MasterDetail,
|
|
12
|
+
tags: ["autodocs"],
|
|
13
|
+
parameters: { layout: "fullscreen" },
|
|
14
|
+
};
|
|
15
|
+
export default meta;
|
|
16
|
+
|
|
17
|
+
type Story = StoryObj<typeof MasterDetail>;
|
|
18
|
+
|
|
19
|
+
const sample = [
|
|
20
|
+
{ id: "1", name: "moderation.SubmitPost", status: "active", summary: "Marta submits a post" },
|
|
21
|
+
{ id: "2", name: "moderation.ClaimForReview", status: "active", summary: "Dina claims a post" },
|
|
22
|
+
{
|
|
23
|
+
id: "3",
|
|
24
|
+
name: "moderation.ApprovePost",
|
|
25
|
+
status: "deprecated",
|
|
26
|
+
summary: "Dina approves a post",
|
|
27
|
+
},
|
|
28
|
+
{ id: "4", name: "moderation.RejectPost", status: "sunset", summary: "Dina rejects a post" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const ResolversExample: Story = {
|
|
32
|
+
render: () => ({
|
|
33
|
+
components: { MasterDetail, ListRow, FilterInput, KindBadge, Network },
|
|
34
|
+
setup() {
|
|
35
|
+
const filter = ref("");
|
|
36
|
+
const selected = ref<string | null>("1");
|
|
37
|
+
const detail = () => sample.find((s) => s.id === selected.value);
|
|
38
|
+
return { filter, selected, sample, detail, Network };
|
|
39
|
+
},
|
|
40
|
+
template: `
|
|
41
|
+
<div style="height: 80vh" class="border border-zinc-800 rounded">
|
|
42
|
+
<MasterDetail>
|
|
43
|
+
<template #listHeader>
|
|
44
|
+
<FilterInput v-model="filter" placeholder="filter…" />
|
|
45
|
+
</template>
|
|
46
|
+
<template #list>
|
|
47
|
+
<ListRow
|
|
48
|
+
v-for="s in sample"
|
|
49
|
+
:key="s.id"
|
|
50
|
+
:selected="selected === s.id"
|
|
51
|
+
@click="selected = s.id"
|
|
52
|
+
>
|
|
53
|
+
<template #title>
|
|
54
|
+
<Network class="w-3 h-3 text-rose-400 shrink-0" />
|
|
55
|
+
<span class="font-mono text-sm truncate">{{ s.name }}</span>
|
|
56
|
+
</template>
|
|
57
|
+
<template #meta>
|
|
58
|
+
<KindBadge :variant="s.status">{{ s.status }}</KindBadge>
|
|
59
|
+
</template>
|
|
60
|
+
<template #description>{{ s.summary }}</template>
|
|
61
|
+
</ListRow>
|
|
62
|
+
</template>
|
|
63
|
+
<template v-if="detail()" #detail>
|
|
64
|
+
<div class="p-6">
|
|
65
|
+
<h2 class="font-mono text-xl">{{ detail()?.name }}</h2>
|
|
66
|
+
<p class="text-sm text-zinc-400 mt-2">{{ detail()?.summary }}</p>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
<template #empty>Select an item.</template>
|
|
70
|
+
</MasterDetail>
|
|
71
|
+
</div>
|
|
72
|
+
`,
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Two-pane master/detail layout used by Actions, Events, Resolvers,
|
|
4
|
+
* Workflows. The list pane uses shadcn-vue's `ScrollArea` so long lists
|
|
5
|
+
* get a styled scrollbar consistent with the rest of the UI.
|
|
6
|
+
*
|
|
7
|
+
* <MasterDetail>
|
|
8
|
+
* <template #listHeader>...filter input...</template>
|
|
9
|
+
* <template #list>...items...</template>
|
|
10
|
+
* <template #detail>...selected item...</template>
|
|
11
|
+
* <template #empty>"Select an item."</template>
|
|
12
|
+
* </MasterDetail>
|
|
13
|
+
*/
|
|
14
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="h-full flex" data-testid="master-detail">
|
|
19
|
+
<div class="w-2/5 border-r border-zinc-800 flex flex-col">
|
|
20
|
+
<div class="border-b border-zinc-800 px-4 py-3 shrink-0">
|
|
21
|
+
<slot name="listHeader" />
|
|
22
|
+
</div>
|
|
23
|
+
<ScrollArea class="flex-1" data-testid="master-list">
|
|
24
|
+
<slot name="list" />
|
|
25
|
+
</ScrollArea>
|
|
26
|
+
</div>
|
|
27
|
+
<ScrollArea class="flex-1" data-testid="detail-pane">
|
|
28
|
+
<slot name="detail">
|
|
29
|
+
<div class="p-6 text-zinc-500 text-sm">
|
|
30
|
+
<slot name="empty">Select an item.</slot>
|
|
31
|
+
</div>
|
|
32
|
+
</slot>
|
|
33
|
+
</ScrollArea>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `MonacoViewer` — read-only code view backed by Monaco Editor. The
|
|
4
|
+
* editor module is lazy-imported the first time the component mounts;
|
|
5
|
+
* subsequent instances share the loaded module. ~3MB on first open,
|
|
6
|
+
* negligible after.
|
|
7
|
+
*
|
|
8
|
+
* <MonacoViewer
|
|
9
|
+
* :content="text"
|
|
10
|
+
* language="typescript"
|
|
11
|
+
* :highlight-line="42"
|
|
12
|
+
* />
|
|
13
|
+
*
|
|
14
|
+
* Designed for source-viewer drawers (the file referenced by a
|
|
15
|
+
* `SourcePill`), not for editing. Editing is out of scope — readers
|
|
16
|
+
* should jump to their IDE for that.
|
|
17
|
+
*/
|
|
18
|
+
import { onMounted, onBeforeUnmount, ref, watch } from "vue";
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
/** File contents to display. */
|
|
22
|
+
content: string;
|
|
23
|
+
/** Monaco language id (`typescript`, `javascript`, `json`, `markdown`, ...). */
|
|
24
|
+
language?: string;
|
|
25
|
+
/** Line to scroll into view + visually mark. 1-based. */
|
|
26
|
+
highlightLine?: number;
|
|
27
|
+
/** Override max height; default is the parent's height. */
|
|
28
|
+
maxHeight?: string;
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const container = ref<HTMLElement>();
|
|
32
|
+
let editor:
|
|
33
|
+
| {
|
|
34
|
+
dispose(): void;
|
|
35
|
+
setValue(v: string): void;
|
|
36
|
+
revealLineInCenter(l: number): void;
|
|
37
|
+
updateOptions(o: Record<string, unknown>): void;
|
|
38
|
+
}
|
|
39
|
+
| undefined;
|
|
40
|
+
let monacoModule: typeof import("monaco-editor") | undefined;
|
|
41
|
+
let decorationIds: string[] = [];
|
|
42
|
+
|
|
43
|
+
async function ensureMonaco(): Promise<typeof import("monaco-editor")> {
|
|
44
|
+
if (!monacoModule) {
|
|
45
|
+
monacoModule = await import("monaco-editor");
|
|
46
|
+
// Disable web workers — they need a worker URL we don't ship.
|
|
47
|
+
// Falls back to running languages in the main thread (fine for read-only).
|
|
48
|
+
(self as unknown as { MonacoEnvironment: unknown }).MonacoEnvironment = {
|
|
49
|
+
getWorker() {
|
|
50
|
+
return {
|
|
51
|
+
postMessage() {},
|
|
52
|
+
terminate() {},
|
|
53
|
+
addEventListener() {},
|
|
54
|
+
removeEventListener() {},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return monacoModule;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function mount() {
|
|
63
|
+
if (!container.value) return;
|
|
64
|
+
const monaco = await ensureMonaco();
|
|
65
|
+
editor = monaco.editor.create(container.value, {
|
|
66
|
+
value: props.content,
|
|
67
|
+
language: props.language ?? "typescript",
|
|
68
|
+
readOnly: true,
|
|
69
|
+
domReadOnly: true,
|
|
70
|
+
minimap: { enabled: false },
|
|
71
|
+
scrollBeyondLastLine: false,
|
|
72
|
+
automaticLayout: true,
|
|
73
|
+
fontSize: 12,
|
|
74
|
+
lineNumbers: "on",
|
|
75
|
+
folding: true,
|
|
76
|
+
renderLineHighlight: "all",
|
|
77
|
+
scrollbar: { vertical: "auto", horizontal: "auto" },
|
|
78
|
+
theme: "vs-dark",
|
|
79
|
+
overviewRulerBorder: false,
|
|
80
|
+
glyphMargin: false,
|
|
81
|
+
contextmenu: false,
|
|
82
|
+
});
|
|
83
|
+
applyHighlight();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function applyHighlight() {
|
|
87
|
+
if (!editor || !monacoModule || !props.highlightLine) return;
|
|
88
|
+
const line = props.highlightLine;
|
|
89
|
+
decorationIds = (
|
|
90
|
+
editor as unknown as { deltaDecorations(o: string[], n: unknown[]): string[] }
|
|
91
|
+
).deltaDecorations(decorationIds, [
|
|
92
|
+
{
|
|
93
|
+
range: new monacoModule.Range(line, 1, line, 1),
|
|
94
|
+
options: {
|
|
95
|
+
isWholeLine: true,
|
|
96
|
+
className: "monaco-source-highlight",
|
|
97
|
+
marginClassName: "monaco-source-highlight-margin",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
editor.revealLineInCenter(line);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onMounted(() => {
|
|
105
|
+
void mount();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
onBeforeUnmount(() => {
|
|
109
|
+
if (editor) {
|
|
110
|
+
editor.dispose();
|
|
111
|
+
editor = undefined;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
watch(
|
|
116
|
+
() => props.content,
|
|
117
|
+
(v) => {
|
|
118
|
+
editor?.setValue(v ?? "");
|
|
119
|
+
applyHighlight();
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
watch(
|
|
124
|
+
() => props.highlightLine,
|
|
125
|
+
() => applyHighlight(),
|
|
126
|
+
);
|
|
127
|
+
</script>
|
|
128
|
+
|
|
129
|
+
<template>
|
|
130
|
+
<div
|
|
131
|
+
ref="container"
|
|
132
|
+
class="w-full h-full min-h-[200px] rounded-md overflow-hidden border border-zinc-800"
|
|
133
|
+
:style="maxHeight ? { maxHeight } : {}"
|
|
134
|
+
/>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<style>
|
|
138
|
+
/* Loaded globally so monaco's injected DOM picks it up. */
|
|
139
|
+
.monaco-source-highlight {
|
|
140
|
+
background-color: rgba(249, 115, 22, 0.1);
|
|
141
|
+
border-left: 2px solid rgba(249, 115, 22, 0.7);
|
|
142
|
+
}
|
|
143
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
import { Network, GitBranch } from "lucide-vue-next";
|
|
3
|
+
import PageHeader from "./PageHeader.vue";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof PageHeader> = {
|
|
6
|
+
title: "Components/PageHeader",
|
|
7
|
+
component: PageHeader,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof PageHeader>;
|
|
13
|
+
|
|
14
|
+
export const Basic: Story = {
|
|
15
|
+
args: { title: "Modules" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const WithSubtitle: Story = {
|
|
19
|
+
args: {
|
|
20
|
+
title: "Resolvers",
|
|
21
|
+
subtitle: "Interface layer — every operation exposed to transports",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const WithIconAndCount: Story = {
|
|
26
|
+
args: {
|
|
27
|
+
title: "Resolvers",
|
|
28
|
+
subtitle: "Interface layer — every operation",
|
|
29
|
+
icon: Network,
|
|
30
|
+
iconColor: "text-rose-400",
|
|
31
|
+
count: 4,
|
|
32
|
+
total: 17,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const Workflows: Story = {
|
|
37
|
+
args: {
|
|
38
|
+
title: "Workflows",
|
|
39
|
+
subtitle: "Event-driven side effects — reactions, translators, sagas",
|
|
40
|
+
icon: GitBranch,
|
|
41
|
+
iconColor: "text-violet-400",
|
|
42
|
+
count: 7,
|
|
43
|
+
total: 7,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Page header — title + icon + optional count + slot for actions.
|
|
4
|
+
*
|
|
5
|
+
* Used at the top of every Studio data page so the chrome looks the same:
|
|
6
|
+
* title, optional Lucide icon, optional "x / y" count badge, action slot.
|
|
7
|
+
*/
|
|
8
|
+
import type { FunctionalComponent } from "vue";
|
|
9
|
+
|
|
10
|
+
defineProps<{
|
|
11
|
+
/** Page title — e.g. "Resolvers". */
|
|
12
|
+
title: string;
|
|
13
|
+
/** Optional subtitle / description shown below the title. */
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
/** Lucide icon component, e.g. `Globe`. */
|
|
16
|
+
icon?: FunctionalComponent;
|
|
17
|
+
/** Tailwind text color class for the icon. Default: `text-zinc-300`. */
|
|
18
|
+
iconColor?: string;
|
|
19
|
+
/** Filtered count shown as `count / total`. */
|
|
20
|
+
count?: number;
|
|
21
|
+
/** Total count for the `count / total` badge. */
|
|
22
|
+
total?: number;
|
|
23
|
+
}>();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="flex items-start justify-between gap-4">
|
|
28
|
+
<div>
|
|
29
|
+
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
|
30
|
+
<component :is="icon" v-if="icon" class="w-6 h-6" :class="iconColor ?? 'text-zinc-300'" />
|
|
31
|
+
{{ title }}
|
|
32
|
+
</h1>
|
|
33
|
+
<p v-if="subtitle" class="text-sm text-zinc-500 mt-1">{{ subtitle }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="flex items-center gap-3 shrink-0">
|
|
36
|
+
<span
|
|
37
|
+
v-if="total !== undefined"
|
|
38
|
+
class="text-xs text-zinc-500 font-mono"
|
|
39
|
+
data-testid="page-count"
|
|
40
|
+
>
|
|
41
|
+
{{ count ?? total }} / {{ total }}
|
|
42
|
+
</span>
|
|
43
|
+
<slot name="actions" />
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|