nuxt-ui-basekit 0.2.0 → 0.2.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/app/assets/css/basekit.css +23 -24
- package/app/components/BaseKitBackLink.vue +9 -9
- package/app/components/BaseKitChoiceCard.vue +19 -19
- package/app/components/BaseKitConfirmModal.vue +7 -7
- package/app/components/BaseKitDataTable.vue +30 -30
- package/app/components/BaseKitEmptyState.vue +7 -7
- package/app/components/BaseKitFileUpload.vue +5 -5
- package/app/components/BaseKitIconPicker.vue +8 -8
- package/app/components/BaseKitMarkdownEditor.vue +11 -9
- package/app/components/BaseKitPending.vue +15 -16
- package/app/components/BaseKitRecordPicker.vue +14 -14
- package/app/components/BaseKitSettingRow.vue +15 -14
- package/app/components/BaseKitStatTile.vue +8 -8
- package/app/components/BaseKitTabs.vue +33 -34
- package/app/components/BaseKitViewLink.vue +14 -14
- package/app/components/charts/BaseKitChartBars.vue +8 -9
- package/app/components/charts/BaseKitChartColumns.vue +30 -31
- package/app/components/charts/BaseKitChartDonut.vue +18 -18
- package/app/components/charts/BaseKitChartFigure.vue +10 -10
- package/app/components/charts/BaseKitChartMeter.vue +4 -4
- package/app/composables/useBaseKit.ts +36 -41
- package/app/composables/useChartPalette.ts +14 -14
- package/app/composables/useChartWidth.ts +10 -10
- package/app/composables/useConfirm.ts +3 -3
- package/app/stores/confirm.ts +16 -16
- package/app/utils/html-to-markdown.ts +14 -16
- package/app/utils/markdown.ts +8 -7
- package/package.json +1 -1
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/* ==========================================================================
|
|
2
|
-
* BaseKit —
|
|
2
|
+
* BaseKit — design tokens
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The components only ever reach for `--basekit-*`, never for a variable
|
|
5
|
+
* belonging to one particular application. What follows are the defaults.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* An application with its own theme maps the tokens onto its own variables in
|
|
8
|
+
* one place — a block in its stylesheet, loaded after this one:
|
|
9
9
|
*
|
|
10
10
|
* :root {
|
|
11
|
-
* --basekit-accent: var(--
|
|
12
|
-
* --basekit-surface: var(--
|
|
11
|
+
* --basekit-accent: var(--my-brand-colour, #2563eb);
|
|
12
|
+
* --basekit-surface: var(--my-card-surface, #ffffff);
|
|
13
13
|
* }
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Drop that block and the components keep rendering with the values here.
|
|
16
16
|
* ========================================================================== */
|
|
17
17
|
|
|
18
18
|
:root {
|
|
@@ -38,21 +38,20 @@
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/* --------------------------------------------------------------------------
|
|
41
|
-
*
|
|
41
|
+
* Chart colours (BaseKitChart*)
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
43
|
+
* Deliberately **not** derived from the brand colour. A theme may carry any
|
|
44
|
+
* accent — four shades of it side by side in a stacked area would give series
|
|
45
|
+
* nobody can tell apart, and under a red-green deficiency nothing at all. The
|
|
46
|
+
* five series colours are therefore fixed and validated against the card
|
|
47
|
+
* surface: lightness band, chroma, CVD distance ΔE ≥ 8 between neighbours,
|
|
48
|
+
* ΔE ≥ 15 for normal vision.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* zusammen, statt eine Farbe zu erfinden.
|
|
50
|
+
* Order is part of that validation. Reordering them, or adding a sixth, means
|
|
51
|
+
* measuring again. Beyond five series, group them rather than invent a colour.
|
|
53
52
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* A project may override the values — as a validated set, though, not one at
|
|
54
|
+
* a time.
|
|
56
55
|
* ------------------------------------------------------------------------ */
|
|
57
56
|
:root {
|
|
58
57
|
--basekit-chart-1: #2a78d6;
|
|
@@ -61,13 +60,13 @@
|
|
|
61
60
|
--basekit-chart-4: #eda100;
|
|
62
61
|
--basekit-chart-5: #e87ba4;
|
|
63
62
|
|
|
64
|
-
/*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
63
|
+
/* The surface a chart sits on. It carries the 2px gap between stacked
|
|
64
|
+
* segments and the ring around points — which is why it has to match the
|
|
65
|
+
* card colour rather than being white-ish. */
|
|
67
66
|
--basekit-chart-surface: var(--ui-bg, #ffffff);
|
|
68
67
|
--basekit-chart-grid: rgb(0 0 0 / 8%);
|
|
69
68
|
--basekit-chart-axis: rgb(0 0 0 / 18%);
|
|
70
|
-
/*
|
|
69
|
+
/* Muted series: context, not a statement. */
|
|
71
70
|
--basekit-chart-muted: rgb(0 0 0 / 22%);
|
|
72
71
|
}
|
|
73
72
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* The way back from a detail page to its list.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Always in the same place: top right of the page header. Before this, every
|
|
6
|
+
* page solved it differently — sometimes a button above the heading, sometimes
|
|
7
|
+
* a small link, sometimes a button on the right, labelled either with the name
|
|
8
|
+
* of the list or with "Back". Anyone moving between two areas had to look for
|
|
9
|
+
* the way back anew each time.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The label is therefore fixed, and it names the direction rather than the
|
|
12
|
+
* target. `label` overrides it for the cases where the way back leads to a
|
|
13
|
+
* parent record instead of a list.
|
|
14
14
|
*/
|
|
15
15
|
import { computed } from 'vue'
|
|
16
16
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Pick one of a handful of variants, for the case where the variants can be
|
|
4
|
+
* shown — themes, layouts, templates. A dropdown gives you names only; here
|
|
5
|
+
* the preview sits right next to the label.
|
|
6
6
|
*
|
|
7
7
|
* ┌──────────────────────────┐
|
|
8
|
-
* │ │ ←
|
|
9
|
-
* │
|
|
8
|
+
* │ │ ← `preview` slot (a stylised rendering)
|
|
9
|
+
* │ Preview │
|
|
10
10
|
* ├──────────────────────────┤
|
|
11
|
-
* │
|
|
12
|
-
* │
|
|
11
|
+
* │ Title [ Active ] │ ← active: a badge, otherwise an Apply button
|
|
12
|
+
* │ Description │
|
|
13
13
|
* └──────────────────────────┘
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Usage — cards in a grid, one per variant:
|
|
16
16
|
*
|
|
17
17
|
* <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
18
18
|
* <BaseKitChoiceCard
|
|
@@ -25,28 +25,28 @@
|
|
|
25
25
|
* :pending="pending"
|
|
26
26
|
* @apply="apply(o.id)"
|
|
27
27
|
* >
|
|
28
|
-
* <template #preview><
|
|
28
|
+
* <template #preview><MyPreview :id="o.id" /></template>
|
|
29
29
|
* </BaseKitChoiceCard>
|
|
30
30
|
* </div>
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* The card itself triggers nothing — the button does the choosing. Where a
|
|
33
|
+
* choice takes effect for everyone at once, a stray click is otherwise made
|
|
34
|
+
* quickly.
|
|
35
35
|
*/
|
|
36
36
|
withDefaults(defineProps<{
|
|
37
|
-
/** Name
|
|
37
|
+
/** Name of the variant. */
|
|
38
38
|
title: string
|
|
39
|
-
/**
|
|
39
|
+
/** A short explanation below the title. */
|
|
40
40
|
description?: string
|
|
41
|
-
/**
|
|
41
|
+
/** This variant is the active one. */
|
|
42
42
|
active?: boolean
|
|
43
|
-
/**
|
|
43
|
+
/** A save is in flight — disables the button. */
|
|
44
44
|
pending?: boolean
|
|
45
|
-
/**
|
|
45
|
+
/** Label of the button that applies this variant. */
|
|
46
46
|
applyLabel?: string
|
|
47
|
-
/**
|
|
47
|
+
/** Label of the active badge. */
|
|
48
48
|
activeLabel?: string
|
|
49
|
-
/**
|
|
49
|
+
/** Aspect ratio of the preview area. */
|
|
50
50
|
ratio?: string
|
|
51
51
|
}>(), {
|
|
52
52
|
description: undefined,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* The globally mounted confirmation modal, driven by the `basekit:confirm`
|
|
4
|
+
* store — see `useConfirm()`. Exactly one instance per layout is enough; it
|
|
5
|
+
* answers every prompt.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Replaces native `window.confirm` dialogs: themed, dark-mode-capable, with a
|
|
8
|
+
* red confirm button for destructive actions. Closing without a click, by
|
|
9
|
+
* Escape or backdrop, counts as cancel.
|
|
10
10
|
*/
|
|
11
11
|
import { computed } from 'vue'
|
|
12
12
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
@@ -20,7 +20,7 @@ const opts = computed(() => store.options)
|
|
|
20
20
|
const open = computed({
|
|
21
21
|
get: () => store.open,
|
|
22
22
|
set: (value: boolean) => {
|
|
23
|
-
//
|
|
23
|
+
// Closing by backdrop or Escape without confirming means cancel.
|
|
24
24
|
if (!value) store.settle(false)
|
|
25
25
|
},
|
|
26
26
|
})
|
|
@@ -4,22 +4,22 @@ import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
|
4
4
|
import { NuxtLink } from '#components'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* A reusable admin table: sortable columns, a text filter and a create button
|
|
8
|
+
* top right. Client-side throughout — the rows are handed in.
|
|
9
9
|
*
|
|
10
|
-
* - `columns`
|
|
11
|
-
* -
|
|
10
|
+
* - `columns` defines the columns; `sortable` makes a header clickable.
|
|
11
|
+
* - Cells render `row[key]` by default, overridable through the slot
|
|
12
12
|
* `#cell-<key>="{ row, value }"`.
|
|
13
|
-
* -
|
|
14
|
-
* - `row-link`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
13
|
+
* - Row actions go into the slot `#actions="{ row }"`, the rightmost column.
|
|
14
|
+
* - `row-link` makes the name column clickable: the function receives the row
|
|
15
|
+
* and returns its target, or `null` where that row has none. Which column
|
|
16
|
+
* carries the link is `link-column`; without it, the first.
|
|
17
|
+
* - Extra filters go into the slot `#toolbar`, right of the search field.
|
|
18
|
+
* - `create-label` plus `@create` renders the button top right.
|
|
19
|
+
* - Pagination is client-side: `page-size` and `page-size-options` (default
|
|
20
|
+
* 25, offering 10/25/50/100/250/all).
|
|
21
21
|
*
|
|
22
|
-
*
|
|
22
|
+
* Generic over the row type `T` — slots hand `row` back typed.
|
|
23
23
|
*/
|
|
24
24
|
export interface BaseKitDataColumn {
|
|
25
25
|
key: string
|
|
@@ -29,7 +29,7 @@ export interface BaseKitDataColumn {
|
|
|
29
29
|
class?: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/** Page size: a fixed number of rows, or `'all'` for no limit. */
|
|
33
33
|
export type BaseKitPageSize = number | 'all'
|
|
34
34
|
|
|
35
35
|
const props = withDefaults(defineProps<{
|
|
@@ -37,23 +37,23 @@ const props = withDefaults(defineProps<{
|
|
|
37
37
|
rows: T[]
|
|
38
38
|
rowKey?: string
|
|
39
39
|
searchable?: boolean
|
|
40
|
-
/**
|
|
40
|
+
/** Fields the search looks through. Defaults to every column key. */
|
|
41
41
|
searchKeys?: string[]
|
|
42
42
|
searchPlaceholder?: string
|
|
43
43
|
createLabel?: string
|
|
44
44
|
loading?: boolean
|
|
45
45
|
emptyLabel?: string
|
|
46
|
-
/**
|
|
46
|
+
/** Page size selected initially. */
|
|
47
47
|
pageSize?: BaseKitPageSize
|
|
48
|
-
/**
|
|
48
|
+
/** The page sizes on offer. */
|
|
49
49
|
pageSizeOptions?: BaseKitPageSize[]
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* Target per row. Set, it makes the name clickable — the route people try
|
|
52
|
+
* first. The actions column stays regardless: it shows what there is besides
|
|
53
|
+
* opening.
|
|
54
54
|
*/
|
|
55
55
|
rowLink?: (row: T) => string | null | undefined
|
|
56
|
-
/**
|
|
56
|
+
/** The column carrying the link. Without it, the first. */
|
|
57
57
|
linkColumn?: string
|
|
58
58
|
}>(), {
|
|
59
59
|
rowKey: 'id',
|
|
@@ -75,7 +75,7 @@ const sortDir = ref<'asc' | 'desc'>('asc')
|
|
|
75
75
|
|
|
76
76
|
const searchFields = computed(() => props.searchKeys ?? props.columns.map(c => c.key))
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/** The linked column — explicitly set, or the first. */
|
|
79
79
|
const linkKey = computed(() => props.linkColumn ?? props.columns[0]?.key ?? null)
|
|
80
80
|
|
|
81
81
|
function rowTarget(row: T, key: string): string | null {
|
|
@@ -84,7 +84,7 @@ function rowTarget(row: T, key: string): string | null {
|
|
|
84
84
|
return target || null
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/**
|
|
87
|
+
/** Value of a cell — internal string indexing over the generic row type. */
|
|
88
88
|
function cell(row: T, key: string): unknown {
|
|
89
89
|
return (row as Record<string, unknown>)[key]
|
|
90
90
|
}
|
|
@@ -107,7 +107,7 @@ const displayed = computed<T[]>(() => {
|
|
|
107
107
|
return [...filtered.value].sort((a, b) => compare(cell(a, key), cell(b, key)) * dir)
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
// —
|
|
110
|
+
// — Pagination -------------------------------------------------------------
|
|
111
111
|
const page = ref(1)
|
|
112
112
|
const pageSize = ref<BaseKitPageSize>(props.pageSize)
|
|
113
113
|
|
|
@@ -136,12 +136,12 @@ const pageSizeItems = computed(() =>
|
|
|
136
136
|
})),
|
|
137
137
|
)
|
|
138
138
|
|
|
139
|
-
//
|
|
139
|
+
// Search or page size changed, so go back to page 1.
|
|
140
140
|
watch([search, pageSize], () => {
|
|
141
141
|
page.value = 1
|
|
142
142
|
})
|
|
143
143
|
|
|
144
|
-
//
|
|
144
|
+
// The data shrank, through a filter say, so clamp the page into range.
|
|
145
145
|
watch(totalPages, (pages) => {
|
|
146
146
|
if (page.value > pages) page.value = pages
|
|
147
147
|
})
|
|
@@ -176,7 +176,7 @@ function sortIcon(col: BaseKitDataColumn): string | null {
|
|
|
176
176
|
|
|
177
177
|
<template>
|
|
178
178
|
<div class="space-y-4">
|
|
179
|
-
<!-- Toolbar:
|
|
179
|
+
<!-- Toolbar: search on the left, filters and create on the right -->
|
|
180
180
|
<div v-if="searchable || $slots.toolbar || createLabel" class="flex flex-wrap items-center justify-between gap-3">
|
|
181
181
|
<UInput
|
|
182
182
|
v-if="searchable"
|
|
@@ -195,7 +195,7 @@ function sortIcon(col: BaseKitDataColumn): string | null {
|
|
|
195
195
|
</div>
|
|
196
196
|
</div>
|
|
197
197
|
|
|
198
|
-
<!--
|
|
198
|
+
<!-- States -->
|
|
199
199
|
<div v-if="loading" class="py-12 text-center text-muted">
|
|
200
200
|
<UIcon name="i-lucide-loader-2" class="size-6 animate-spin" />
|
|
201
201
|
</div>
|
|
@@ -209,7 +209,7 @@ function sortIcon(col: BaseKitDataColumn): string | null {
|
|
|
209
209
|
</slot>
|
|
210
210
|
</div>
|
|
211
211
|
|
|
212
|
-
<!--
|
|
212
|
+
<!-- Table -->
|
|
213
213
|
<table v-else class="w-full text-sm">
|
|
214
214
|
<thead class="border-b border-neutral-200 text-left text-muted dark:border-neutral-800">
|
|
215
215
|
<tr>
|
|
@@ -268,7 +268,7 @@ function sortIcon(col: BaseKitDataColumn): string | null {
|
|
|
268
268
|
</tbody>
|
|
269
269
|
</table>
|
|
270
270
|
|
|
271
|
-
<!--
|
|
271
|
+
<!-- Footer: page size on the left, range and paging on the right -->
|
|
272
272
|
<div
|
|
273
273
|
v-if="!loading && displayed.length"
|
|
274
274
|
class="flex flex-wrap items-center justify-between gap-3 pt-1 text-sm text-muted"
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* One empty state for all of them: icon, an action-shaped heading, optional
|
|
4
|
+
* explanation, optional primary action in the default slot.
|
|
5
5
|
*
|
|
6
|
-
* `variant`
|
|
7
|
-
* - `empty`
|
|
8
|
-
* - `search`
|
|
6
|
+
* `variant` separates the two cases that look alike and mean different things:
|
|
7
|
+
* - `empty` — nothing has been created yet; leads to the first step.
|
|
8
|
+
* - `search` — the query ran and found nothing; confirms it, offers a fix.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The icon follows the variant and can be overridden through `icon`. The
|
|
11
|
+
* action (a `UButton`, say) goes into the default slot.
|
|
12
12
|
*/
|
|
13
13
|
withDefaults(defineProps<{
|
|
14
14
|
title: string
|
|
@@ -3,9 +3,9 @@ import { ref } from 'vue'
|
|
|
3
3
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* File selection by click or drag and drop. Hands the chosen files up through
|
|
7
|
+
* `select` and does NOT upload them itself — that stays with the caller, who
|
|
8
|
+
* knows whether this is an image or a video.
|
|
9
9
|
*/
|
|
10
10
|
const props = withDefaults(defineProps<{
|
|
11
11
|
accept?: string
|
|
@@ -31,7 +31,7 @@ function pick(): void {
|
|
|
31
31
|
function onChange(event: Event): void {
|
|
32
32
|
const files = Array.from((event.target as HTMLInputElement).files ?? [])
|
|
33
33
|
if (files.length) emit('select', files)
|
|
34
|
-
//
|
|
34
|
+
// Reset, so the same file can be picked again.
|
|
35
35
|
if (input.value) input.value.value = ''
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -84,7 +84,7 @@ function onDrop(event: DragEvent): void {
|
|
|
84
84
|
.basekit-upload__icon { font-size: 1.5rem; }
|
|
85
85
|
.basekit-upload__label { font-size: 0.8125rem; font-weight: 500; }
|
|
86
86
|
|
|
87
|
-
/* Dark
|
|
87
|
+
/* Dark mode: override the drop zone's light fallbacks. */
|
|
88
88
|
:where(.dark) .basekit-upload { border-color: #1f2937; background: #0f172a; color: #9ca3af; }
|
|
89
89
|
:where(.dark) .basekit-upload:hover, :where(.dark) .basekit-upload--drag { background: #1f2937; }
|
|
90
90
|
</style>
|
|
@@ -3,14 +3,14 @@ import { computed, ref } from 'vue'
|
|
|
3
3
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* A form control for `i-lucide-*` names — display plus picker, no free text.
|
|
7
|
+
* The trigger shows the chosen icon, or a placeholder; a click opens a popover
|
|
8
|
+
* with a search field and a grid of curated icons. `modelValue` is the icon
|
|
9
|
+
* name, an empty string meaning no icon.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Curated rather than the full Iconify set, on purpose: it keeps the bundle
|
|
12
|
+
* small and needs no extra data source. When an icon is missing, the list
|
|
13
|
+
* below grows by a line.
|
|
14
14
|
*/
|
|
15
15
|
const props = defineProps<{ modelValue: string }>()
|
|
16
16
|
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
@@ -20,7 +20,7 @@ const labels = useBaseKitLabels()
|
|
|
20
20
|
const open = ref(false)
|
|
21
21
|
const search = ref('')
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Curated Lucide icons, the ones that come up in navigation and content.
|
|
24
24
|
const ICONS: string[] = [
|
|
25
25
|
'i-lucide-home', 'i-lucide-layout-dashboard', 'i-lucide-layout-grid', 'i-lucide-list',
|
|
26
26
|
'i-lucide-file-text', 'i-lucide-file', 'i-lucide-files', 'i-lucide-folder',
|
|
@@ -8,14 +8,16 @@ import { renderMarkdown } from '../utils/markdown'
|
|
|
8
8
|
import { htmlToMarkdown, prepareHtmlToMarkdown } from '../utils/html-to-markdown'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* WYSIWYG
|
|
11
|
+
* A WYSIWYG editor whose v-model is a **Markdown string**.
|
|
12
12
|
*
|
|
13
|
-
* Tiptap
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
13
|
+
* Tiptap works in HTML internally. The bridge to Markdown runs through two
|
|
14
|
+
* established libraries rather than a Tiptap Markdown plugin, which survives
|
|
15
|
+
* version bumps better:
|
|
16
|
+
* - loading: Markdown to HTML through markdown-it (`renderMarkdown`)
|
|
17
|
+
* - saving: HTML to Markdown through turndown
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
+
* The editor is created `onMounted` — ProseMirror needs a DOM, so this stays
|
|
20
|
+
* SSR-safe.
|
|
19
21
|
*/
|
|
20
22
|
const props = defineProps<{ modelValue?: string | null }>()
|
|
21
23
|
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
@@ -29,7 +31,7 @@ function toMarkdown(html: string): string {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
onMounted(() => {
|
|
32
|
-
// turndown
|
|
34
|
+
// Pull turndown in on the client — details in html-to-markdown.ts.
|
|
33
35
|
prepareHtmlToMarkdown()
|
|
34
36
|
editor.value = new Editor({
|
|
35
37
|
extensions: [
|
|
@@ -43,7 +45,7 @@ onMounted(() => {
|
|
|
43
45
|
|
|
44
46
|
onBeforeUnmount(() => editor.value?.destroy())
|
|
45
47
|
|
|
46
|
-
//
|
|
48
|
+
// Take an outside change without disturbing the cursor — only on a real diff.
|
|
47
49
|
watch(() => props.modelValue, (v) => {
|
|
48
50
|
if (!editor.value) return
|
|
49
51
|
if ((v ?? '') !== toMarkdown(editor.value.getHTML())) {
|
|
@@ -102,7 +104,7 @@ function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
|
|
102
104
|
.md-content :deep(.ProseMirror code) { font-family: ui-monospace, monospace; font-size: 0.85em; background: var(--basekit-surface-muted, #f4f6fa); padding: 0.1em 0.35em; border-radius: 4px; }
|
|
103
105
|
.md-content :deep(.ProseMirror a) { color: var(--basekit-accent, #2563eb); text-decoration: underline; }
|
|
104
106
|
|
|
105
|
-
/* Dark
|
|
107
|
+
/* Dark mode: override the hard-coded light fallbacks. */
|
|
106
108
|
:where(.dark) .md-editor { background: #101828; border-color: #1f2937; }
|
|
107
109
|
:where(.dark) .md-toolbar { background: #0b1220; border-color: #1f2937; }
|
|
108
110
|
:where(.dark) .md-toolbar button { color: #e5e7eb; }
|
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A loading state for a page or a section of one.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Pages that fetch in `onMounted` show their form immediately otherwise — with
|
|
6
|
+
* empty fields that fill themselves a blink later. Whoever types fast writes
|
|
7
|
+
* into a field that is about to be overwritten; whoever reads slowly takes the
|
|
8
|
+
* record for empty.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Hence: spinner first, content after. The spinner says "in a moment", the
|
|
11
|
+
* empty form says "nothing here" — and only one of those is true.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Two ways, both valid:
|
|
14
14
|
*
|
|
15
15
|
* <BaseKitPending :pending="pending">
|
|
16
|
-
* <UForm …> <!--
|
|
16
|
+
* <UForm …> <!-- appears once the data is in -->
|
|
17
17
|
* </BaseKitPending>
|
|
18
18
|
*
|
|
19
19
|
* <BaseKitPending v-if="pending" />
|
|
20
20
|
*
|
|
21
|
-
* `label`
|
|
22
|
-
*
|
|
23
|
-
* ein Wort, das niemand liest.
|
|
21
|
+
* Set `label` only where the operation takes a while and has a name ("Searching").
|
|
22
|
+
* On an ordinary form the bare spinner is more honest than a word nobody reads.
|
|
24
23
|
*/
|
|
25
24
|
withDefaults(defineProps<{
|
|
26
|
-
/**
|
|
25
|
+
/** While true, the spinner stands in for the content. */
|
|
27
26
|
pending?: boolean
|
|
28
|
-
/**
|
|
27
|
+
/** Optional label below the spinner. */
|
|
29
28
|
label?: string
|
|
30
29
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Height of the area. `page` for a whole page, `inline` for a section within
|
|
31
|
+
* one — otherwise the layout jumps when the content arrives.
|
|
33
32
|
*/
|
|
34
33
|
size?: 'page' | 'inline'
|
|
35
34
|
}>(), {
|