@antelopejs/dms-database 0.0.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.
Files changed (164) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +53 -0
  3. package/dist/automation/index.d.ts +2 -0
  4. package/dist/automation/index.js +13 -0
  5. package/dist/automation/index.js.map +1 -0
  6. package/dist/automation/tableWatch.d.ts +17 -0
  7. package/dist/automation/tableWatch.js +189 -0
  8. package/dist/automation/tableWatch.js.map +1 -0
  9. package/dist/config.d.ts +7 -0
  10. package/dist/config.js +12 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/db/index.d.ts +2 -0
  13. package/dist/db/index.js +19 -0
  14. package/dist/db/index.js.map +1 -0
  15. package/dist/db/models/diagram_layouts.model.d.ts +25 -0
  16. package/dist/db/models/diagram_layouts.model.js +29 -0
  17. package/dist/db/models/diagram_layouts.model.js.map +1 -0
  18. package/dist/db/models/diagram_notes.model.d.ts +23 -0
  19. package/dist/db/models/diagram_notes.model.js +15 -0
  20. package/dist/db/models/diagram_notes.model.js.map +1 -0
  21. package/dist/db/models/index.d.ts +4 -0
  22. package/dist/db/models/index.js +21 -0
  23. package/dist/db/models/index.js.map +1 -0
  24. package/dist/db/models/query_history.model.d.ts +25 -0
  25. package/dist/db/models/query_history.model.js +33 -0
  26. package/dist/db/models/query_history.model.js.map +1 -0
  27. package/dist/db/models/saved_queries.model.d.ts +24 -0
  28. package/dist/db/models/saved_queries.model.js +21 -0
  29. package/dist/db/models/saved_queries.model.js.map +1 -0
  30. package/dist/db/tables/diagram_layouts.table.d.ts +7 -0
  31. package/dist/db/tables/diagram_layouts.table.js +35 -0
  32. package/dist/db/tables/diagram_layouts.table.js.map +1 -0
  33. package/dist/db/tables/diagram_notes.table.d.ts +13 -0
  34. package/dist/db/tables/diagram_notes.table.js +59 -0
  35. package/dist/db/tables/diagram_notes.table.js.map +1 -0
  36. package/dist/db/tables/index.d.ts +4 -0
  37. package/dist/db/tables/index.js +21 -0
  38. package/dist/db/tables/index.js.map +1 -0
  39. package/dist/db/tables/query_history.table.d.ts +11 -0
  40. package/dist/db/tables/query_history.table.js +54 -0
  41. package/dist/db/tables/query_history.table.js.map +1 -0
  42. package/dist/db/tables/saved_queries.table.d.ts +12 -0
  43. package/dist/db/tables/saved_queries.table.js +58 -0
  44. package/dist/db/tables/saved_queries.table.js.map +1 -0
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.js +56 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/pages/data.d.ts +9 -0
  49. package/dist/pages/data.js +29 -0
  50. package/dist/pages/data.js.map +1 -0
  51. package/dist/pages/index.d.ts +4 -0
  52. package/dist/pages/index.js +21 -0
  53. package/dist/pages/index.js.map +1 -0
  54. package/dist/pages/overview.d.ts +9 -0
  55. package/dist/pages/overview.js +29 -0
  56. package/dist/pages/overview.js.map +1 -0
  57. package/dist/pages/query.d.ts +9 -0
  58. package/dist/pages/query.js +29 -0
  59. package/dist/pages/query.js.map +1 -0
  60. package/dist/pages/schemas.d.ts +9 -0
  61. package/dist/pages/schemas.js +29 -0
  62. package/dist/pages/schemas.js.map +1 -0
  63. package/dist/routes/data.d.ts +25 -0
  64. package/dist/routes/data.js +324 -0
  65. package/dist/routes/data.js.map +1 -0
  66. package/dist/routes/diagram.d.ts +47 -0
  67. package/dist/routes/diagram.js +200 -0
  68. package/dist/routes/diagram.js.map +1 -0
  69. package/dist/routes/health.d.ts +6 -0
  70. package/dist/routes/health.js +35 -0
  71. package/dist/routes/health.js.map +1 -0
  72. package/dist/routes/index.d.ts +6 -0
  73. package/dist/routes/index.js +23 -0
  74. package/dist/routes/index.js.map +1 -0
  75. package/dist/routes/query.d.ts +26 -0
  76. package/dist/routes/query.js +245 -0
  77. package/dist/routes/query.js.map +1 -0
  78. package/dist/routes/schemas.d.ts +8 -0
  79. package/dist/routes/schemas.js +35 -0
  80. package/dist/routes/schemas.js.map +1 -0
  81. package/dist/routes/tables.d.ts +23 -0
  82. package/dist/routes/tables.js +94 -0
  83. package/dist/routes/tables.js.map +1 -0
  84. package/dist/service/fieldDescriptor.d.ts +66 -0
  85. package/dist/service/fieldDescriptor.js +156 -0
  86. package/dist/service/fieldDescriptor.js.map +1 -0
  87. package/dist/service/health.d.ts +20 -0
  88. package/dist/service/health.js +58 -0
  89. package/dist/service/health.js.map +1 -0
  90. package/dist/service/introspect.d.ts +6 -0
  91. package/dist/service/introspect.js +312 -0
  92. package/dist/service/introspect.js.map +1 -0
  93. package/dist/service/schemaLabels.d.ts +7 -0
  94. package/dist/service/schemaLabels.js +39 -0
  95. package/dist/service/schemaLabels.js.map +1 -0
  96. package/dist/service/schemaRegistry.d.ts +2 -0
  97. package/dist/service/schemaRegistry.js +8 -0
  98. package/dist/service/schemaRegistry.js.map +1 -0
  99. package/dist/service/stagedSerialization.d.ts +6 -0
  100. package/dist/service/stagedSerialization.js +54 -0
  101. package/dist/service/stagedSerialization.js.map +1 -0
  102. package/dist/service/types.d.ts +67 -0
  103. package/dist/service/types.js +3 -0
  104. package/dist/service/types.js.map +1 -0
  105. package/dist/types/constants.d.ts +3 -0
  106. package/dist/types/constants.js +7 -0
  107. package/dist/types/constants.js.map +1 -0
  108. package/dist/types/responses.d.ts +14 -0
  109. package/dist/types/responses.js +3 -0
  110. package/dist/types/responses.js.map +1 -0
  111. package/dist/utils/query.d.ts +17 -0
  112. package/dist/utils/query.js +116 -0
  113. package/dist/utils/query.js.map +1 -0
  114. package/dist/utils/requestValidation.d.ts +2 -0
  115. package/dist/utils/requestValidation.js +14 -0
  116. package/dist/utils/requestValidation.js.map +1 -0
  117. package/frontend-vue/.editorconfig +12 -0
  118. package/frontend-vue/.prettierrc +11 -0
  119. package/frontend-vue/app/components/DataBrowserSidebar.vue +254 -0
  120. package/frontend-vue/app/components/DataBrowserTabBar.vue +166 -0
  121. package/frontend-vue/app/components/DataGrid.vue +735 -0
  122. package/frontend-vue/app/components/DataGridCell.vue +147 -0
  123. package/frontend-vue/app/components/DataGridHeaderCell.vue +175 -0
  124. package/frontend-vue/app/components/DiagramNoteNode.vue +100 -0
  125. package/frontend-vue/app/components/DiagramRelationEdge.vue +62 -0
  126. package/frontend-vue/app/components/DiagramStubNode.vue +37 -0
  127. package/frontend-vue/app/components/DiagramTableNode.vue +94 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +93 -0
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +501 -0
  130. package/frontend-vue/app/components/QueryFavoritesPanel.vue +127 -0
  131. package/frontend-vue/app/components/QueryHistoryPanel.vue +134 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +204 -0
  133. package/frontend-vue/app/components/SchemaDiagram.vue +1150 -0
  134. package/frontend-vue/app/components/SchemaTableInspector.vue +278 -0
  135. package/frontend-vue/app/composables/useDataBrowserGrid.ts +162 -0
  136. package/frontend-vue/app/composables/useDataBrowserTabs.ts +255 -0
  137. package/frontend-vue/app/composables/useDatabaseHealth.ts +50 -0
  138. package/frontend-vue/app/composables/useDatabaseSchemas.ts +92 -0
  139. package/frontend-vue/app/composables/useDiagramPersistence.ts +99 -0
  140. package/frontend-vue/app/composables/useQueryRuntime.ts +118 -0
  141. package/frontend-vue/app/composables/useQueryStore.ts +117 -0
  142. package/frontend-vue/app/composables/useTableViewRowClick.ts +22 -0
  143. package/frontend-vue/app/custom-pages/database/data.vue +42 -0
  144. package/frontend-vue/app/custom-pages/database/overview.vue +367 -0
  145. package/frontend-vue/app/custom-pages/database/query.vue +432 -0
  146. package/frontend-vue/app/custom-pages/database/schemas.vue +216 -0
  147. package/frontend-vue/app/utils/databaseFieldModifiers.ts +33 -0
  148. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +224 -0
  149. package/frontend-vue/app/utils/dmsTableView.ts +83 -0
  150. package/frontend-vue/app/utils/relativeTime.ts +15 -0
  151. package/frontend-vue/app/utils/stagedSerialization.ts +91 -0
  152. package/frontend-vue/dms.frontend.ts +38 -0
  153. package/frontend-vue/eslint.config.mjs +16 -0
  154. package/frontend-vue/i18n/locales/database-en-GB.json +239 -0
  155. package/frontend-vue/i18n/locales/database-fr-FR.json +239 -0
  156. package/frontend-vue/package.json +42 -0
  157. package/frontend-vue/pnpm-lock.yaml +5819 -0
  158. package/frontend-vue/pnpm-workspace.yaml +2 -0
  159. package/frontend-vue/tests/persistence.test.ts +70 -0
  160. package/frontend-vue/tests/queryRuntime.test.ts +53 -0
  161. package/frontend-vue/tests/registration.test.ts +33 -0
  162. package/frontend-vue/tsconfig.json +13 -0
  163. package/frontend-vue/vitest.config.ts +5 -0
  164. package/package.json +78 -0
@@ -0,0 +1,147 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ value: unknown;
4
+ editable: boolean;
5
+ editing: boolean;
6
+ pending: boolean;
7
+ invalid: boolean;
8
+ focused: boolean;
9
+ }>();
10
+
11
+ const emit = defineEmits<{
12
+ select: [];
13
+ "start-edit": [];
14
+ commit: [draft: string | boolean, source: "enter" | "blur"];
15
+ cancel: [];
16
+ "open-json": [];
17
+ }>();
18
+
19
+ const isJson = computed(
20
+ () => props.value !== null && props.value !== undefined && typeof props.value === "object",
21
+ );
22
+ const isNull = computed(() => props.value === null || props.value === undefined);
23
+
24
+ const display = computed(() => {
25
+ if (isNull.value) return "";
26
+ if (isJson.value) {
27
+ try {
28
+ return JSON.stringify(props.value);
29
+ } catch {
30
+ return String(props.value);
31
+ }
32
+ }
33
+ return String(props.value);
34
+ });
35
+
36
+ // --- inline editing ---
37
+ const draft = ref("");
38
+ const boolDraft = ref(false);
39
+ const cellInput = useTemplateRef<HTMLInputElement | HTMLSelectElement>("cellInput");
40
+ // Set before blurring on Escape so the blur-commit does not fire after a cancel.
41
+ const cancelling = ref(false);
42
+
43
+ watch(
44
+ () => props.editing,
45
+ (editing) => {
46
+ if (!editing) return;
47
+ cancelling.value = false;
48
+ if (typeof props.value === "boolean") {
49
+ boolDraft.value = props.value;
50
+ } else {
51
+ draft.value = isNull.value ? "" : String(props.value);
52
+ }
53
+ nextTick(() => {
54
+ cellInput.value?.focus();
55
+ if (cellInput.value instanceof HTMLInputElement) cellInput.value.select();
56
+ });
57
+ },
58
+ );
59
+
60
+ // The parent needs the trigger: Enter keeps an invalid draft open for fixing,
61
+ // while a blur (click-away) discards it so the grid never holds an unfocused
62
+ // editor hostage.
63
+ function commit(source: "enter" | "blur") {
64
+ if (cancelling.value) return;
65
+ emit(
66
+ "commit",
67
+ typeof props.value === "boolean" ? boolDraft.value : draft.value,
68
+ source,
69
+ );
70
+ }
71
+
72
+ function cancel() {
73
+ cancelling.value = true;
74
+ emit("cancel");
75
+ }
76
+
77
+ function onDoubleClick() {
78
+ if (isJson.value) emit("open-json");
79
+ else if (props.editable) emit("start-edit");
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <td
85
+ class="relative cursor-default border-r border-b border-default p-0 align-middle"
86
+ :class="[
87
+ invalid
88
+ ? 'ring-2 ring-error ring-inset'
89
+ : focused
90
+ ? 'ring-2 ring-primary ring-inset'
91
+ : '',
92
+ editing ? 'bg-primary/5' : '',
93
+ ]"
94
+ @click="emit('select')"
95
+ @dblclick="onDoubleClick"
96
+ >
97
+ <template v-if="editing">
98
+ <select
99
+ v-if="typeof value === 'boolean'"
100
+ ref="cellInput"
101
+ v-model="boolDraft"
102
+ class="w-full bg-default px-2 py-1 font-mono text-[12.5px] outline-none"
103
+ @change="commit('enter')"
104
+ @keydown.esc.stop="cancel"
105
+ @keydown.stop
106
+ @blur="commit('blur')"
107
+ >
108
+ <option :value="true">true</option>
109
+ <option :value="false">false</option>
110
+ </select>
111
+ <input
112
+ v-else
113
+ ref="cellInput"
114
+ v-model="draft"
115
+ type="text"
116
+ class="w-full bg-default px-2 py-1 font-mono text-[12.5px] outline-none"
117
+ @keydown.enter.prevent="commit('enter')"
118
+ @keydown.esc.stop="cancel"
119
+ @keydown.stop
120
+ @blur="commit('blur')"
121
+ >
122
+ </template>
123
+ <div
124
+ v-else
125
+ class="flex items-center gap-1 overflow-hidden px-2.5 py-1 whitespace-nowrap"
126
+ >
127
+ <span
128
+ v-if="isNull"
129
+ class="rounded bg-elevated px-1 font-mono text-[10px] tracking-wide text-dimmed uppercase"
130
+ >
131
+ null
132
+ </span>
133
+ <span
134
+ v-else
135
+ class="overflow-hidden font-mono text-[12.5px] text-ellipsis"
136
+ :class="isJson ? 'cursor-pointer text-info' : 'text-toned'"
137
+ >
138
+ {{ display }}
139
+ </span>
140
+ <UIcon
141
+ v-if="pending"
142
+ name="i-ph-circle-notch"
143
+ class="size-3 shrink-0 animate-spin text-primary"
144
+ />
145
+ </div>
146
+ </td>
147
+ </template>
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ import type { ColumnFilterState, GridColumn } from "../composables/useDataBrowserGrid";
3
+
4
+ const props = defineProps<{
5
+ column: GridColumn;
6
+ sortKey: string | null;
7
+ sortDirection: "asc" | "desc";
8
+ filter: ColumnFilterState | null;
9
+ width: number;
10
+ }>();
11
+
12
+ const emit = defineEmits<{
13
+ sort: [];
14
+ filter: [value: ColumnFilterState | null];
15
+ resize: [width: number];
16
+ }>();
17
+
18
+ const { t } = useI18n();
19
+
20
+ // Shared icon set (utils/databaseFieldTypeIcons) so runtime-sampled columns
21
+ // carry the same glyph, color and named-field overrides as the inspector.
22
+ const typeIcon = computed(() =>
23
+ runtimeTypeIcon(props.column.type, props.column.name),
24
+ );
25
+
26
+ const sortIcon = computed(() => {
27
+ if (props.sortKey !== props.column.name) return null;
28
+ return props.sortDirection === "asc" ? "i-ph-caret-up" : "i-ph-caret-down";
29
+ });
30
+
31
+ // --- filter popover ---
32
+ const filterOpen = ref(false);
33
+ const draftMode = ref<"is" | "contains">("contains");
34
+ const draftValue = ref("");
35
+
36
+ watch(filterOpen, (open) => {
37
+ if (!open) return;
38
+ draftMode.value = props.filter?.mode ?? "contains";
39
+ draftValue.value = props.filter?.value ?? "";
40
+ });
41
+
42
+ const modeOptions = computed(() => [
43
+ { label: t("dms_database.data.filterModeContains"), value: "contains" },
44
+ { label: t("dms_database.data.filterModeIs"), value: "is" },
45
+ ]);
46
+
47
+ function applyFilter() {
48
+ const value = draftValue.value.trim();
49
+ emit("filter", value ? { mode: draftMode.value, value } : null);
50
+ filterOpen.value = false;
51
+ }
52
+
53
+ function clearFilter() {
54
+ emit("filter", null);
55
+ filterOpen.value = false;
56
+ }
57
+
58
+ // --- column resize (drag the right edge) ---
59
+ const MIN_WIDTH = 80;
60
+ const MAX_WIDTH = 640;
61
+
62
+ // Emits are rAF-throttled (one width update per frame, not per mousemove) and
63
+ // the window listeners are detached if the header unmounts mid-drag.
64
+ let resizeFrame = 0;
65
+ let detachResize: (() => void) | null = null;
66
+
67
+ function startResize(event: MouseEvent) {
68
+ event.preventDefault();
69
+ const startX = event.clientX;
70
+ const startWidth = props.width;
71
+ function onMove(move: MouseEvent) {
72
+ cancelAnimationFrame(resizeFrame);
73
+ resizeFrame = requestAnimationFrame(() => {
74
+ const next = Math.min(
75
+ MAX_WIDTH,
76
+ Math.max(MIN_WIDTH, startWidth + (move.clientX - startX)),
77
+ );
78
+ emit("resize", next);
79
+ });
80
+ }
81
+ function onUp() {
82
+ detachResize?.();
83
+ }
84
+ detachResize = () => {
85
+ cancelAnimationFrame(resizeFrame);
86
+ window.removeEventListener("mousemove", onMove);
87
+ window.removeEventListener("mouseup", onUp);
88
+ detachResize = null;
89
+ };
90
+ window.addEventListener("mousemove", onMove);
91
+ window.addEventListener("mouseup", onUp);
92
+ }
93
+
94
+ onBeforeUnmount(() => detachResize?.());
95
+ </script>
96
+
97
+ <template>
98
+ <th
99
+ class="group relative select-none border-r border-b border-default bg-elevated p-0 text-left font-medium"
100
+ scope="col"
101
+ >
102
+ <div class="flex items-center gap-1.5 px-2.5 py-1.5">
103
+ <UIcon
104
+ v-if="column.isPrimaryKey"
105
+ name="i-ph-key"
106
+ class="size-3 shrink-0 text-amber-500"
107
+ />
108
+ <UIcon
109
+ v-else
110
+ :name="typeIcon.icon"
111
+ class="size-3 shrink-0"
112
+ :class="typeIcon.color"
113
+ :title="typeIcon.label"
114
+ />
115
+ <button
116
+ type="button"
117
+ class="min-w-0 flex-1 truncate text-left font-mono text-xs text-toned hover:text-highlighted"
118
+ @click="emit('sort')"
119
+ >
120
+ {{ column.name }}
121
+ </button>
122
+ <UIcon v-if="sortIcon" :name="sortIcon" class="size-3 shrink-0 text-primary" />
123
+ <!--
124
+ Data filters on columns named after the wire protocol's selection
125
+ params (schema / table / instance) are silently dropped server-side,
126
+ so don't offer the filter button on those columns at all.
127
+ -->
128
+ <UPopover v-if="!isReservedFilterField(column.name)" v-model:open="filterOpen">
129
+ <button
130
+ type="button"
131
+ class="rounded p-0.5 transition-opacity"
132
+ :class="
133
+ filter
134
+ ? 'text-primary opacity-100'
135
+ : 'text-dimmed opacity-0 group-hover:opacity-100'
136
+ "
137
+ :aria-label="t('dms_database.data.filterTitle', { column: column.name })"
138
+ >
139
+ <UIcon name="i-ph-funnel" class="size-3" />
140
+ </button>
141
+ <template #content>
142
+ <div class="w-56 space-y-2 p-3">
143
+ <p class="font-mono text-xs text-muted">{{ column.name }}</p>
144
+ <USelect v-model="draftMode" :items="modeOptions" size="xs" class="w-full" />
145
+ <UInput
146
+ v-model="draftValue"
147
+ size="xs"
148
+ class="w-full"
149
+ autofocus
150
+ @keydown.enter="applyFilter"
151
+ />
152
+ <div class="flex justify-end gap-1.5">
153
+ <UButton
154
+ size="xs"
155
+ variant="ghost"
156
+ color="neutral"
157
+ :label="t('dms_database.data.filterClear')"
158
+ @click="clearFilter"
159
+ />
160
+ <UButton
161
+ size="xs"
162
+ :label="t('dms_database.data.filterApply')"
163
+ @click="applyFilter"
164
+ />
165
+ </div>
166
+ </div>
167
+ </template>
168
+ </UPopover>
169
+ </div>
170
+ <span
171
+ class="absolute top-0 right-0 z-10 h-full w-1 cursor-col-resize hover:bg-primary/40"
172
+ @mousedown="startResize"
173
+ />
174
+ </th>
175
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ interface NoteNodeData {
3
+ noteId: string;
4
+ text: string;
5
+ color: string | null;
6
+ width: number;
7
+ height: number;
8
+ onTextChange: (noteId: string, text: string) => void;
9
+ onDelete: (noteId: string) => void;
10
+ }
11
+
12
+ const props = defineProps<{ data: NoteNodeData; selected: boolean }>();
13
+
14
+ // Sticky-note palette (amber). Centralised so the note frame, header and ink
15
+ // stay in sync instead of being repeated as literals across the template.
16
+ const NOTE_DEFAULT_BG = "#fef9c3";
17
+ const NOTE_BORDER = "rgba(202, 138, 4, 0.45)";
18
+ const NOTE_HEADER_BG = "rgba(250, 204, 21, 0.5)";
19
+ const NOTE_HEADER_BORDER = "rgba(202, 138, 4, 0.35)";
20
+ const NOTE_ACCENT = "#92400e";
21
+ const NOTE_INK = "#422006";
22
+ const NOTE_PLACEHOLDER_INK = "rgba(66, 32, 6, 0.45)";
23
+
24
+ const editing = ref(false);
25
+ const textBuffer = ref(props.data.text);
26
+
27
+ watch(
28
+ () => props.data.text,
29
+ (v) => {
30
+ if (!editing.value) textBuffer.value = v;
31
+ },
32
+ );
33
+
34
+ function commitText() {
35
+ editing.value = false;
36
+ const next = textBuffer.value;
37
+ if (next !== props.data.text) {
38
+ props.data.onTextChange(props.data.noteId, next);
39
+ }
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <div
45
+ class="rounded-lg shadow-md border relative overflow-hidden flex flex-col"
46
+ :class="{ 'ring-2 ring-primary': selected }"
47
+ :style="{
48
+ background: data.color ?? NOTE_DEFAULT_BG,
49
+ borderColor: NOTE_BORDER,
50
+ width: `${data.width}px`,
51
+ height: `${data.height}px`,
52
+ }"
53
+ @dblclick="editing = true"
54
+ >
55
+ <!-- Header strip: gives the note a recognisable sticky-note frame and
56
+ hosts the delete affordance without overlapping the text. -->
57
+ <div
58
+ class="flex items-center justify-between h-6 px-2 shrink-0 cursor-grab"
59
+ :style="{
60
+ background: NOTE_HEADER_BG,
61
+ borderBottom: `1px solid ${NOTE_HEADER_BORDER}`,
62
+ }"
63
+ >
64
+ <UIcon
65
+ name="i-ph-note-pencil"
66
+ class="size-3.5"
67
+ :style="{ color: NOTE_ACCENT }"
68
+ />
69
+ <button
70
+ v-if="selected"
71
+ type="button"
72
+ class="p-0.5 rounded hover:bg-black/10"
73
+ :style="{ color: NOTE_ACCENT }"
74
+ :aria-label="$t('dms_database.diagram.notes.delete')"
75
+ @click.stop="data.onDelete(data.noteId)"
76
+ >
77
+ <UIcon name="i-ph-x" class="size-3.5" />
78
+ </button>
79
+ </div>
80
+ <!-- Body. Fixed dark ink so the text stays readable on the light note in
81
+ any app theme (the previous theme-aware text colour washed out). -->
82
+ <textarea
83
+ v-if="editing"
84
+ v-model="textBuffer"
85
+ class="flex-1 w-full bg-transparent text-sm p-3 resize-none outline-none"
86
+ :style="{ color: NOTE_INK }"
87
+ :placeholder="$t('dms_database.diagram.notes.placeholder')"
88
+ autofocus
89
+ @blur="commitText"
90
+ @keydown.escape.prevent="commitText"
91
+ />
92
+ <div
93
+ v-else
94
+ class="flex-1 w-full p-3 text-sm whitespace-pre-wrap break-words cursor-text overflow-auto"
95
+ :style="{ color: data.text ? NOTE_INK : NOTE_PLACEHOLDER_INK }"
96
+ >
97
+ {{ data.text || $t("dms_database.diagram.notes.placeholder") }}
98
+ </div>
99
+ </div>
100
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { BaseEdge, type EdgeProps, Position, getBezierPath } from "@vue-flow/core";
3
+
4
+ const props = defineProps<EdgeProps>();
5
+
6
+ const ARC_MIN_OFFSET = 60;
7
+ const ARC_DY_FACTOR = 0.35;
8
+
9
+ function sideSign(position: Position): 1 | -1 | 0 {
10
+ if (position === Position.Right) return 1;
11
+ if (position === Position.Left) return -1;
12
+ return 0;
13
+ }
14
+
15
+ const computedPath = computed<[string, number, number]>(() => {
16
+ const sourceSign = sideSign(props.sourcePosition);
17
+ const targetSign = sideSign(props.targetPosition);
18
+ if (sourceSign !== 0 && targetSign !== 0) {
19
+ const dy = Math.abs(props.targetY - props.sourceY);
20
+ const offset = Math.max(ARC_MIN_OFFSET, dy * ARC_DY_FACTOR);
21
+ const cx1 = props.sourceX + sourceSign * offset;
22
+ const cy1 = props.sourceY;
23
+ const cx2 = props.targetX + targetSign * offset;
24
+ const cy2 = props.targetY;
25
+ const d = `M${props.sourceX},${props.sourceY} C${cx1},${cy1} ${cx2},${cy2} ${props.targetX},${props.targetY}`;
26
+ // Label sits at the bezier midpoint (t = 0.5).
27
+ const labelX =
28
+ (props.sourceX + 3 * cx1 + 3 * cx2 + props.targetX) / 8;
29
+ const labelY =
30
+ (props.sourceY + 3 * cy1 + 3 * cy2 + props.targetY) / 8;
31
+ return [d, labelX, labelY];
32
+ }
33
+ const [d, lx, ly] = getBezierPath({
34
+ sourceX: props.sourceX,
35
+ sourceY: props.sourceY,
36
+ sourcePosition: props.sourcePosition,
37
+ targetX: props.targetX,
38
+ targetY: props.targetY,
39
+ targetPosition: props.targetPosition,
40
+ });
41
+ return [d, lx, ly];
42
+ });
43
+ </script>
44
+
45
+ <template>
46
+ <BaseEdge
47
+ :id="id"
48
+ :path="computedPath[0]"
49
+ :marker-end="markerEnd"
50
+ :marker-start="markerStart"
51
+ :style="style"
52
+ :label="label"
53
+ :label-x="computedPath[1]"
54
+ :label-y="computedPath[2]"
55
+ :label-style="labelStyle"
56
+ :label-show-bg="labelShowBg"
57
+ :label-bg-style="labelBgStyle"
58
+ :label-bg-padding="labelBgPadding"
59
+ :label-bg-border-radius="labelBgBorderRadius"
60
+ :interaction-width="interactionWidth"
61
+ />
62
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { Handle, Position } from "@vue-flow/core";
3
+
4
+ interface StubNodeData {
5
+ targetSchema: string;
6
+ targetTable: string;
7
+ }
8
+
9
+ defineProps<{ data: StubNodeData }>();
10
+
11
+ const route = useDmsRoute();
12
+ const router = useDmsRouter();
13
+
14
+ function jumpToSchema(schemaId: string) {
15
+ router.replace({ query: { ...route.query, schema: schemaId } });
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <button
21
+ type="button"
22
+ class="diagram-stub-node flex items-center gap-1 px-2 py-1 rounded border border-dashed border-default bg-elevated text-xs text-muted hover:text-highlighted hover:border-primary transition-colors opacity-70 relative"
23
+ @click="jumpToSchema(data.targetSchema)"
24
+ >
25
+ <Handle id="stub::left::target" type="target" :position="Position.Left" />
26
+ <Handle id="stub::right::target" type="target" :position="Position.Right" />
27
+ <UIcon name="i-ph-arrow-right" />
28
+ <span class="font-mono">{{ data.targetSchema }}.{{ data.targetTable }}</span>
29
+ </button>
30
+ </template>
31
+
32
+ <style scoped>
33
+ .diagram-stub-node :deep(.vue-flow__handle) {
34
+ opacity: 0;
35
+ pointer-events: none;
36
+ }
37
+ </style>
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ import { Handle, Position } from "@vue-flow/core";
3
+
4
+ interface NodeData {
5
+ tableName: string;
6
+ fields: Record<string, unknown>;
7
+ indexes: Record<string, { fields?: string[]; multi?: boolean }>;
8
+ modifiers: Record<string, string[]>;
9
+ }
10
+
11
+ defineProps<{ data: NodeData }>();
12
+ </script>
13
+
14
+ <template>
15
+ <div class="diagram-table-node bg-elevated border border-default rounded-lg w-60 shadow-md">
16
+ <!-- Fallback handles on the node body for edges that reference a non-existent field -->
17
+ <Handle id="__node__::left::source" type="source" :position="Position.Left" />
18
+ <Handle id="__node__::left::target" type="target" :position="Position.Left" />
19
+ <Handle id="__node__::right::source" type="source" :position="Position.Right" />
20
+ <Handle id="__node__::right::target" type="target" :position="Position.Right" />
21
+
22
+ <div class="px-3 py-2 border-b border-default flex items-center gap-2">
23
+ <UIcon name="i-ph-table" />
24
+ <span class="font-mono text-sm">{{ data.tableName }}</span>
25
+ </div>
26
+
27
+ <ul class="py-1">
28
+ <li
29
+ v-for="(fieldType, fieldName) in data.fields"
30
+ :key="fieldName"
31
+ class="relative flex items-center justify-between px-3 py-1 text-sm"
32
+ >
33
+ <Handle
34
+ :id="`${fieldName}::left::source`"
35
+ type="source"
36
+ :position="Position.Left"
37
+ />
38
+ <Handle
39
+ :id="`${fieldName}::left::target`"
40
+ type="target"
41
+ :position="Position.Left"
42
+ />
43
+ <Handle
44
+ :id="`${fieldName}::right::source`"
45
+ type="source"
46
+ :position="Position.Right"
47
+ />
48
+ <Handle
49
+ :id="`${fieldName}::right::target`"
50
+ type="target"
51
+ :position="Position.Right"
52
+ />
53
+
54
+ <span class="flex items-center gap-0.5 font-mono min-w-0">
55
+ <UTooltip
56
+ v-if="isPrimaryKey(String(fieldName), data.indexes)"
57
+ text="Primary key"
58
+ >
59
+ <UIcon name="i-ph-key" class="text-amber-500 size-4 shrink-0" />
60
+ </UTooltip>
61
+ <template
62
+ v-for="modifierId in data.modifiers?.[String(fieldName)] ?? []"
63
+ :key="modifierId"
64
+ >
65
+ <UTooltip :text="resolveModifierIcon(modifierId).label">
66
+ <UIcon
67
+ :name="resolveModifierIcon(modifierId).icon"
68
+ :class="[resolveModifierIcon(modifierId).color, 'size-4 shrink-0']"
69
+ />
70
+ </UTooltip>
71
+ </template>
72
+ <span class="truncate ml-1">{{ fieldName }}</span>
73
+ </span>
74
+
75
+ <UTooltip :text="inferFieldTypeIcon(fieldType, String(fieldName)).label">
76
+ <UIcon
77
+ :name="inferFieldTypeIcon(fieldType, String(fieldName)).icon"
78
+ :class="[
79
+ inferFieldTypeIcon(fieldType, String(fieldName)).color,
80
+ 'size-4 shrink-0',
81
+ ]"
82
+ />
83
+ </UTooltip>
84
+ </li>
85
+ </ul>
86
+ </div>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .diagram-table-node :deep(.vue-flow__handle) {
91
+ opacity: 0;
92
+ pointer-events: none;
93
+ }
94
+ </style>