@vincent99/vlib 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +178 -0
  2. package/README.md +107 -0
  3. package/bin/vlib.js +10 -0
  4. package/dist/AdminForm.vue_vue_type_style_index_0_lang-xCk1ywLq.js +753 -0
  5. package/dist/auth/middleware.d.ts +18 -0
  6. package/dist/auth/middleware.d.ts.map +1 -0
  7. package/dist/auth/middleware.js +44 -0
  8. package/dist/auth/middleware.js.map +1 -0
  9. package/dist/auth/password.d.ts +10 -0
  10. package/dist/auth/password.d.ts.map +1 -0
  11. package/dist/auth/password.js +44 -0
  12. package/dist/auth/password.js.map +1 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +104 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/components/AdminForm.vue.d.ts +7 -0
  18. package/dist/components/AdminTable.vue.d.ts +5 -0
  19. package/dist/components/AppLayout.vue.d.ts +36 -0
  20. package/dist/components/NavSidebar.vue.d.ts +11 -0
  21. package/dist/components/TableView.vue.d.ts +52 -0
  22. package/dist/components/index.d.ts +6 -0
  23. package/dist/components/index.js +8 -0
  24. package/dist/components/types.d.ts +25 -0
  25. package/dist/db/index.d.ts +12 -0
  26. package/dist/db/index.d.ts.map +1 -0
  27. package/dist/db/index.js +84 -0
  28. package/dist/db/index.js.map +1 -0
  29. package/dist/db/migrate.d.ts +2 -0
  30. package/dist/db/migrate.d.ts.map +1 -0
  31. package/dist/db/migrate.js +94 -0
  32. package/dist/db/migrate.js.map +1 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +11 -0
  35. package/dist/router/index.d.ts +33 -0
  36. package/dist/router/index.js +62 -0
  37. package/dist/server/api/admin.d.ts +3 -0
  38. package/dist/server/api/admin.d.ts.map +1 -0
  39. package/dist/server/api/admin.js +184 -0
  40. package/dist/server/api/admin.js.map +1 -0
  41. package/dist/server/api/auth.d.ts +3 -0
  42. package/dist/server/api/auth.d.ts.map +1 -0
  43. package/dist/server/api/auth.js +66 -0
  44. package/dist/server/api/auth.js.map +1 -0
  45. package/dist/server/index.d.ts +17 -0
  46. package/dist/server/index.d.ts.map +1 -0
  47. package/dist/server/index.js +47 -0
  48. package/dist/server/index.js.map +1 -0
  49. package/dist/types.d.ts +53 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +3 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/vlib.css +1 -0
  54. package/package.json +91 -0
  55. package/src/components/AdminForm.vue +491 -0
  56. package/src/components/AdminTable.vue +269 -0
  57. package/src/components/AppLayout.vue +280 -0
  58. package/src/components/NavSidebar.vue +176 -0
  59. package/src/components/TableView.vue +379 -0
  60. package/src/components/index.ts +13 -0
  61. package/src/components/types.ts +28 -0
  62. package/templates/.env.example +4 -0
  63. package/templates/.prettierignore +3 -0
  64. package/templates/.prettierrc +6 -0
  65. package/templates/Dockerfile.ejs +31 -0
  66. package/templates/docker-compose.prod.yml.ejs +22 -0
  67. package/templates/docker-compose.yml.ejs +22 -0
  68. package/templates/eslint.config.mjs +42 -0
  69. package/templates/index.html.ejs +13 -0
  70. package/templates/package.json.ejs +44 -0
  71. package/templates/postcss.config.js.ejs +6 -0
  72. package/templates/schemas/001-initial.sql +35 -0
  73. package/templates/scripts/migrate.ts +13 -0
  74. package/templates/server/index.ts +13 -0
  75. package/templates/src/App.vue +8 -0
  76. package/templates/src/main.ts +6 -0
  77. package/templates/src/router.ts +26 -0
  78. package/templates/src/routes/_layout.vue +58 -0
  79. package/templates/src/routes/admin/_layout.vue +8 -0
  80. package/templates/src/routes/admin/index.vue +88 -0
  81. package/templates/src/routes/admin/tables/[table]/[id].vue +20 -0
  82. package/templates/src/routes/admin/tables/[table]/index.vue +10 -0
  83. package/templates/src/routes/admin/tables/[table]/new.vue +10 -0
  84. package/templates/src/routes/index.vue +34 -0
  85. package/templates/src/routes/login.vue +128 -0
  86. package/templates/src/stores/auth.ts +58 -0
  87. package/templates/src/styles/main.scss +98 -0
  88. package/templates/src/styles/variables.scss +7 -0
  89. package/templates/tailwind.config.js.ejs +27 -0
  90. package/templates/tsconfig.json.ejs +26 -0
  91. package/templates/tsconfig.server.json.ejs +17 -0
  92. package/templates/vite.config.ts.ejs +36 -0
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@vincent99/vlib",
3
+ "version": "0.1.0",
4
+ "description": "Shared library and scaffold generator for Vue 3 + SQLite webapps",
5
+ "homepage": "https://github.com/vincent99/vlib#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/vincent99/vlib/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/vincent99/vlib.git"
12
+ },
13
+ "license": "Apache-2.0",
14
+ "author": "Vincent Fiduccia",
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./dist/index.js",
19
+ "types": "./dist/index.d.ts"
20
+ },
21
+ "./server": {
22
+ "import": "./dist/server/index.js",
23
+ "types": "./dist/server/index.d.ts"
24
+ },
25
+ "./router": {
26
+ "import": "./dist/router/index.js",
27
+ "types": "./dist/router/index.d.ts"
28
+ },
29
+ "./components": {
30
+ "import": "./dist/components/index.js",
31
+ "types": "./dist/components/index.d.ts"
32
+ }
33
+ },
34
+ "main": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "bin": {
37
+ "vlib": "bin/vlib.js"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "bin",
42
+ "templates",
43
+ "src/components"
44
+ ],
45
+ "scripts": {
46
+ "build": "yarn build:server && yarn build:client",
47
+ "build:server": "tsc -p tsconfig.server.json --outDir dist",
48
+ "build:client": "vite build --config vite.lib.config.ts",
49
+ "dev": "tsc -p tsconfig.server.json --watch",
50
+ "type-check": "vue-tsc --noEmit",
51
+ "lint": "eslint .",
52
+ "lint:fix": "eslint --fix .",
53
+ "format": "prettier --write .",
54
+ "format:check": "prettier --check .",
55
+ "prepublishOnly": "yarn build"
56
+ },
57
+ "dependencies": {
58
+ "better-sqlite3": "^9.6.0",
59
+ "commander": "^12.1.0",
60
+ "cookie-parser": "^1.4.7",
61
+ "ejs": "^3.1.10",
62
+ "express": "^4.21.2",
63
+ "uuid": "^10.0.0"
64
+ },
65
+ "devDependencies": {
66
+ "@eslint/js": "^9.18.0",
67
+ "@types/better-sqlite3": "^7.6.12",
68
+ "@types/cookie-parser": "^1.4.8",
69
+ "@types/ejs": "^3.1.5",
70
+ "@types/express": "^5.0.0",
71
+ "@types/node": "^22.10.0",
72
+ "@types/uuid": "^10.0.0",
73
+ "@vitejs/plugin-vue": "^5.2.1",
74
+ "@vue/eslint-config-typescript": "^14.2.0",
75
+ "eslint": "^9.18.0",
76
+ "eslint-plugin-vue": "^9.32.0",
77
+ "prettier": "^3.4.2",
78
+ "sass": "^1.83.4",
79
+ "typescript": "^5.7.2",
80
+ "typescript-eslint": "^8.20.0",
81
+ "vite": "^6.0.7",
82
+ "vite-plugin-dts": "^4.3.0",
83
+ "vue": "^3.5.13",
84
+ "vue-router": "^4.5.0",
85
+ "vue-tsc": "^2.2.0"
86
+ },
87
+ "peerDependencies": {
88
+ "vue": "^3.4.0",
89
+ "vue-router": "^4.3.0"
90
+ }
91
+ }
@@ -0,0 +1,491 @@
1
+ <template>
2
+ <div class="vl-admin-form">
3
+ <div class="vl-admin-form__header">
4
+ <RouterLink :to="`/admin/tables/${tableName}`" class="vl-back-link"
5
+ >← Back to {{ tableName }}</RouterLink
6
+ >
7
+ <h1 class="vl-admin-form__title">
8
+ {{
9
+ isMultiEdit
10
+ ? `Edit ${rowIds.length} rows`
11
+ : isNew
12
+ ? `New ${tableName} row`
13
+ : `Edit row`
14
+ }}
15
+ </h1>
16
+ </div>
17
+
18
+ <div v-if="loadError" class="vl-alert vl-alert--error">{{ loadError }}</div>
19
+ <div v-if="saveError" class="vl-alert vl-alert--error">{{ saveError }}</div>
20
+ <div v-if="saveSuccess" class="vl-alert vl-alert--success">
21
+ Saved successfully.
22
+ </div>
23
+
24
+ <div v-if="loading" class="vl-admin-form__loading">Loading…</div>
25
+
26
+ <form v-else class="vl-form" @submit.prevent="save">
27
+ <div
28
+ v-for="col in editableColumns"
29
+ :key="col.name"
30
+ class="vl-form__field"
31
+ >
32
+ <div class="vl-form__field-header">
33
+ <!-- Multi-edit: checkbox to enable field -->
34
+ <input
35
+ v-if="isMultiEdit"
36
+ :id="`enable-${col.name}`"
37
+ v-model="enabledFields[col.name]"
38
+ type="checkbox"
39
+ class="vl-form__field-enable"
40
+ />
41
+ <label :for="`field-${col.name}`" class="vl-form__label">
42
+ {{ col.name }}
43
+ <span class="vl-form__type">{{ col.type }}</span>
44
+ <span
45
+ v-if="col.notnull && !col.dflt_value"
46
+ class="vl-form__required"
47
+ >*</span
48
+ >
49
+ </label>
50
+ </div>
51
+
52
+ <div class="vl-form__input-wrap">
53
+ <template v-if="isMultiEdit && !enabledFields[col.name]">
54
+ <input
55
+ type="text"
56
+ :value="getMultiValue(col.name)"
57
+ disabled
58
+ class="vl-form__input vl-form__input--disabled"
59
+ />
60
+ </template>
61
+ <template v-else>
62
+ <!-- Boolean -->
63
+ <input
64
+ v-if="isBoolType(col)"
65
+ :id="`field-${col.name}`"
66
+ v-model="formData[col.name] as boolean"
67
+ type="checkbox"
68
+ class="vl-form__checkbox"
69
+ />
70
+ <!-- Password -->
71
+ <input
72
+ v-else-if="col.name.toLowerCase().includes('password')"
73
+ :id="`field-${col.name}`"
74
+ v-model="formData[col.name] as string"
75
+ type="password"
76
+ class="vl-form__input"
77
+ :placeholder="isNew ? '' : '(leave blank to keep unchanged)'"
78
+ />
79
+ <!-- JSON / large text -->
80
+ <textarea
81
+ v-else-if="isJsonType(col) || isTextType(col)"
82
+ :id="`field-${col.name}`"
83
+ v-model="formData[col.name] as string"
84
+ class="vl-form__input vl-form__input--textarea"
85
+ rows="4"
86
+ />
87
+ <!-- Number -->
88
+ <input
89
+ v-else-if="isNumericType(col)"
90
+ :id="`field-${col.name}`"
91
+ v-model="formData[col.name] as number"
92
+ type="number"
93
+ class="vl-form__input"
94
+ />
95
+ <!-- Date -->
96
+ <input
97
+ v-else-if="isDateType(col)"
98
+ :id="`field-${col.name}`"
99
+ v-model="formData[col.name] as string"
100
+ type="datetime-local"
101
+ class="vl-form__input"
102
+ />
103
+ <!-- Default: text -->
104
+ <input
105
+ v-else
106
+ :id="`field-${col.name}`"
107
+ v-model="formData[col.name] as string"
108
+ type="text"
109
+ class="vl-form__input"
110
+ />
111
+ </template>
112
+ <!-- Multi-edit "Multiple" warning -->
113
+ <span
114
+ v-if="
115
+ isMultiEdit &&
116
+ enabledFields[col.name] &&
117
+ formData[col.name] === MULTIPLE_SENTINEL
118
+ "
119
+ class="vl-form__multi-warn"
120
+ >Cannot save "— Multiple —". Please enter a new value.</span
121
+ >
122
+ </div>
123
+ </div>
124
+
125
+ <div class="vl-form__actions">
126
+ <RouterLink :to="`/admin/tables/${tableName}`" class="vl-btn"
127
+ >Cancel</RouterLink
128
+ >
129
+ <button
130
+ type="submit"
131
+ class="vl-btn vl-btn--primary"
132
+ :disabled="saving || hasMultipleError"
133
+ >
134
+ {{ saving ? 'Saving…' : 'Save' }}
135
+ </button>
136
+ </div>
137
+ </form>
138
+ </div>
139
+ </template>
140
+
141
+ <script setup lang="ts">
142
+ import { ref, computed, onMounted } from 'vue';
143
+ import { useRouter } from 'vue-router';
144
+
145
+ interface ColInfo {
146
+ name: string;
147
+ type: string;
148
+ notnull: number;
149
+ dflt_value: string | null;
150
+ pk: number;
151
+ }
152
+
153
+ const props = defineProps<{
154
+ tableName: string;
155
+ /** Single row ID for edit, undefined for new, or array for multi-edit */
156
+ rowIds?: (string | number)[];
157
+ }>();
158
+
159
+ const MULTIPLE_SENTINEL = '— Multiple —';
160
+
161
+ const router = useRouter();
162
+ const columns = ref<ColInfo[]>([]);
163
+ const formData = ref<Record<string, unknown>>({});
164
+ const originalData = ref<Record<string, Record<string, unknown>>>({}); // rowId -> original values
165
+ const enabledFields = ref<Record<string, boolean>>({});
166
+ const loading = ref(true);
167
+ const saving = ref(false);
168
+ const loadError = ref<string | null>(null);
169
+ const saveError = ref<string | null>(null);
170
+ const saveSuccess = ref(false);
171
+
172
+ const isNew = computed(() => !props.rowIds || props.rowIds.length === 0);
173
+ const isMultiEdit = computed(() => (props.rowIds?.length ?? 0) > 1);
174
+ const rowIds = computed(() => props.rowIds ?? []);
175
+
176
+ const editableColumns = computed(() =>
177
+ columns.value.filter((c) => {
178
+ if (isNew.value) {
179
+ return c.pk === 0 || c.dflt_value === null;
180
+ }
181
+ return c.pk === 0;
182
+ })
183
+ );
184
+
185
+ const hasMultipleError = computed(
186
+ () =>
187
+ isMultiEdit.value &&
188
+ editableColumns.value.some(
189
+ (c) =>
190
+ enabledFields.value[c.name] &&
191
+ formData.value[c.name] === MULTIPLE_SENTINEL
192
+ )
193
+ );
194
+
195
+ function isBoolType(col: ColInfo): boolean {
196
+ return /bool/i.test(col.type);
197
+ }
198
+
199
+ function isJsonType(col: ColInfo): boolean {
200
+ return /json/i.test(col.type) || col.name.toLowerCase() === 'preferences';
201
+ }
202
+
203
+ function isTextType(col: ColInfo): boolean {
204
+ return /text|varchar|nvarchar|clob/i.test(col.type) && col.name.length > 40;
205
+ }
206
+
207
+ function isNumericType(col: ColInfo): boolean {
208
+ return /int|real|float|double|numeric|decimal/i.test(col.type);
209
+ }
210
+
211
+ function isDateType(col: ColInfo): boolean {
212
+ return /date|time/i.test(col.type);
213
+ }
214
+
215
+ function getMultiValue(colName: string): string {
216
+ const values = new Set(
217
+ Object.values(originalData.value).map((r) => r[colName])
218
+ );
219
+ if (values.size > 1) {
220
+ return MULTIPLE_SENTINEL;
221
+ }
222
+ return String([...values][0] ?? '');
223
+ }
224
+
225
+ async function loadColumns() {
226
+ const res = await fetch('/api/admin/tables');
227
+ if (!res.ok) {
228
+ throw new Error('Failed to load table info');
229
+ }
230
+ const tables: Array<{ name: string; columns: ColInfo[] }> = await res.json();
231
+ const tableInfo = tables.find((t) => t.name === props.tableName);
232
+ if (!tableInfo) {
233
+ throw new Error(`Table "${props.tableName}" not found`);
234
+ }
235
+ columns.value = tableInfo.columns;
236
+ }
237
+
238
+ async function loadRows() {
239
+ if (isNew.value) {
240
+ return;
241
+ }
242
+ for (const id of rowIds.value) {
243
+ const res = await fetch(`/api/admin/tables/${props.tableName}/${id}`);
244
+ if (!res.ok) {
245
+ throw new Error(`Row ${id} not found`);
246
+ }
247
+ originalData.value[String(id)] = await res.json();
248
+ }
249
+ }
250
+
251
+ function initForm() {
252
+ if (isNew.value) {
253
+ for (const col of editableColumns.value) {
254
+ formData.value[col.name] = col.dflt_value ?? null;
255
+ }
256
+ return;
257
+ }
258
+
259
+ for (const col of editableColumns.value) {
260
+ if (isMultiEdit.value) {
261
+ const multiVal = getMultiValue(col.name);
262
+ formData.value[col.name] = multiVal;
263
+ enabledFields.value[col.name] = false;
264
+ } else {
265
+ const id = String(rowIds.value[0]);
266
+ formData.value[col.name] = originalData.value[id]?.[col.name] ?? null;
267
+ }
268
+ }
269
+ }
270
+
271
+ async function save() {
272
+ saving.value = false;
273
+ saveError.value = null;
274
+ saveSuccess.value = false;
275
+ saving.value = true;
276
+
277
+ try {
278
+ if (isNew.value) {
279
+ const res = await fetch(`/api/admin/tables/${props.tableName}`, {
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify(formData.value),
283
+ });
284
+ if (!res.ok) {
285
+ throw new Error((await res.json()).error);
286
+ }
287
+ router.push(`/admin/tables/${props.tableName}`);
288
+ } else if (isMultiEdit.value) {
289
+ const payload: Record<string, unknown> = {};
290
+ for (const col of editableColumns.value) {
291
+ if (
292
+ enabledFields.value[col.name] &&
293
+ formData.value[col.name] !== MULTIPLE_SENTINEL
294
+ ) {
295
+ // Skip blank password fields
296
+ if (
297
+ col.name.toLowerCase().includes('password') &&
298
+ !formData.value[col.name]
299
+ ) {
300
+ continue;
301
+ }
302
+ payload[col.name] = formData.value[col.name];
303
+ }
304
+ }
305
+ for (const id of rowIds.value) {
306
+ const res = await fetch(`/api/admin/tables/${props.tableName}/${id}`, {
307
+ method: 'PUT',
308
+ headers: { 'Content-Type': 'application/json' },
309
+ body: JSON.stringify(payload),
310
+ });
311
+ if (!res.ok) {
312
+ throw new Error((await res.json()).error);
313
+ }
314
+ }
315
+ router.push(`/admin/tables/${props.tableName}`);
316
+ } else {
317
+ const payload = { ...formData.value };
318
+ // Remove blank password fields (keep existing)
319
+ for (const col of editableColumns.value) {
320
+ if (col.name.toLowerCase().includes('password') && !payload[col.name]) {
321
+ delete payload[col.name];
322
+ }
323
+ }
324
+ const res = await fetch(
325
+ `/api/admin/tables/${props.tableName}/${rowIds.value[0]}`,
326
+ {
327
+ method: 'PUT',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: JSON.stringify(payload),
330
+ }
331
+ );
332
+ if (!res.ok) {
333
+ throw new Error((await res.json()).error);
334
+ }
335
+ saveSuccess.value = true;
336
+ }
337
+ } catch (e) {
338
+ saveError.value = String(e);
339
+ } finally {
340
+ saving.value = false;
341
+ }
342
+ }
343
+
344
+ onMounted(async () => {
345
+ try {
346
+ await loadColumns();
347
+ await loadRows();
348
+ initForm();
349
+ } catch (e) {
350
+ loadError.value = String(e);
351
+ } finally {
352
+ loading.value = false;
353
+ }
354
+ });
355
+ </script>
356
+
357
+ <style lang="scss">
358
+ .vl-admin-form {
359
+ max-width: 720px;
360
+
361
+ &__header {
362
+ margin-bottom: var(--space-5);
363
+ }
364
+
365
+ &__title {
366
+ font-size: 1.5rem;
367
+ font-weight: 700;
368
+ color: var(--color-text);
369
+ margin-top: var(--space-2);
370
+ }
371
+
372
+ &__loading {
373
+ color: var(--color-text-secondary);
374
+ padding: var(--space-6);
375
+ text-align: center;
376
+ }
377
+ }
378
+
379
+ .vl-back-link {
380
+ font-size: 0.875rem;
381
+ color: var(--color-primary);
382
+ text-decoration: none;
383
+
384
+ &:hover {
385
+ text-decoration: underline;
386
+ }
387
+ }
388
+
389
+ .vl-form {
390
+ background: white;
391
+ border-radius: var(--radius-lg);
392
+ box-shadow: var(--shadow);
393
+ padding: var(--space-6);
394
+
395
+ &__field {
396
+ margin-bottom: var(--space-4);
397
+ }
398
+
399
+ &__field-header {
400
+ display: flex;
401
+ align-items: center;
402
+ gap: var(--space-2);
403
+ margin-bottom: var(--space-1);
404
+ }
405
+
406
+ &__field-enable {
407
+ width: 16px;
408
+ height: 16px;
409
+ cursor: pointer;
410
+ }
411
+
412
+ &__label {
413
+ font-size: 0.875rem;
414
+ font-weight: 600;
415
+ color: var(--color-text);
416
+ display: flex;
417
+ align-items: center;
418
+ gap: var(--space-2);
419
+ }
420
+
421
+ &__type {
422
+ font-size: 0.75rem;
423
+ font-weight: 400;
424
+ color: var(--color-text-secondary);
425
+ font-family: monospace;
426
+ }
427
+
428
+ &__required {
429
+ color: var(--color-danger);
430
+ }
431
+
432
+ &__input {
433
+ display: block;
434
+ width: 100%;
435
+ padding: var(--space-2) var(--space-3);
436
+ border: 1px solid var(--color-border);
437
+ border-radius: var(--radius);
438
+ font-size: 0.875rem;
439
+ color: var(--color-text);
440
+ background: white;
441
+ transition:
442
+ border-color 0.15s,
443
+ box-shadow 0.15s;
444
+ box-sizing: border-box;
445
+
446
+ &:focus {
447
+ outline: none;
448
+ border-color: var(--color-primary-light);
449
+ box-shadow: 0 0 0 3px
450
+ color-mix(in srgb, var(--color-primary) 20%, transparent);
451
+ }
452
+
453
+ &--disabled {
454
+ background: var(--color-background);
455
+ color: var(--color-text-secondary);
456
+ cursor: not-allowed;
457
+ }
458
+
459
+ &--textarea {
460
+ resize: vertical;
461
+ font-family: monospace;
462
+ }
463
+ }
464
+
465
+ &__checkbox {
466
+ width: 18px;
467
+ height: 18px;
468
+ cursor: pointer;
469
+ }
470
+
471
+ &__input-wrap {
472
+ position: relative;
473
+ }
474
+
475
+ &__multi-warn {
476
+ font-size: 0.75rem;
477
+ color: var(--color-danger);
478
+ margin-top: var(--space-1);
479
+ display: block;
480
+ }
481
+
482
+ &__actions {
483
+ display: flex;
484
+ gap: var(--space-3);
485
+ justify-content: flex-end;
486
+ padding-top: var(--space-4);
487
+ border-top: 1px solid var(--color-border);
488
+ margin-top: var(--space-5);
489
+ }
490
+ }
491
+ </style>