@ddd-ts/event-tree-viewer 0.0.0-eventviz.10

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -0
  3. package/dist/cli.mjs +20 -0
  4. package/index.html +13 -0
  5. package/package.json +67 -0
  6. package/src/App.tsx +153 -0
  7. package/src/application/trpc-client.ts +6 -0
  8. package/src/application/use-direction.ts +18 -0
  9. package/src/application/use-domains.ts +9 -0
  10. package/src/application/use-expansion.ts +42 -0
  11. package/src/application/use-filters.ts +78 -0
  12. package/src/application/use-graph.ts +38 -0
  13. package/src/application/use-reveal.ts +26 -0
  14. package/src/application/use-selection.ts +15 -0
  15. package/src/application/use-settings.ts +84 -0
  16. package/src/application/use-view-mode.ts +14 -0
  17. package/src/assets/fonts/Monor_Regular.otf +0 -0
  18. package/src/assets/fonts/Supreme-Variable.woff2 +0 -0
  19. package/src/assets/fonts/Supreme-VariableItalic.woff2 +0 -0
  20. package/src/assets/fonts/monor-bold.otf +0 -0
  21. package/src/assets/react.svg +1 -0
  22. package/src/cli.ts +29 -0
  23. package/src/components/direction-toggle.tsx +28 -0
  24. package/src/components/domain-header.tsx +44 -0
  25. package/src/components/export-dialog.tsx +164 -0
  26. package/src/components/filter-bar.tsx +17 -0
  27. package/src/components/header.tsx +37 -0
  28. package/src/components/inspector.tsx +183 -0
  29. package/src/components/kind-filter.tsx +70 -0
  30. package/src/components/node-badge.tsx +19 -0
  31. package/src/components/node-name.tsx +66 -0
  32. package/src/components/settings-menu.tsx +147 -0
  33. package/src/components/ui/badge.tsx +52 -0
  34. package/src/components/ui/button.tsx +56 -0
  35. package/src/components/ui/card.tsx +103 -0
  36. package/src/components/ui/checkbox.tsx +28 -0
  37. package/src/components/ui/dialog.tsx +108 -0
  38. package/src/components/ui/input.tsx +20 -0
  39. package/src/components/ui/popover.tsx +88 -0
  40. package/src/components/ui/scroll-area.tsx +54 -0
  41. package/src/components/ui/select.tsx +88 -0
  42. package/src/components/ui/separator.tsx +23 -0
  43. package/src/components/ui/toggle-group.tsx +89 -0
  44. package/src/components/ui/toggle.tsx +43 -0
  45. package/src/components/view-switcher.tsx +28 -0
  46. package/src/components/views/graph-view.tsx +1203 -0
  47. package/src/components/views/list-view.tsx +109 -0
  48. package/src/components/views/tree-view.tsx +485 -0
  49. package/src/domain/cypher-export.ts +66 -0
  50. package/src/domain/direction.ts +1 -0
  51. package/src/domain/domain-grouping.ts +217 -0
  52. package/src/domain/edge.ts +37 -0
  53. package/src/domain/filter.ts +21 -0
  54. package/src/domain/flatten-tree.ts +167 -0
  55. package/src/domain/graph.ts +42 -0
  56. package/src/domain/node.ts +28 -0
  57. package/src/domain/roots.ts +18 -0
  58. package/src/domain/traversal.ts +60 -0
  59. package/src/index.css +205 -0
  60. package/src/lib/utils.ts +6 -0
  61. package/src/main.tsx +16 -0
  62. package/src/server/router.ts +87 -0
  63. package/src/server/vite-plugin.ts +99 -0
  64. package/vite.config.ts +36 -0
package/src/cli.ts ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "node:path"
4
+ import { styleText } from "node:util"
5
+ import { createServer } from "vite"
6
+
7
+ const dirname =
8
+ typeof __dirname === "undefined"
9
+ ? path.dirname(new URL(import.meta.url).pathname)
10
+ : __dirname
11
+
12
+ const vite = await createServer({
13
+ root: path.resolve(dirname, "../"),
14
+ configFile: path.resolve(dirname, "../vite.config.ts"),
15
+ logLevel: "error",
16
+ })
17
+
18
+ const server = await vite.listen(20031)
19
+
20
+ const SCAN_ROOT = path.resolve(process.env.EVENTVIZ_SCAN_ROOT ?? process.cwd())
21
+
22
+ console.log(styleText(["bold", "underline"], "Event Tree Viewer"))
23
+ console.log(` Status ${styleText("green", "running")}`)
24
+ console.log(
25
+ ` URL ${styleText("cyan", server.resolvedUrls?.local[0] ?? "unknown")}`
26
+ )
27
+ console.log(` Root ${styleText("yellow", SCAN_ROOT)}`)
28
+ console.log()
29
+ console.log(styleText("dim", "Press Ctrl+C to stop the server"))
@@ -0,0 +1,28 @@
1
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
2
+ import type { Direction } from "@/domain/direction"
3
+ import type { DirectionApi } from "@/application/use-direction"
4
+
5
+ const OPTIONS: { value: Direction; label: string }[] = [
6
+ { value: "forward", label: "Forward" },
7
+ { value: "reverse", label: "Reverse" },
8
+ ]
9
+
10
+ export function DirectionToggle({ direction }: { direction: DirectionApi }) {
11
+ return (
12
+ <ToggleGroup
13
+ value={[direction.direction]}
14
+ onValueChange={(next) => {
15
+ const picked = next[0] as Direction | undefined
16
+ if (picked) direction.setDirection(picked)
17
+ }}
18
+ variant="outline"
19
+ size="sm"
20
+ >
21
+ {OPTIONS.map((opt) => (
22
+ <ToggleGroupItem key={opt.value} value={opt.value}>
23
+ {opt.label}
24
+ </ToggleGroupItem>
25
+ ))}
26
+ </ToggleGroup>
27
+ )
28
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react"
2
+ import { CaretRightIcon } from "@phosphor-icons/react"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ interface DomainHeaderProps {
6
+ label: string
7
+ expanded: boolean
8
+ onToggle: () => void
9
+ meta?: ReactNode
10
+ className?: string
11
+ }
12
+
13
+ export function DomainHeader({
14
+ label,
15
+ expanded,
16
+ onToggle,
17
+ meta,
18
+ className,
19
+ }: DomainHeaderProps) {
20
+ return (
21
+ <button
22
+ type="button"
23
+ onClick={onToggle}
24
+ className={cn(
25
+ "flex h-full min-h-9 w-full items-center gap-2 px-1 py-2 text-left transition-colors hover:bg-muted/30",
26
+ className
27
+ )}
28
+ >
29
+ <CaretRightIcon
30
+ className={`size-3 shrink-0 text-muted-foreground transition-transform ${
31
+ expanded ? "rotate-90" : ""
32
+ }`}
33
+ />
34
+ <span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
35
+ {label}
36
+ </span>
37
+ {meta && (
38
+ <span className="font-mono text-xs font-normal text-muted-foreground">
39
+ {meta}
40
+ </span>
41
+ )}
42
+ </button>
43
+ )
44
+ }
@@ -0,0 +1,164 @@
1
+ import { useMemo, useState } from "react"
2
+ import {
3
+ CheckIcon,
4
+ CopyIcon,
5
+ DownloadIcon,
6
+ ExportIcon,
7
+ } from "@phosphor-icons/react"
8
+ import { Button } from "@/components/ui/button"
9
+ import { Checkbox } from "@/components/ui/checkbox"
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogTrigger,
18
+ } from "@/components/ui/dialog"
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from "@/components/ui/select"
26
+ import {
27
+ exportGraphToCypher,
28
+ subgraphFromVisible,
29
+ } from "@/domain/cypher-export"
30
+ import type { GraphIndex } from "@/domain/graph"
31
+ import type { Node } from "@/domain/node"
32
+
33
+ type Format = "cypher"
34
+
35
+ const FORMATS: { value: Format; label: string; ext: string }[] = [
36
+ { value: "cypher", label: "Cypher", ext: "cypher" },
37
+ ]
38
+
39
+ interface ExportDialogProps {
40
+ index: GraphIndex
41
+ visibleNodes: Node[]
42
+ }
43
+
44
+ export function ExportDialog({ index, visibleNodes }: ExportDialogProps) {
45
+ const [open, setOpen] = useState(false)
46
+ const [format, setFormat] = useState<Format>("cypher")
47
+ const [followFilters, setFollowFilters] = useState(true)
48
+ const [copied, setCopied] = useState(false)
49
+
50
+ const output = useMemo(() => {
51
+ if (!open) return ""
52
+ const graph = followFilters
53
+ ? subgraphFromVisible(index.graph, visibleNodes)
54
+ : index.graph
55
+ return exportGraphToCypher(graph)
56
+ }, [open, followFilters, index, visibleNodes])
57
+
58
+ const counts = useMemo(() => {
59
+ if (!open) return { nodes: 0, edges: 0 }
60
+ const graph = followFilters
61
+ ? subgraphFromVisible(index.graph, visibleNodes)
62
+ : index.graph
63
+ return { nodes: graph.nodes.length, edges: graph.edges.length }
64
+ }, [open, followFilters, index, visibleNodes])
65
+
66
+ const formatMeta = FORMATS.find((f) => f.value === format) ?? FORMATS[0]
67
+
68
+ const copy = async () => {
69
+ try {
70
+ await navigator.clipboard.writeText(output)
71
+ setCopied(true)
72
+ setTimeout(() => setCopied(false), 1500)
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
77
+
78
+ const download = () => {
79
+ const blob = new Blob([output], { type: "text/plain;charset=utf-8" })
80
+ const url = URL.createObjectURL(blob)
81
+ const a = document.createElement("a")
82
+ a.href = url
83
+ a.download = `event-graph.${formatMeta.ext}`
84
+ document.body.appendChild(a)
85
+ a.click()
86
+ document.body.removeChild(a)
87
+ URL.revokeObjectURL(url)
88
+ }
89
+
90
+ return (
91
+ <Dialog open={open} onOpenChange={setOpen}>
92
+ <DialogTrigger
93
+ render={
94
+ <Button variant="ghost" size="icon-sm" aria-label="Export graph">
95
+ <ExportIcon />
96
+ </Button>
97
+ }
98
+ />
99
+ <DialogContent className="max-w-2xl">
100
+ <DialogHeader>
101
+ <DialogTitle>Export graph</DialogTitle>
102
+ <DialogDescription>
103
+ Export the graph in a format designed for graph databases.
104
+ </DialogDescription>
105
+ </DialogHeader>
106
+
107
+ <div className="flex flex-col gap-4">
108
+ <div className="flex items-center justify-between gap-2">
109
+ <span className="text-sm">Format</span>
110
+ <Select<Format>
111
+ value={format}
112
+ onValueChange={(value) => {
113
+ if (value) setFormat(value)
114
+ }}
115
+ >
116
+ <SelectTrigger className="w-40">
117
+ <SelectValue>
118
+ {(value) =>
119
+ FORMATS.find((f) => f.value === value)?.label ?? ""
120
+ }
121
+ </SelectValue>
122
+ </SelectTrigger>
123
+ <SelectContent>
124
+ {FORMATS.map((f) => (
125
+ <SelectItem key={f.value} value={f.value}>
126
+ {f.label}
127
+ </SelectItem>
128
+ ))}
129
+ </SelectContent>
130
+ </Select>
131
+ </div>
132
+
133
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
134
+ <Checkbox
135
+ checked={followFilters}
136
+ onCheckedChange={(checked) => setFollowFilters(checked === true)}
137
+ />
138
+ <span>Follow current filters</span>
139
+ </label>
140
+
141
+ <div className="flex flex-col gap-1">
142
+ <span className="self-end font-mono text-xs text-muted-foreground">
143
+ {counts.nodes} nodes · {counts.edges} edges
144
+ </span>
145
+ <pre className="max-h-72 overflow-auto bg-muted p-3 font-mono text-xs whitespace-pre-wrap text-foreground">
146
+ {output}
147
+ </pre>
148
+ </div>
149
+ </div>
150
+
151
+ <DialogFooter>
152
+ <Button variant="outline" size="sm" onClick={copy}>
153
+ {copied ? <CheckIcon /> : <CopyIcon />}
154
+ {copied ? "Copied" : "Copy"}
155
+ </Button>
156
+ <Button variant="default" size="sm" onClick={download}>
157
+ <DownloadIcon />
158
+ Download
159
+ </Button>
160
+ </DialogFooter>
161
+ </DialogContent>
162
+ </Dialog>
163
+ )
164
+ }
@@ -0,0 +1,17 @@
1
+ import { Input } from "@/components/ui/input"
2
+ import { KindFilter } from "@/components/kind-filter"
3
+ import type { FiltersApi } from "@/application/use-filters"
4
+
5
+ export function FilterBar({ filters }: { filters: FiltersApi }) {
6
+ return (
7
+ <div className="flex items-center gap-3 border-b px-6 py-3">
8
+ <Input
9
+ placeholder="Search nodes…"
10
+ value={filters.filter.search}
11
+ onChange={(e) => filters.setSearch(e.target.value)}
12
+ className="max-w-xs"
13
+ />
14
+ <KindFilter filters={filters} />
15
+ </div>
16
+ )
17
+ }
@@ -0,0 +1,37 @@
1
+ import { ExportDialog } from "@/components/export-dialog"
2
+ import { SettingsMenu } from "@/components/settings-menu"
3
+ import type { GraphIndex } from "@/domain/graph"
4
+ import type { Node } from "@/domain/node"
5
+ import type { SettingsApi } from "@/application/use-settings"
6
+
7
+ export function Header({
8
+ index,
9
+ visibleNodes,
10
+ settings,
11
+ }: {
12
+ index: GraphIndex
13
+ visibleNodes: Node[]
14
+ settings: SettingsApi
15
+ }) {
16
+ const { graph } = index
17
+ return (
18
+ <header className="flex items-center justify-between border-b px-6 py-3">
19
+ <div className="flex items-baseline gap-3">
20
+ <span className="font-display text-base font-bold tracking-tight text-foreground">
21
+ DDD-TS
22
+ </span>
23
+ <span className="text-muted-foreground/70">·</span>
24
+ <h1 className="text-sm font-medium tracking-tight text-muted-foreground">
25
+ Event Tree Viewer
26
+ </h1>
27
+ </div>
28
+ <div className="flex items-center gap-3">
29
+ <span className="font-mono text-xs tracking-wide text-muted-foreground">
30
+ {graph.nodes.length} nodes · {graph.edges.length} edges
31
+ </span>
32
+ <ExportDialog index={index} visibleNodes={visibleNodes} />
33
+ <SettingsMenu settings={settings} />
34
+ </div>
35
+ </header>
36
+ )
37
+ }
@@ -0,0 +1,183 @@
1
+ import { useMemo } from "react"
2
+ import { ArrowSquareOutIcon } from "@phosphor-icons/react"
3
+ import { Separator } from "@/components/ui/separator"
4
+ import { ScrollArea } from "@/components/ui/scroll-area"
5
+ import { Button } from "@/components/ui/button"
6
+ import { NodeBadge } from "@/components/node-badge"
7
+ import { nodeId, type GraphIndex, type NodeId } from "@/domain/graph"
8
+ import type { Node } from "@/domain/node"
9
+ import { edgeKind, type Edge } from "@/domain/edge"
10
+ import { trpc } from "@/application/trpc-client"
11
+
12
+ async function openInEditor(source: { file: string; start: number }) {
13
+ try {
14
+ await trpc.editor.open.mutate({ file: source.file, offset: source.start })
15
+ } catch (error) {
16
+ console.warn(`[open-in-editor] ${(error as Error).message}`)
17
+ }
18
+ }
19
+
20
+ interface InspectorProps {
21
+ index: GraphIndex
22
+ selectedId: NodeId
23
+ onSelect: (id: NodeId) => void
24
+ onClose: () => void
25
+ }
26
+
27
+ export function Inspector({
28
+ index,
29
+ selectedId,
30
+ onSelect,
31
+ onClose,
32
+ }: InspectorProps) {
33
+ const node = index.nodesById.get(selectedId)
34
+ const outgoing = useMemo(
35
+ () => index.outgoing.get(selectedId) ?? [],
36
+ [index, selectedId]
37
+ )
38
+ const incoming = useMemo(
39
+ () => index.incoming.get(selectedId) ?? [],
40
+ [index, selectedId]
41
+ )
42
+
43
+ if (!node) return null
44
+
45
+ return (
46
+ <aside className="surface-elevated flex h-full w-96 shrink-0 flex-col border-l bg-card">
47
+ <header className="flex shrink-0 items-center justify-between gap-2 border-b px-5 py-3">
48
+ <div className="flex min-w-0 items-center gap-2">
49
+ <NodeBadge kind={node.type} />
50
+ <span className="truncate text-sm font-semibold">{node.name}</span>
51
+ </div>
52
+ <Button
53
+ variant="ghost"
54
+ size="sm"
55
+ onClick={onClose}
56
+ className="shrink-0"
57
+ >
58
+ Close
59
+ </Button>
60
+ </header>
61
+
62
+ <ScrollArea className="min-h-0 flex-1">
63
+ <div className="flex flex-col gap-4 px-5 py-4">
64
+ <Meta node={node} />
65
+ <Button
66
+ variant="outline"
67
+ size="sm"
68
+ onClick={() => openInEditor(node.source)}
69
+ className="w-full justify-start gap-2"
70
+ >
71
+ <ArrowSquareOutIcon />
72
+ Open in editor
73
+ </Button>
74
+ <Separator />
75
+ <Section
76
+ title={`Incoming (${incoming.length})`}
77
+ edges={incoming}
78
+ direction="in"
79
+ onSelect={onSelect}
80
+ />
81
+ <Separator />
82
+ <Section
83
+ title={`Outgoing (${outgoing.length})`}
84
+ edges={outgoing}
85
+ direction="out"
86
+ onSelect={onSelect}
87
+ />
88
+ </div>
89
+ </ScrollArea>
90
+ </aside>
91
+ )
92
+ }
93
+
94
+ function Meta({ node }: { node: Node }) {
95
+ return (
96
+ <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-sm">
97
+ <dt className="text-muted-foreground">Name</dt>
98
+ <dd className="font-mono break-all">{node.name}</dd>
99
+ {"meta" in node && "alias" in node.meta && (
100
+ <>
101
+ <dt className="text-muted-foreground">Alias</dt>
102
+ <dd className="font-mono break-all">{node.meta.alias}</dd>
103
+ </>
104
+ )}
105
+ {"meta" in node && "base" in node.meta && (
106
+ <>
107
+ <dt className="text-muted-foreground">Base</dt>
108
+ <dd className="font-mono break-all">{node.meta.base}</dd>
109
+ </>
110
+ )}
111
+ <dt className="text-muted-foreground">File</dt>
112
+ <dd className="font-mono text-xs break-all">{node.source.file}</dd>
113
+ </dl>
114
+ )
115
+ }
116
+
117
+ function Section({
118
+ title,
119
+ edges,
120
+ direction,
121
+ onSelect,
122
+ }: {
123
+ title: string
124
+ edges: Edge[]
125
+ direction: "in" | "out"
126
+ onSelect: (id: NodeId) => void
127
+ }) {
128
+ return (
129
+ <section className="flex flex-col gap-2">
130
+ <h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
131
+ {title}
132
+ </h3>
133
+ {edges.length === 0 && (
134
+ <p className="text-sm text-muted-foreground">None</p>
135
+ )}
136
+ <ul className="flex flex-col gap-1">
137
+ {edges.map((edge, idx) => (
138
+ <li key={idx}>
139
+ <EdgeRow edge={edge} direction={direction} onSelect={onSelect} />
140
+ </li>
141
+ ))}
142
+ </ul>
143
+ </section>
144
+ )
145
+ }
146
+
147
+ function EdgeRow({
148
+ edge,
149
+ direction,
150
+ onSelect,
151
+ }: {
152
+ edge: Edge
153
+ direction: "in" | "out"
154
+ onSelect: (id: NodeId) => void
155
+ }) {
156
+ const peer = direction === "in" ? edge.from : edge.to
157
+ const peerId = nodeId(peer.type, peer.name)
158
+ const dotIdx = peer.name.indexOf(".")
159
+ const head = dotIdx >= 0 ? peer.name.slice(0, dotIdx) : peer.name
160
+ const tail = dotIdx >= 0 ? peer.name.slice(dotIdx) : ""
161
+
162
+ return (
163
+ <Button
164
+ variant="ghost"
165
+ size="sm"
166
+ onClick={() => onSelect(peerId)}
167
+ className="h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-sm font-normal"
168
+ >
169
+ <span className="shrink-0 font-mono text-xs text-muted-foreground">
170
+ {edgeKind(edge)}
171
+ </span>
172
+ <NodeBadge kind={peer.type} />
173
+ <span className="min-w-0 truncate">
174
+ <span className="font-medium">{head}</span>
175
+ {tail && (
176
+ <span className="font-mono text-xs text-muted-foreground">
177
+ {tail}
178
+ </span>
179
+ )}
180
+ </span>
181
+ </Button>
182
+ )
183
+ }
@@ -0,0 +1,70 @@
1
+ import { CaretDownIcon } from "@phosphor-icons/react"
2
+ import { Button } from "@/components/ui/button"
3
+ import { Checkbox } from "@/components/ui/checkbox"
4
+ import {
5
+ Popover,
6
+ PopoverContent,
7
+ PopoverTrigger,
8
+ } from "@/components/ui/popover"
9
+ import { NodeBadge } from "@/components/node-badge"
10
+ import { NODE_KINDS, type NodeKind } from "@/domain/node"
11
+ import type { FiltersApi } from "@/application/use-filters"
12
+
13
+ export function KindFilter({ filters }: { filters: FiltersApi }) {
14
+ const selected = filters.filter.kinds
15
+ const label = buildLabel(selected)
16
+
17
+ return (
18
+ <Popover>
19
+ <PopoverTrigger
20
+ render={
21
+ <Button variant="outline" size="sm">
22
+ <span>{label}</span>
23
+ <CaretDownIcon data-icon="inline-end" />
24
+ </Button>
25
+ }
26
+ />
27
+ <PopoverContent align="start" className="w-56 p-1">
28
+ <ul className="flex flex-col">
29
+ {NODE_KINDS.map((kind) => (
30
+ <li key={kind}>
31
+ <KindOption
32
+ kind={kind}
33
+ checked={selected.has(kind)}
34
+ onToggle={() => filters.toggleKind(kind)}
35
+ />
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ </PopoverContent>
40
+ </Popover>
41
+ )
42
+ }
43
+
44
+ function KindOption({
45
+ kind,
46
+ checked,
47
+ onToggle,
48
+ }: {
49
+ kind: NodeKind
50
+ checked: boolean
51
+ onToggle: () => void
52
+ }) {
53
+ return (
54
+ <label className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm transition-colors hover:bg-muted hover:text-foreground">
55
+ <Checkbox checked={checked} onCheckedChange={onToggle} />
56
+ <NodeBadge kind={kind} />
57
+ <span className="capitalize">{kind}</span>
58
+ </label>
59
+ )
60
+ }
61
+
62
+ function buildLabel(selected: ReadonlySet<NodeKind>): string {
63
+ if (selected.size === NODE_KINDS.length) return "All kinds"
64
+ if (selected.size === 0) return "No kinds"
65
+ if (selected.size === 1) {
66
+ const only = [...selected][0]
67
+ return only[0].toUpperCase() + only.slice(1)
68
+ }
69
+ return `${selected.size} kinds`
70
+ }
@@ -0,0 +1,19 @@
1
+ import { Badge } from "@/components/ui/badge"
2
+ import type { NodeKind } from "@/domain/node"
3
+
4
+ const STYLES: Record<NodeKind, string> = {
5
+ event: "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200",
6
+ command: "bg-sky-100 text-sky-900 dark:bg-sky-950 dark:text-sky-200",
7
+ saga: "bg-violet-100 text-violet-900 dark:bg-violet-950 dark:text-violet-200",
8
+ aggregate:
9
+ "bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
10
+ projection: "bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200",
11
+ }
12
+
13
+ export function NodeBadge({ kind }: { kind: NodeKind }) {
14
+ return (
15
+ <Badge variant="secondary" className={STYLES[kind]}>
16
+ {kind}
17
+ </Badge>
18
+ )
19
+ }
@@ -0,0 +1,66 @@
1
+ import { DotsThreeIcon } from "@phosphor-icons/react"
2
+ import { isJustKind, stripDomainAffix } from "@/domain/domain-grouping"
3
+
4
+ interface NodeNameProps {
5
+ name: string
6
+ kind: string
7
+ domainPrefix: string
8
+ hide: boolean
9
+ allowEmpty?: boolean
10
+ metaClass?: string
11
+ }
12
+
13
+ export function NodeName({
14
+ name,
15
+ kind,
16
+ domainPrefix,
17
+ hide,
18
+ allowEmpty = false,
19
+ metaClass = "text-xs",
20
+ }: NodeNameProps) {
21
+ const dotIdx = name.indexOf(".")
22
+ const head = dotIdx >= 0 ? name.slice(0, dotIdx) : name
23
+ const tail = dotIdx >= 0 ? name.slice(dotIdx) : ""
24
+ const methodTag = tail ? (
25
+ <span className={`font-mono ${metaClass} text-muted-foreground`}>
26
+ {tail}
27
+ </span>
28
+ ) : null
29
+
30
+ if (!hide) {
31
+ return (
32
+ <span className="font-medium">
33
+ {head}
34
+ {methodTag}
35
+ </span>
36
+ )
37
+ }
38
+ const { stripped, position } = stripDomainAffix(
39
+ head,
40
+ domainPrefix,
41
+ allowEmpty || tail !== ""
42
+ )
43
+ if (position === null) {
44
+ return (
45
+ <span className="font-medium">
46
+ {head}
47
+ {methodTag}
48
+ </span>
49
+ )
50
+ }
51
+ const visible = isJustKind(stripped, kind) ? "" : stripped
52
+ const ellipsis = (
53
+ <DotsThreeIcon
54
+ className="inline size-3 align-middle text-muted-foreground"
55
+ aria-label={name}
56
+ />
57
+ )
58
+ return (
59
+ <span className="font-medium" title={name}>
60
+ {position === "prefix" && ellipsis}
61
+ {visible}
62
+ {position === "suffix" && ellipsis}
63
+ {methodTag}
64
+ </span>
65
+ )
66
+ }