nuxt-ui-basekit 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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/app/assets/css/basekit.css +86 -0
- package/app/components/BaseKitBackLink.vue +41 -0
- package/app/components/BaseKitChoiceCard.vue +107 -0
- package/app/components/BaseKitConfirmModal.vue +55 -0
- package/app/components/BaseKitDataTable.vue +293 -0
- package/app/components/BaseKitEmptyState.vue +39 -0
- package/app/components/BaseKitFileUpload.vue +90 -0
- package/app/components/BaseKitIconPicker.vue +130 -0
- package/app/components/BaseKitMarkdownEditor.vue +112 -0
- package/app/components/BaseKitPending.vue +54 -0
- package/app/components/BaseKitRecordPicker.vue +154 -0
- package/app/components/BaseKitSettingRow.vue +66 -0
- package/app/components/BaseKitStatTile.vue +64 -0
- package/app/components/BaseKitTabs.vue +161 -0
- package/app/components/BaseKitViewLink.vue +53 -0
- package/app/components/charts/BaseKitChartBars.vue +87 -0
- package/app/components/charts/BaseKitChartColumns.vue +319 -0
- package/app/components/charts/BaseKitChartDonut.vue +175 -0
- package/app/components/charts/BaseKitChartFigure.vue +87 -0
- package/app/components/charts/BaseKitChartMeter.vue +57 -0
- package/app/composables/useBaseKit.ts +155 -0
- package/app/composables/useChartPalette.ts +89 -0
- package/app/composables/useChartWidth.ts +41 -0
- package/app/composables/useConfirm.ts +16 -0
- package/app/stores/confirm.ts +56 -0
- package/app/utils/html-to-markdown.ts +47 -0
- package/app/utils/markdown.ts +24 -0
- package/nuxt.config.ts +43 -0
- package/package.json +78 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Der einzige Draht zwischen den BaseKit-Komponenten und der Anwendung,
|
|
5
|
+
* die sie einsetzt.
|
|
6
|
+
*
|
|
7
|
+
* Die Komponenten sollen in jedem Nuxt-Projekt laufen. Sie dürfen deshalb
|
|
8
|
+
* weder `vue-i18n` aufrufen noch Übersetzungsschlüssel kennen: `t('common.search')`
|
|
9
|
+
* gibt es nur in der einen Anwendung, die diesen Schlüssel führt, und ein
|
|
10
|
+
* Paket, das solche Schlüssel voraussetzt, ist keins.
|
|
11
|
+
*
|
|
12
|
+
* Stattdessen reicht die Anwendung Sprache und Beschriftungen einmal herein —
|
|
13
|
+
* in einem Plugin, das die Werte aus ihrer eigenen Quelle zieht:
|
|
14
|
+
*
|
|
15
|
+
* // plugins/basekit.ts
|
|
16
|
+
* export default defineNuxtPlugin((nuxtApp) => {
|
|
17
|
+
* const config = computed(() => ({
|
|
18
|
+
* locale: 'de-DE',
|
|
19
|
+
* labels: { ...BASEKIT_DEFAULTS.labels, search: 'Suchen' },
|
|
20
|
+
* }))
|
|
21
|
+
* nuxtApp.vueApp.provide(baseKitKey, config)
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* Wer nichts bereitstellt, bekommt die Voreinstellungen unten. Wichtig, wenn
|
|
25
|
+
* die Werte aus einer i18n-Bibliothek kommen: `useI18n()` verlangt einen
|
|
26
|
+
* Komponenten-Setup-Kontext und wirft im Plugin. Die Instanz gehört über
|
|
27
|
+
* `nuxtApp.$i18n` geholt, und zwar erst beim Lesen des `computed`.
|
|
28
|
+
*
|
|
29
|
+
* Über `provide`/`inject`, nicht über `useNuxtApp()`: so laufen die
|
|
30
|
+
* Komponenten auch in einem Vitest-Mount ohne Nuxt-Kontext, und unter SSR
|
|
31
|
+
* teilen sich zwei Anfragen nichts.
|
|
32
|
+
*
|
|
33
|
+
* Einzelne Beschriftungen bleiben weiterhin als Prop überschreibbar — hier
|
|
34
|
+
* steht nur, was ohne Zutun herauskommt.
|
|
35
|
+
*/
|
|
36
|
+
export interface BaseKitLabels {
|
|
37
|
+
/** Suchfeld über Listen und Auswahl-Dialogen. */
|
|
38
|
+
search: string
|
|
39
|
+
/** Filter-Eintrag „ohne Einschränkung". */
|
|
40
|
+
all: string
|
|
41
|
+
select: string
|
|
42
|
+
change: string
|
|
43
|
+
edit: string
|
|
44
|
+
cancel: string
|
|
45
|
+
confirm: string
|
|
46
|
+
/** Überschrift der Rückfrage, wenn der Aufrufer keine mitgibt. */
|
|
47
|
+
confirmTitle: string
|
|
48
|
+
/** Text der Rückfrage, wenn der Aufrufer keinen mitgibt. */
|
|
49
|
+
confirmBody: string
|
|
50
|
+
/** Leere Liste — es gibt noch nichts. */
|
|
51
|
+
empty: string
|
|
52
|
+
/** Leere Liste — die Suche greift, findet aber nichts. */
|
|
53
|
+
noResults: string
|
|
54
|
+
noResultsHint: string
|
|
55
|
+
back: string
|
|
56
|
+
view: string
|
|
57
|
+
upload: string
|
|
58
|
+
perPage: string
|
|
59
|
+
iconChoose: string
|
|
60
|
+
iconEmpty: string
|
|
61
|
+
/** Auswahl aufheben — im Symbolwähler die leere Wahl. */
|
|
62
|
+
iconClear: string
|
|
63
|
+
chartAsTable: string
|
|
64
|
+
chartAsChart: string
|
|
65
|
+
/** „1–25 von 300" — als Funktion, weil die Zahlen mitten im Satz stehen. */
|
|
66
|
+
paginationRange: (range: { from: number, to: number, total: number }) => string
|
|
67
|
+
/**
|
|
68
|
+
* Werkzeugleiste des Markdown-Editors. Eigene Gruppe, weil die Begriffe nur
|
|
69
|
+
* dort vorkommen und die obere Ebene sonst zur Hälfte aus ihnen bestünde.
|
|
70
|
+
*/
|
|
71
|
+
markdown: {
|
|
72
|
+
bold: string
|
|
73
|
+
italic: string
|
|
74
|
+
h2: string
|
|
75
|
+
h3: string
|
|
76
|
+
bullet: string
|
|
77
|
+
ordered: string
|
|
78
|
+
quote: string
|
|
79
|
+
code: string
|
|
80
|
+
link: string
|
|
81
|
+
/** Abfrage beim Setzen eines Links. */
|
|
82
|
+
linkPrompt: string
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BaseKitConfig {
|
|
87
|
+
/**
|
|
88
|
+
* BCP-47-Kennung für `Intl` — steuert Tausendertrennung, Prozent- und
|
|
89
|
+
* Datumsformate in den Diagrammen.
|
|
90
|
+
*/
|
|
91
|
+
locale: string
|
|
92
|
+
labels: BaseKitLabels
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Voreinstellung: deutsch. Alle Projekte im Haus sind es, und eine Anwendung
|
|
97
|
+
* mit mehreren Sprachen überschreibt die Tabelle ohnehin komplett.
|
|
98
|
+
*/
|
|
99
|
+
export const BASEKIT_DEFAULTS: BaseKitConfig = {
|
|
100
|
+
locale: 'de-DE',
|
|
101
|
+
labels: {
|
|
102
|
+
search: 'Suchen',
|
|
103
|
+
all: 'Alle',
|
|
104
|
+
select: 'Auswählen',
|
|
105
|
+
change: 'Ändern',
|
|
106
|
+
edit: 'Bearbeiten',
|
|
107
|
+
cancel: 'Abbrechen',
|
|
108
|
+
confirm: 'Bestätigen',
|
|
109
|
+
confirmTitle: 'Sind Sie sicher?',
|
|
110
|
+
confirmBody: 'Diese Aktion lässt sich nicht rückgängig machen.',
|
|
111
|
+
empty: 'Noch nichts vorhanden',
|
|
112
|
+
noResults: 'Keine Treffer',
|
|
113
|
+
noResultsHint: 'Andere Schreibweise oder weniger Filter probieren.',
|
|
114
|
+
back: 'Zurück zur Übersicht',
|
|
115
|
+
view: 'Ansehen',
|
|
116
|
+
upload: 'Datei wählen',
|
|
117
|
+
perPage: 'pro Seite',
|
|
118
|
+
iconChoose: 'Symbol wählen',
|
|
119
|
+
iconEmpty: 'Kein Symbol gefunden',
|
|
120
|
+
iconClear: 'Kein Symbol',
|
|
121
|
+
chartAsTable: 'Als Tabelle',
|
|
122
|
+
chartAsChart: 'Als Diagramm',
|
|
123
|
+
paginationRange: ({ from, to, total }) => `${from}–${to} von ${total}`,
|
|
124
|
+
markdown: {
|
|
125
|
+
bold: 'Fett',
|
|
126
|
+
italic: 'Kursiv',
|
|
127
|
+
h2: 'Überschrift 2',
|
|
128
|
+
h3: 'Überschrift 3',
|
|
129
|
+
bullet: 'Liste',
|
|
130
|
+
ordered: 'Nummerierte Liste',
|
|
131
|
+
quote: 'Zitat',
|
|
132
|
+
code: 'Code',
|
|
133
|
+
link: 'Link',
|
|
134
|
+
linkPrompt: 'Link-URL',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const baseKitKey: InjectionKey<ComputedRef<BaseKitConfig>> = Symbol('basekit')
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Konfiguration für eine BaseKit-Komponente. Ohne bereitgestellten Wert
|
|
143
|
+
* greifen die Voreinstellungen — die Komponente rendert dann auf Deutsch,
|
|
144
|
+
* statt leere Beschriftungen zu zeigen.
|
|
145
|
+
*/
|
|
146
|
+
export function useBaseKit(): ComputedRef<BaseKitConfig> {
|
|
147
|
+
const provided = inject(baseKitKey, null)
|
|
148
|
+
return provided ?? computed(() => BASEKIT_DEFAULTS)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Kurzform für den häufigen Fall, dass nur die Beschriftungen gebraucht werden. */
|
|
152
|
+
export function useBaseKitLabels(): ComputedRef<BaseKitLabels> {
|
|
153
|
+
const config = useBaseKit()
|
|
154
|
+
return computed(() => config.value.labels)
|
|
155
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { useBaseKit } from './useBaseKit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reihenfarben und Zahlenformate für die `BaseKitChart*`-Komponenten.
|
|
6
|
+
*
|
|
7
|
+
* Die Farben stehen als CSS-Variablen in `main.css` und sind als **Satz**
|
|
8
|
+
* geprüft — Reihenfolge inklusive. Deshalb gibt es hier nur einen Zugriff per
|
|
9
|
+
* Index und keine Erzeugung: eine sechste Farbe wäre unter einer
|
|
10
|
+
* Farbfehlsichtigkeit von einer der fünf nicht mehr zu unterscheiden.
|
|
11
|
+
* Wer mehr Reihen hat, fasst zusammen.
|
|
12
|
+
*/
|
|
13
|
+
export const BASEKIT_CHART_SLOTS = 5
|
|
14
|
+
|
|
15
|
+
/** Farbe für Reihe `index` (0-basiert). Ab Slot 6 die zurückgenommene Graustufe. */
|
|
16
|
+
export function baseKitChartColor(index: number): string {
|
|
17
|
+
return index < BASEKIT_CHART_SLOTS
|
|
18
|
+
? `var(--basekit-chart-${index + 1})`
|
|
19
|
+
: 'var(--basekit-chart-muted)'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useChartFormat(): {
|
|
23
|
+
number: (value: number) => string
|
|
24
|
+
compact: (value: number) => string
|
|
25
|
+
percent: (value: number, total: number) => string
|
|
26
|
+
bytes: (value: number) => string
|
|
27
|
+
duration: (seconds: number) => string
|
|
28
|
+
} {
|
|
29
|
+
const config = useBaseKit()
|
|
30
|
+
const locale = computed(() => config.value.locale)
|
|
31
|
+
|
|
32
|
+
function number(value: number): string {
|
|
33
|
+
return new Intl.NumberFormat(locale.value).format(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Für Werte an Marken und in Kacheln: 12.400 → 12,4 Tsd. */
|
|
37
|
+
function compact(value: number): string {
|
|
38
|
+
return value < 10000
|
|
39
|
+
? number(value)
|
|
40
|
+
: new Intl.NumberFormat(locale.value, { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Anteil in Prozent. Abgerundet, solange nicht alles erreicht ist — sonst
|
|
45
|
+
* würde 9.999 von 10.000 als „100 %" durchgehen und die eine fehlende
|
|
46
|
+
* Übersetzung wäre weggerundet.
|
|
47
|
+
*/
|
|
48
|
+
function percent(value: number, total: number): string {
|
|
49
|
+
if (total <= 0) return '—'
|
|
50
|
+
if (value >= total) {
|
|
51
|
+
return new Intl.NumberFormat(locale.value, { style: 'percent', maximumFractionDigits: 0 })
|
|
52
|
+
.format(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const exact = (value / total) * 100
|
|
56
|
+
const floored = Math.floor(exact * 10) / 10
|
|
57
|
+
return new Intl.NumberFormat(locale.value, {
|
|
58
|
+
style: 'percent',
|
|
59
|
+
maximumFractionDigits: 1,
|
|
60
|
+
}).format(floored / 100)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dezimalpräfixe (kB, MB, GB) — nicht KiB/MiB. Speicheranbieter rechnen so
|
|
65
|
+
* ab, und die Zahl soll zu der auf der Rechnung passen.
|
|
66
|
+
*/
|
|
67
|
+
function bytes(value: number): string {
|
|
68
|
+
const units = ['B', 'kB', 'MB', 'GB', 'TB']
|
|
69
|
+
let size = Math.max(0, value)
|
|
70
|
+
let unit = 0
|
|
71
|
+
while (size >= 1000 && unit < units.length - 1) {
|
|
72
|
+
size /= 1000
|
|
73
|
+
unit++
|
|
74
|
+
}
|
|
75
|
+
const digits = unit === 0 ? 0 : (size < 10 ? 1 : 0)
|
|
76
|
+
return `${new Intl.NumberFormat(locale.value, { maximumFractionDigits: digits }).format(size)} ${units[unit]}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Laufzeit als „14 h 20 min", unter einer Stunde nur Minuten. */
|
|
80
|
+
function duration(seconds: number): string {
|
|
81
|
+
const total = Math.max(0, Math.round(seconds / 60))
|
|
82
|
+
const hours = Math.floor(total / 60)
|
|
83
|
+
const minutes = total % 60
|
|
84
|
+
if (hours === 0) return `${number(minutes)} min`
|
|
85
|
+
return minutes === 0 ? `${number(hours)} h` : `${number(hours)} h ${number(minutes)} min`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { number, compact, percent, bytes, duration }
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Misst die Breite eines Containers, damit ein SVG in echten Pixeln zeichnen
|
|
5
|
+
* kann statt über eine skalierte `viewBox`.
|
|
6
|
+
*
|
|
7
|
+
* Der Unterschied ist die Schrift: eine `viewBox`, die auf Containerbreite
|
|
8
|
+
* hochgerechnet wird, zieht Achsenbeschriftung und Werte mit hoch — auf einer
|
|
9
|
+
* breiten Karte steht dann 15-px-Text, wo 11 gemeint waren. Mit gemessener
|
|
10
|
+
* Breite bleibt Text Text und nur die Geometrie wächst.
|
|
11
|
+
*
|
|
12
|
+
* Vor dem Mounten (SSR, erster Frame) gilt `fallback`. Das Diagramm ist damit
|
|
13
|
+
* sofort da und rückt einmal zurecht, statt zu flackern.
|
|
14
|
+
*/
|
|
15
|
+
export function useChartWidth(fallback = 640): {
|
|
16
|
+
el: Ref<HTMLElement | null>
|
|
17
|
+
width: Ref<number>
|
|
18
|
+
} {
|
|
19
|
+
const el = ref<HTMLElement | null>(null)
|
|
20
|
+
const width = ref(fallback)
|
|
21
|
+
let observer: ResizeObserver | null = null
|
|
22
|
+
|
|
23
|
+
onMounted(() => {
|
|
24
|
+
if (!el.value || typeof ResizeObserver === 'undefined') return
|
|
25
|
+
|
|
26
|
+
observer = new ResizeObserver((entries) => {
|
|
27
|
+
const measured = entries[0]?.contentRect.width ?? 0
|
|
28
|
+
// Unter 240 px wird die Geometrie unbrauchbar; dann lieber schmal
|
|
29
|
+
// zeichnen und den Container scrollen lassen.
|
|
30
|
+
if (measured > 0) width.value = Math.max(240, Math.round(measured))
|
|
31
|
+
})
|
|
32
|
+
observer.observe(el.value)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
onBeforeUnmount(() => {
|
|
36
|
+
observer?.disconnect()
|
|
37
|
+
observer = null
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return { el, width }
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useConfirmStore } from '../stores/confirm'
|
|
2
|
+
import type { BaseKitConfirmOptions } from '../stores/confirm'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bestätigungsabfrage als Ersatz für natives `window.confirm`.
|
|
6
|
+
*
|
|
7
|
+
* Öffnet das global gemountete `BaseKitConfirmModal` und liefert ein Promise,
|
|
8
|
+
* das mit `true`/`false` auflöst.
|
|
9
|
+
*
|
|
10
|
+
* const confirm = useConfirm()
|
|
11
|
+
* if (!(await confirm({ description: t('…'), color: 'error', confirmLabel: t('common.delete') }))) return
|
|
12
|
+
*/
|
|
13
|
+
export function useConfirm(): (opts?: BaseKitConfirmOptions) => Promise<boolean> {
|
|
14
|
+
const store = useConfirmStore()
|
|
15
|
+
return (opts?: BaseKitConfirmOptions) => store.ask(opts)
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** Optionen für eine Bestätigungsabfrage. */
|
|
5
|
+
export interface BaseKitConfirmOptions {
|
|
6
|
+
/** Titel des Dialogs (Default: `common.confirm.title`). */
|
|
7
|
+
title?: string
|
|
8
|
+
/** Erklärtext / Konsequenz — bei Destruktivem ausformulieren. */
|
|
9
|
+
description?: string
|
|
10
|
+
/** Beschriftung des Bestätigen-Buttons (Default: `common.confirm.confirm`). */
|
|
11
|
+
confirmLabel?: string
|
|
12
|
+
/** Beschriftung des Abbrechen-Buttons (Default: `common.cancel`). */
|
|
13
|
+
cancelLabel?: string
|
|
14
|
+
/** Farbe des Bestätigen-Buttons — `error` für destruktive Aktionen. */
|
|
15
|
+
color?: 'error' | 'primary'
|
|
16
|
+
/** Optionales Icon am Bestätigen-Button. */
|
|
17
|
+
icon?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Zentraler Bestätigungs-Store — ersetzt native `window.confirm`-Dialoge durch
|
|
22
|
+
* eine gethemte, dark-mode-fähige Abfrage.
|
|
23
|
+
*
|
|
24
|
+
* `ask()` öffnet das global gemountete `BaseKitConfirmModal` und liefert ein
|
|
25
|
+
* Promise, das mit `true` (bestätigt) oder `false` (abgebrochen/geschlossen)
|
|
26
|
+
* auflöst. Der Resolver lebt außerhalb der Reaktivität, damit genau eine
|
|
27
|
+
* Antwort pro Abfrage zurückgeht.
|
|
28
|
+
*
|
|
29
|
+
* Genutzt über das Composable `useConfirm()`:
|
|
30
|
+
* const confirm = useConfirm()
|
|
31
|
+
* if (!(await confirm({ description: t('…'), color: 'error' }))) return
|
|
32
|
+
*/
|
|
33
|
+
export const useConfirmStore = defineStore('basekit:confirm', () => {
|
|
34
|
+
const open = ref(false)
|
|
35
|
+
const options = ref<BaseKitConfirmOptions>({})
|
|
36
|
+
let resolver: ((value: boolean) => void) | null = null
|
|
37
|
+
|
|
38
|
+
function ask(opts: BaseKitConfirmOptions = {}): Promise<boolean> {
|
|
39
|
+
// Läuft noch eine Abfrage, wird sie als abgebrochen aufgelöst.
|
|
40
|
+
resolver?.(false)
|
|
41
|
+
options.value = opts
|
|
42
|
+
open.value = true
|
|
43
|
+
return new Promise<boolean>((resolve) => {
|
|
44
|
+
resolver = resolve
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Abfrage beantworten und Dialog schließen. */
|
|
49
|
+
function settle(value: boolean): void {
|
|
50
|
+
open.value = false
|
|
51
|
+
resolver?.(value)
|
|
52
|
+
resolver = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { open, options, ask, settle }
|
|
56
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML → Markdown (turndown). Zwei Einsätze, beide nur im Admin/Editor — daher
|
|
3
|
+
* bewusst getrennt von `markdown.ts` (das im öffentlichen Widget-Bundle steckt
|
|
4
|
+
* und turndown nicht mitziehen soll):
|
|
5
|
+
* - Tiptap speichert HTML → hier nach Markdown serialisieren.
|
|
6
|
+
* - Legacy-Text-Blöcke mit rohem `config.html` einmalig nach Markdown wandeln.
|
|
7
|
+
*
|
|
8
|
+
* **turndown wird bewusst erst beim ersten Aufruf geladen.** Das Paket ist
|
|
9
|
+
* CommonJS; ein Import auf Modulebene landet im Server-Bundle und wirft dort
|
|
10
|
+
* beim Rendern „require is not defined in ES module scope". Der Editor läuft
|
|
11
|
+
* ohnehin nur im Browser — auf dem Server wird die Funktion nie aufgerufen.
|
|
12
|
+
*/
|
|
13
|
+
type Turndown = { turndown: (html: string) => string }
|
|
14
|
+
|
|
15
|
+
let instance: Turndown | null = null
|
|
16
|
+
|
|
17
|
+
function service(): Turndown | null {
|
|
18
|
+
if (instance) return instance
|
|
19
|
+
if (import.meta.server) return null
|
|
20
|
+
|
|
21
|
+
// Synchroner Zugriff auf ein bereits geladenes Modul: der Editor ruft
|
|
22
|
+
// `prepare()` beim Einhängen auf, bevor gespeichert werden kann.
|
|
23
|
+
return instance
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lädt turndown im Browser vor. Die Editor-Komponenten rufen das beim
|
|
28
|
+
* Einhängen auf, damit {@link htmlToMarkdown} danach synchron bleiben kann.
|
|
29
|
+
*/
|
|
30
|
+
export async function prepareHtmlToMarkdown(): Promise<void> {
|
|
31
|
+
if (instance || import.meta.server) return
|
|
32
|
+
const { default: TurndownService } = await import('turndown')
|
|
33
|
+
instance = new TurndownService({
|
|
34
|
+
headingStyle: 'atx',
|
|
35
|
+
codeBlockStyle: 'fenced',
|
|
36
|
+
bulletListMarker: '-',
|
|
37
|
+
}) as Turndown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function htmlToMarkdown(html?: string | null): string {
|
|
41
|
+
if (!html) return ''
|
|
42
|
+
const td = service()
|
|
43
|
+
// Ohne geladenes turndown (Server, oder prepare() vergessen) lieber das
|
|
44
|
+
// Original zurückgeben als eine Ausnahme zu werfen — der Text ginge sonst
|
|
45
|
+
// beim Speichern verloren.
|
|
46
|
+
return td ? td.turndown(String(html)).trim() : String(html)
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown → HTML für den Text-Block (`richtext`-Widget, jetzt Markdown).
|
|
5
|
+
*
|
|
6
|
+
* Bewusst `html: false`: roher HTML im Markdown wird NICHT durchgereicht — das
|
|
7
|
+
* schließt die wichtigste XSS-Lücke ohne separaten Sanitizer (markdown-it
|
|
8
|
+
* blockt zudem `javascript:`-Links per Default-`validateLink`). `linkify` macht
|
|
9
|
+
* nackte URLs klickbar, `typographer` glättet Anführungszeichen/Bindestriche.
|
|
10
|
+
*
|
|
11
|
+
* Geteilt zwischen Anzeige und Editor-Vorschau, damit
|
|
12
|
+
* Bearbeiten und Ausgabe identisch aussehen.
|
|
13
|
+
*/
|
|
14
|
+
const md: MarkdownIt = new MarkdownIt({
|
|
15
|
+
html: false,
|
|
16
|
+
linkify: true,
|
|
17
|
+
typographer: true,
|
|
18
|
+
breaks: false,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export function renderMarkdown(source?: string | null): string {
|
|
22
|
+
if (!source) return ''
|
|
23
|
+
return md.render(String(source))
|
|
24
|
+
}
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BaseKit als Nuxt-Layer.
|
|
5
|
+
*
|
|
6
|
+
* Der Layer registriert die Komponenten global und meldet die Composables und
|
|
7
|
+
* den Store für die Auto-Imports an. Er bringt bewusst **kein** UI-Modul mit:
|
|
8
|
+
* `@nuxt/ui` und `pinia` sind Peers, weil sie im Konsumenten ohnehin stehen
|
|
9
|
+
* und zwei Instanzen desselben Moduls sich in die Quere kommen.
|
|
10
|
+
*
|
|
11
|
+
* Die CSS-Datei trägt die Voreinstellungen der `--basekit-*`-Token. Wer eigene
|
|
12
|
+
* Farben führt, überschreibt sie in seinem eigenen Stylesheet — das nach
|
|
13
|
+
* diesem geladen wird.
|
|
14
|
+
*/
|
|
15
|
+
export default defineNuxtConfig({
|
|
16
|
+
$meta: {
|
|
17
|
+
name: 'nuxt-ui-basekit',
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
css: ['nuxt-ui-basekit/app/assets/css/basekit.css'],
|
|
21
|
+
|
|
22
|
+
// Ohne Pfad-Präfix registrieren: `app/components/charts/BaseKitChartDonut.vue`
|
|
23
|
+
// wird zu `<BaseKitChartDonut>`, nicht `<ChartsBaseKitChartDonut>`.
|
|
24
|
+
// Absoluter Pfad, weil `~/components` im Konsumenten auf dessen Ordner
|
|
25
|
+
// zeigen würde.
|
|
26
|
+
components: [
|
|
27
|
+
{
|
|
28
|
+
path: fileURLToPath(new URL('./app/components', import.meta.url)),
|
|
29
|
+
pathPrefix: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
|
|
33
|
+
imports: {
|
|
34
|
+
dirs: [
|
|
35
|
+
fileURLToPath(new URL('./app/composables', import.meta.url)),
|
|
36
|
+
fileURLToPath(new URL('./app/stores', import.meta.url)),
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
alias: {
|
|
41
|
+
'nuxt-ui-basekit': fileURLToPath(new URL('./', import.meta.url)),
|
|
42
|
+
},
|
|
43
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nuxt-ui-basekit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Markenfreie Basis-Komponenten für Nuxt 4 auf Nuxt UI — Tabellen, Reiter, Diagramme, Auswahl und Leerzustände als Nuxt-Layer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nuxt",
|
|
7
|
+
"nuxt-layer",
|
|
8
|
+
"nuxt-ui",
|
|
9
|
+
"vue",
|
|
10
|
+
"components",
|
|
11
|
+
"charts",
|
|
12
|
+
"data-table"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24"
|
|
17
|
+
},
|
|
18
|
+
"main": "./nuxt.config.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"app",
|
|
21
|
+
"nuxt.config.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./nuxt.config.ts",
|
|
28
|
+
"import": "./nuxt.config.ts"
|
|
29
|
+
},
|
|
30
|
+
"./labels": "./app/composables/useBaseKit.ts",
|
|
31
|
+
"./app/*.vue": "./app/*.vue",
|
|
32
|
+
"./app/*.css": "./app/*.css",
|
|
33
|
+
"./app/*": "./app/*.ts",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev:prepare": "nuxi prepare",
|
|
38
|
+
"lint": "nuxi typecheck",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@nuxt/ui": "^4.0.0",
|
|
44
|
+
"nuxt": "^4.0.0",
|
|
45
|
+
"pinia": "^2.2.0 || ^3.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@tiptap/extension-link": "^3.27.0",
|
|
49
|
+
"@tiptap/pm": "^3.27.0",
|
|
50
|
+
"@tiptap/starter-kit": "^3.27.0",
|
|
51
|
+
"@tiptap/vue-3": "^3.27.0",
|
|
52
|
+
"markdown-it": "^14.2.0",
|
|
53
|
+
"turndown": "^7.2.4"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@nuxt/ui": "^4.7.1",
|
|
57
|
+
"@types/markdown-it": "^14.1.2",
|
|
58
|
+
"@types/turndown": "^5.0.6",
|
|
59
|
+
"@vitejs/plugin-vue": "^6.0.6",
|
|
60
|
+
"@vue/test-utils": "^2.4.10",
|
|
61
|
+
"happy-dom": "^20.9.0",
|
|
62
|
+
"nuxt": "^4.0.0",
|
|
63
|
+
"pinia": "^2.2.4",
|
|
64
|
+
"typescript": "^5.5.0",
|
|
65
|
+
"vitest": "^4.1.6",
|
|
66
|
+
"vue-tsc": "^2.1.0"
|
|
67
|
+
},
|
|
68
|
+
"repository": {
|
|
69
|
+
"type": "git",
|
|
70
|
+
"url": "git+https://github.com/McGo/nuxt-ui-basekit.git"
|
|
71
|
+
},
|
|
72
|
+
"homepage": "https://github.com/McGo/nuxt-ui-basekit#readme",
|
|
73
|
+
"bugs": {
|
|
74
|
+
"url": "https://github.com/McGo/nuxt-ui-basekit/issues"
|
|
75
|
+
},
|
|
76
|
+
"author": "Mirko Haaser <kontakt@mirko-haaser.de>",
|
|
77
|
+
"license": "MIT"
|
|
78
|
+
}
|