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.
Files changed (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/app/assets/css/basekit.css +86 -0
  4. package/app/components/BaseKitBackLink.vue +41 -0
  5. package/app/components/BaseKitChoiceCard.vue +107 -0
  6. package/app/components/BaseKitConfirmModal.vue +55 -0
  7. package/app/components/BaseKitDataTable.vue +293 -0
  8. package/app/components/BaseKitEmptyState.vue +39 -0
  9. package/app/components/BaseKitFileUpload.vue +90 -0
  10. package/app/components/BaseKitIconPicker.vue +130 -0
  11. package/app/components/BaseKitMarkdownEditor.vue +112 -0
  12. package/app/components/BaseKitPending.vue +54 -0
  13. package/app/components/BaseKitRecordPicker.vue +154 -0
  14. package/app/components/BaseKitSettingRow.vue +66 -0
  15. package/app/components/BaseKitStatTile.vue +64 -0
  16. package/app/components/BaseKitTabs.vue +161 -0
  17. package/app/components/BaseKitViewLink.vue +53 -0
  18. package/app/components/charts/BaseKitChartBars.vue +87 -0
  19. package/app/components/charts/BaseKitChartColumns.vue +319 -0
  20. package/app/components/charts/BaseKitChartDonut.vue +175 -0
  21. package/app/components/charts/BaseKitChartFigure.vue +87 -0
  22. package/app/components/charts/BaseKitChartMeter.vue +57 -0
  23. package/app/composables/useBaseKit.ts +155 -0
  24. package/app/composables/useChartPalette.ts +89 -0
  25. package/app/composables/useChartWidth.ts +41 -0
  26. package/app/composables/useConfirm.ts +16 -0
  27. package/app/stores/confirm.ts +56 -0
  28. package/app/utils/html-to-markdown.ts +47 -0
  29. package/app/utils/markdown.ts +24 -0
  30. package/nuxt.config.ts +43 -0
  31. package/package.json +78 -0
@@ -0,0 +1,293 @@
1
+ <script setup lang="ts" generic="T extends object">
2
+ import { computed, ref, useSlots, watch } from 'vue'
3
+ import { useBaseKitLabels } from '../composables/useBaseKit'
4
+ import { NuxtLink } from '#components'
5
+
6
+ /**
7
+ * Wiederverwendbare Admin-Tabelle: sortierbare Spalten, Textfilter und ein
8
+ * „Anlegen"-Button oben rechts. Client-seitig (Daten werden übergeben).
9
+ *
10
+ * - `columns` definiert Spalten; `sortable` macht den Kopf klickbar.
11
+ * - Zellen rendern per Default `row[key]`; überschreibbar via Slot
12
+ * `#cell-<key>="{ row, value }"`.
13
+ * - Zeilen-Aktionen über den Slot `#actions="{ row }"` (rechte Spalte).
14
+ * - `row-link` macht die Namensspalte anklickbar: die Funktion bekommt die
15
+ * Zeile und gibt ihr Ziel zurück (oder `null`, wenn diese Zeile keins hat).
16
+ * Welche Spalte den Link trägt, sagt `link-column` — ohne Angabe die erste.
17
+ * - Zusätzliche Filter über den Slot `#toolbar` (rechts neben der Suche).
18
+ * - „Anlegen": `create-label` + `@create` rendert den Button oben rechts.
19
+ * - Paginierung client-seitig: Seitengröße über `page-size` /
20
+ * `page-size-options` (Default 25; Auswahl 10/25/50/100/250/Alle).
21
+ *
22
+ * Generisch über den Zeilentyp `T` — Slots liefern `row` typisiert zurück.
23
+ */
24
+ export interface BaseKitDataColumn {
25
+ key: string
26
+ label: string
27
+ sortable?: boolean
28
+ align?: 'left' | 'right'
29
+ class?: string
30
+ }
31
+
32
+ /** Seitengröße: feste Zeilenzahl oder `'all'` für „ohne Limit". */
33
+ export type BaseKitPageSize = number | 'all'
34
+
35
+ const props = withDefaults(defineProps<{
36
+ columns: BaseKitDataColumn[]
37
+ rows: T[]
38
+ rowKey?: string
39
+ searchable?: boolean
40
+ /** Felder, die die Suche durchsucht (Default: alle Spalten-Keys). */
41
+ searchKeys?: string[]
42
+ searchPlaceholder?: string
43
+ createLabel?: string
44
+ loading?: boolean
45
+ emptyLabel?: string
46
+ /** Anfangs gewählte Seitengröße. */
47
+ pageSize?: BaseKitPageSize
48
+ /** Auswahlmöglichkeiten für die Seitengröße. */
49
+ pageSizeOptions?: BaseKitPageSize[]
50
+ /**
51
+ * Ziel je Zeile. Gesetzt, macht es den Namen anklickbar — der Weg, den man
52
+ * zuerst probiert. Die Aktionsspalte bleibt trotzdem: sie zeigt, was es
53
+ * außer „öffnen" noch gibt.
54
+ */
55
+ rowLink?: (row: T) => string | null | undefined
56
+ /** Spalte, die den Link trägt. Ohne Angabe die erste. */
57
+ linkColumn?: string
58
+ }>(), {
59
+ rowKey: 'id',
60
+ searchable: true,
61
+ loading: false,
62
+ pageSize: 25,
63
+ pageSizeOptions: () => [10, 25, 50, 100, 250, 'all'],
64
+ })
65
+
66
+ const emit = defineEmits<{ create: [] }>()
67
+
68
+ const labels = useBaseKitLabels()
69
+ const slots = useSlots()
70
+ const hasActions = computed(() => !!slots.actions)
71
+
72
+ const search = ref('')
73
+ const sortKey = ref<string | null>(null)
74
+ const sortDir = ref<'asc' | 'desc'>('asc')
75
+
76
+ const searchFields = computed(() => props.searchKeys ?? props.columns.map(c => c.key))
77
+
78
+ /** Die verlinkte Spalte — explizit gesetzt oder die erste. */
79
+ const linkKey = computed(() => props.linkColumn ?? props.columns[0]?.key ?? null)
80
+
81
+ function rowTarget(row: T, key: string): string | null {
82
+ if (!props.rowLink || key !== linkKey.value) return null
83
+ const target = props.rowLink(row)
84
+ return target || null
85
+ }
86
+
87
+ /** Wert einer Zelle — internes String-Indexing über den generischen Zeilentyp. */
88
+ function cell(row: T, key: string): unknown {
89
+ return (row as Record<string, unknown>)[key]
90
+ }
91
+
92
+ const filtered = computed<T[]>(() => {
93
+ const q = search.value.trim().toLowerCase()
94
+ if (!q) return props.rows
95
+ return props.rows.filter(row =>
96
+ searchFields.value.some((key) => {
97
+ const v = cell(row, key)
98
+ return v != null && String(v).toLowerCase().includes(q)
99
+ }),
100
+ )
101
+ })
102
+
103
+ const displayed = computed<T[]>(() => {
104
+ if (!sortKey.value) return filtered.value
105
+ const key = sortKey.value
106
+ const dir = sortDir.value === 'asc' ? 1 : -1
107
+ return [...filtered.value].sort((a, b) => compare(cell(a, key), cell(b, key)) * dir)
108
+ })
109
+
110
+ // — Paginierung ------------------------------------------------------------
111
+ const page = ref(1)
112
+ const pageSize = ref<BaseKitPageSize>(props.pageSize)
113
+
114
+ const total = computed(() => displayed.value.length)
115
+ const totalPages = computed(() =>
116
+ pageSize.value === 'all' ? 1 : Math.max(1, Math.ceil(total.value / pageSize.value)),
117
+ )
118
+
119
+ const paged = computed<T[]>(() => {
120
+ if (pageSize.value === 'all') return displayed.value
121
+ const start = (page.value - 1) * pageSize.value
122
+ return displayed.value.slice(start, start + pageSize.value)
123
+ })
124
+
125
+ const rangeFrom = computed(() =>
126
+ total.value === 0 ? 0 : pageSize.value === 'all' ? 1 : (page.value - 1) * pageSize.value + 1,
127
+ )
128
+ const rangeTo = computed(() =>
129
+ pageSize.value === 'all' ? total.value : Math.min(page.value * pageSize.value, total.value),
130
+ )
131
+
132
+ const pageSizeItems = computed(() =>
133
+ props.pageSizeOptions.map(opt => ({
134
+ label: opt === 'all' ? labels.value.all : String(opt),
135
+ value: opt,
136
+ })),
137
+ )
138
+
139
+ // Suche oder Seitengröße geändert → zurück auf Seite 1.
140
+ watch([search, pageSize], () => {
141
+ page.value = 1
142
+ })
143
+
144
+ // Datenbestand geschrumpft (z. B. Filter) → Seite in gültigen Bereich klemmen.
145
+ watch(totalPages, (pages) => {
146
+ if (page.value > pages) page.value = pages
147
+ })
148
+
149
+ function compare(a: unknown, b: unknown): number {
150
+ if (a == null && b == null) return 0
151
+ if (a == null) return -1
152
+ if (b == null) return 1
153
+ if (typeof a === 'number' && typeof b === 'number') return a - b
154
+ return String(a).localeCompare(String(b))
155
+ }
156
+
157
+ function toggleSort(col: BaseKitDataColumn): void {
158
+ if (!col.sortable) return
159
+ if (sortKey.value !== col.key) {
160
+ sortKey.value = col.key
161
+ sortDir.value = 'asc'
162
+ }
163
+ else if (sortDir.value === 'asc') {
164
+ sortDir.value = 'desc'
165
+ }
166
+ else {
167
+ sortKey.value = null // dritter Klick: Sortierung aus
168
+ }
169
+ }
170
+
171
+ function sortIcon(col: BaseKitDataColumn): string | null {
172
+ if (sortKey.value !== col.key) return null
173
+ return sortDir.value === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
174
+ }
175
+ </script>
176
+
177
+ <template>
178
+ <div class="space-y-4">
179
+ <!-- Toolbar: Suche links, Filter/Anlegen rechts -->
180
+ <div v-if="searchable || $slots.toolbar || createLabel" class="flex flex-wrap items-center justify-between gap-3">
181
+ <UInput
182
+ v-if="searchable"
183
+ v-model="search"
184
+ icon="i-lucide-search"
185
+ :placeholder="searchPlaceholder ?? labels.search"
186
+ class="w-72 max-w-full"
187
+ />
188
+ <span v-else />
189
+
190
+ <div class="flex flex-wrap items-center gap-2">
191
+ <slot name="toolbar" />
192
+ <UButton v-if="createLabel" color="primary" icon="i-lucide-plus" @click="emit('create')">
193
+ {{ createLabel }}
194
+ </UButton>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Zustände -->
199
+ <div v-if="loading" class="py-12 text-center text-muted">
200
+ <UIcon name="i-lucide-loader-2" class="size-6 animate-spin" />
201
+ </div>
202
+ <div v-else-if="!displayed.length">
203
+ <slot name="empty">
204
+ <BaseKitEmptyState
205
+ :variant="search.trim() ? 'search' : 'empty'"
206
+ :title="search.trim() ? labels.noResults : (emptyLabel ?? labels.empty)"
207
+ :description="search.trim() ? labels.noResultsHint : undefined"
208
+ />
209
+ </slot>
210
+ </div>
211
+
212
+ <!-- Tabelle -->
213
+ <table v-else class="w-full text-sm">
214
+ <thead class="border-b border-neutral-200 text-left text-muted dark:border-neutral-800">
215
+ <tr>
216
+ <th
217
+ v-for="col in columns"
218
+ :key="col.key"
219
+ class="py-2 font-medium"
220
+ :class="[col.align === 'right' ? 'text-right' : '', col.class]"
221
+ :aria-sort="col.sortable ? (sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined"
222
+ >
223
+ <button
224
+ v-if="col.sortable"
225
+ type="button"
226
+ class="inline-flex cursor-pointer select-none items-center gap-1"
227
+ :class="col.align === 'right' ? 'justify-end' : ''"
228
+ @click="toggleSort(col)"
229
+ >
230
+ {{ col.label }}
231
+ <UIcon v-if="sortIcon(col)" :name="sortIcon(col)!" class="size-3.5" />
232
+ </button>
233
+ <span v-else class="inline-flex items-center gap-1" :class="col.align === 'right' ? 'justify-end' : ''">
234
+ {{ col.label }}
235
+ </span>
236
+ </th>
237
+ <th v-if="hasActions" class="py-2 text-right font-medium" />
238
+ </tr>
239
+ </thead>
240
+ <tbody class="divide-y divide-neutral-100 dark:divide-neutral-800">
241
+ <tr
242
+ v-for="row in paged"
243
+ :key="String(cell(row, rowKey))"
244
+ class="hover:bg-neutral-50 dark:hover:bg-neutral-900/50"
245
+ >
246
+ <td
247
+ v-for="col in columns"
248
+ :key="col.key"
249
+ class="py-2"
250
+ :class="[col.align === 'right' ? 'text-right' : '', col.class]"
251
+ >
252
+ <component
253
+ :is="rowTarget(row, col.key) ? NuxtLink : 'span'"
254
+ :to="rowTarget(row, col.key) ?? undefined"
255
+ :class="rowTarget(row, col.key) ? 'font-medium hover:text-primary-700 dark:hover:text-primary-300' : undefined"
256
+ >
257
+ <slot :name="`cell-${col.key}`" :row="row" :value="cell(row, col.key)">
258
+ {{ cell(row, col.key) ?? '—' }}
259
+ </slot>
260
+ </component>
261
+ </td>
262
+ <td v-if="hasActions" class="py-2 text-right">
263
+ <div class="flex justify-end gap-2">
264
+ <slot name="actions" :row="row" />
265
+ </div>
266
+ </td>
267
+ </tr>
268
+ </tbody>
269
+ </table>
270
+
271
+ <!-- Fußzeile: Seitengröße links, Bereich + Blättern rechts -->
272
+ <div
273
+ v-if="!loading && displayed.length"
274
+ class="flex flex-wrap items-center justify-between gap-3 pt-1 text-sm text-muted"
275
+ >
276
+ <div class="flex items-center gap-2">
277
+ <span>{{ labels.perPage }}</span>
278
+ <USelect v-model="pageSize" :items="pageSizeItems" size="sm" class="w-24" />
279
+ </div>
280
+ <div class="flex items-center gap-3">
281
+ <span>{{ labels.paginationRange({ from: rangeFrom, to: rangeTo, total }) }}</span>
282
+ <UPagination
283
+ v-if="totalPages > 1"
284
+ v-model:page="page"
285
+ :total="total"
286
+ :items-per-page="pageSize === 'all' ? total : pageSize"
287
+ :sibling-count="1"
288
+ size="sm"
289
+ />
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * BaseKitEmptyState — einheitlicher Leerzustand: Icon + handlungsorientierte
4
+ * Überschrift + optionaler Erklärtext + optionale Primäraktion (Default-Slot).
5
+ *
6
+ * `variant` trennt die beiden Fälle aus docs/18:
7
+ * - `empty` → „noch nichts angelegt" (führt zum ersten Schritt)
8
+ * - `search` → „kein Suchergebnis" (bestätigt die Suche, bietet Korrektur)
9
+ *
10
+ * Das passende Icon wird je Variante gewählt, kann per `icon` überschrieben
11
+ * werden. Aktion (z. B. ein `UButton`) kommt in den Default-Slot.
12
+ */
13
+ withDefaults(defineProps<{
14
+ title: string
15
+ description?: string
16
+ icon?: string
17
+ variant?: 'empty' | 'search'
18
+ }>(), {
19
+ variant: 'empty',
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <div class="flex flex-col items-center gap-3 py-12 text-center">
25
+ <UIcon
26
+ :name="icon ?? (variant === 'search' ? 'i-lucide-search-x' : 'i-lucide-inbox')"
27
+ class="size-10 text-dimmed"
28
+ />
29
+ <div>
30
+ <p class="font-medium text-highlighted">
31
+ {{ title }}
32
+ </p>
33
+ <p v-if="description" class="mx-auto mt-1 max-w-sm text-sm text-muted">
34
+ {{ description }}
35
+ </p>
36
+ </div>
37
+ <slot />
38
+ </div>
39
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useBaseKitLabels } from '../composables/useBaseKit'
4
+
5
+ /**
6
+ * Datei-Auswahl per Klick oder Drag & Drop. Reicht die ausgewählten Dateien
7
+ * über `select` nach oben — kümmert sich NICHT selbst um den Upload (das macht
8
+ * der Aufrufer, je nach Bild/Video unterschiedlich).
9
+ */
10
+ const props = withDefaults(defineProps<{
11
+ accept?: string
12
+ multiple?: boolean
13
+ label?: string
14
+ disabled?: boolean
15
+ }>(), {
16
+ accept: '*/*',
17
+ multiple: false,
18
+ disabled: false,
19
+ })
20
+
21
+ const emit = defineEmits<{ select: [files: File[]] }>()
22
+
23
+ const labels = useBaseKitLabels()
24
+ const input = ref<HTMLInputElement | null>(null)
25
+ const dragging = ref(false)
26
+
27
+ function pick(): void {
28
+ if (!props.disabled) input.value?.click()
29
+ }
30
+
31
+ function onChange(event: Event): void {
32
+ const files = Array.from((event.target as HTMLInputElement).files ?? [])
33
+ if (files.length) emit('select', files)
34
+ // Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
35
+ if (input.value) input.value.value = ''
36
+ }
37
+
38
+ function onDrop(event: DragEvent): void {
39
+ dragging.value = false
40
+ if (props.disabled) return
41
+ const files = Array.from(event.dataTransfer?.files ?? [])
42
+ if (files.length) emit('select', props.multiple ? files : files.slice(0, 1))
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <div
48
+ class="basekit-upload"
49
+ :class="{ 'basekit-upload--drag': dragging, 'basekit-upload--disabled': disabled }"
50
+ role="button"
51
+ tabindex="0"
52
+ @click="pick"
53
+ @keydown.enter.prevent="pick"
54
+ @keydown.space.prevent="pick"
55
+ @dragover.prevent="dragging = true"
56
+ @dragleave.prevent="dragging = false"
57
+ @drop.prevent="onDrop"
58
+ >
59
+ <input
60
+ ref="input"
61
+ type="file"
62
+ class="basekit-upload__input"
63
+ :accept="accept"
64
+ :multiple="multiple"
65
+ :disabled="disabled"
66
+ @change="onChange"
67
+ >
68
+ <UIcon name="i-lucide-upload-cloud" class="basekit-upload__icon" />
69
+ <span class="basekit-upload__label">{{ label ?? labels.upload }}</span>
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .basekit-upload {
75
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
76
+ gap: 6px; min-height: 110px; padding: 16px; cursor: pointer; text-align: center;
77
+ border: 1px dashed var(--basekit-border-strong, #d6dbe4); border-radius: 12px;
78
+ color: var(--basekit-text-muted, #667085); background: var(--basekit-surface, #fff);
79
+ transition: border-color .12s, background .12s;
80
+ }
81
+ .basekit-upload:hover, .basekit-upload--drag { border-color: var(--basekit-accent, #2563eb); background: var(--basekit-surface-muted, #f7f9fc); }
82
+ .basekit-upload--disabled { opacity: .5; cursor: not-allowed; }
83
+ .basekit-upload__input { display: none; }
84
+ .basekit-upload__icon { font-size: 1.5rem; }
85
+ .basekit-upload__label { font-size: 0.8125rem; font-weight: 500; }
86
+
87
+ /* Dark-Mode: die Light-Fallbacks des Dropfelds überschreiben. */
88
+ :where(.dark) .basekit-upload { border-color: #1f2937; background: #0f172a; color: #9ca3af; }
89
+ :where(.dark) .basekit-upload:hover, :where(.dark) .basekit-upload--drag { background: #1f2937; }
90
+ </style>
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useBaseKitLabels } from '../composables/useBaseKit'
4
+
5
+ /**
6
+ * Icon-Auswahl als Form-Element für `i-lucide-*`-Namen — reine Anzeige + Picker,
7
+ * kein Freitext. Der Trigger zeigt das gewählte Icon (oder einen Platzhalter);
8
+ * ein Klick öffnet ein Popover mit Suche und einem Raster kuratierter Icons.
9
+ * `modelValue` ist der Icon-Name; leerer String bedeutet „kein Icon".
10
+ *
11
+ * Bewusst kuratiert statt des kompletten Iconify-Satzes — das hält den Bundle
12
+ * klein und braucht keine zusätzliche Datenquelle. Fehlt mal ein Icon, wird die
13
+ * Liste unten einfach ergänzt.
14
+ */
15
+ const props = defineProps<{ modelValue: string }>()
16
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
17
+
18
+ const labels = useBaseKitLabels()
19
+
20
+ const open = ref(false)
21
+ const search = ref('')
22
+
23
+ // Kuratierte, für Navigation/Inhalte typische Lucide-Icons.
24
+ const ICONS: string[] = [
25
+ 'i-lucide-home', 'i-lucide-layout-dashboard', 'i-lucide-layout-grid', 'i-lucide-list',
26
+ 'i-lucide-file-text', 'i-lucide-file', 'i-lucide-files', 'i-lucide-folder',
27
+ 'i-lucide-folder-open', 'i-lucide-newspaper', 'i-lucide-book-open', 'i-lucide-book',
28
+ 'i-lucide-graduation-cap', 'i-lucide-library', 'i-lucide-calendar', 'i-lucide-calendar-days',
29
+ 'i-lucide-clock', 'i-lucide-image', 'i-lucide-images', 'i-lucide-video',
30
+ 'i-lucide-film', 'i-lucide-music', 'i-lucide-play', 'i-lucide-camera',
31
+ 'i-lucide-mic', 'i-lucide-headphones', 'i-lucide-users', 'i-lucide-user',
32
+ 'i-lucide-user-round', 'i-lucide-contact', 'i-lucide-mail', 'i-lucide-phone',
33
+ 'i-lucide-map-pin', 'i-lucide-map', 'i-lucide-compass', 'i-lucide-globe',
34
+ 'i-lucide-info', 'i-lucide-help-circle', 'i-lucide-circle-help', 'i-lucide-settings',
35
+ 'i-lucide-cog', 'i-lucide-wrench', 'i-lucide-hammer', 'i-lucide-plug',
36
+ 'i-lucide-shield', 'i-lucide-lock', 'i-lucide-key', 'i-lucide-star',
37
+ 'i-lucide-heart', 'i-lucide-bookmark', 'i-lucide-tag', 'i-lucide-tags',
38
+ 'i-lucide-flag', 'i-lucide-award', 'i-lucide-trophy', 'i-lucide-target',
39
+ 'i-lucide-lightbulb', 'i-lucide-sparkles', 'i-lucide-shopping-cart', 'i-lucide-shopping-bag',
40
+ 'i-lucide-package', 'i-lucide-gift', 'i-lucide-store', 'i-lucide-briefcase',
41
+ 'i-lucide-building', 'i-lucide-building-2', 'i-lucide-factory', 'i-lucide-warehouse',
42
+ 'i-lucide-message-square', 'i-lucide-messages-square', 'i-lucide-message-circle', 'i-lucide-bell',
43
+ 'i-lucide-megaphone', 'i-lucide-search', 'i-lucide-link', 'i-lucide-external-link',
44
+ 'i-lucide-download', 'i-lucide-upload', 'i-lucide-share-2', 'i-lucide-table',
45
+ 'i-lucide-columns-3', 'i-lucide-rows-3', 'i-lucide-kanban', 'i-lucide-chart-bar',
46
+ 'i-lucide-chart-line', 'i-lucide-chart-pie', 'i-lucide-trending-up', 'i-lucide-activity',
47
+ 'i-lucide-euro', 'i-lucide-credit-card', 'i-lucide-receipt', 'i-lucide-percent',
48
+ 'i-lucide-wallet', 'i-lucide-banknote', 'i-lucide-leaf', 'i-lucide-tree-pine',
49
+ 'i-lucide-sun', 'i-lucide-moon', 'i-lucide-cloud', 'i-lucide-droplet',
50
+ 'i-lucide-flame', 'i-lucide-zap', 'i-lucide-wifi', 'i-lucide-rss',
51
+ 'i-lucide-clipboard', 'i-lucide-clipboard-list', 'i-lucide-check', 'i-lucide-check-circle',
52
+ 'i-lucide-circle-check', 'i-lucide-bookmark-check', 'i-lucide-heart-handshake', 'i-lucide-handshake',
53
+ 'i-lucide-hand-helping', 'i-lucide-baby', 'i-lucide-dumbbell', 'i-lucide-bike',
54
+ 'i-lucide-car', 'i-lucide-bus', 'i-lucide-plane', 'i-lucide-ship',
55
+ 'i-lucide-utensils', 'i-lucide-coffee', 'i-lucide-cake', 'i-lucide-pizza',
56
+ ]
57
+
58
+ const filtered = computed(() => {
59
+ const q = search.value.trim().toLowerCase()
60
+ if (!q) return ICONS
61
+ return ICONS.filter(name => name.includes(q))
62
+ })
63
+
64
+ function pick(name: string): void {
65
+ emit('update:modelValue', name)
66
+ open.value = false
67
+ }
68
+
69
+ function clear(): void {
70
+ emit('update:modelValue', '')
71
+ open.value = false
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <UPopover v-model:open="open">
77
+ <button
78
+ type="button"
79
+ class="flex w-full items-center gap-2 rounded-md border border-default px-3 py-2 text-left transition-colors hover:bg-elevated"
80
+ >
81
+ <span class="flex size-6 shrink-0 items-center justify-center">
82
+ <UIcon v-if="props.modelValue" :name="props.modelValue" class="size-5" />
83
+ <UIcon v-else name="i-lucide-image" class="size-5 text-dimmed" />
84
+ </span>
85
+ <span class="flex-1 truncate text-sm" :class="{ 'text-muted': !props.modelValue }">
86
+ {{ props.modelValue || labels.iconChoose }}
87
+ </span>
88
+ <UIcon name="i-lucide-chevron-down" class="size-4 shrink-0 text-dimmed" />
89
+ </button>
90
+
91
+ <template #content>
92
+ <div class="w-72 space-y-2 p-3">
93
+ <UInput
94
+ v-model="search"
95
+ icon="i-lucide-search"
96
+ size="sm"
97
+ autofocus
98
+ :placeholder="labels.search"
99
+ />
100
+ <div class="grid max-h-56 grid-cols-6 gap-1 overflow-y-auto">
101
+ <button
102
+ v-for="name in filtered"
103
+ :key="name"
104
+ type="button"
105
+ class="flex aspect-square items-center justify-center rounded hover:bg-elevated"
106
+ :class="{ 'bg-primary/10 ring-1 ring-primary': name === props.modelValue }"
107
+ :title="name"
108
+ @click="pick(name)"
109
+ >
110
+ <UIcon :name="name" class="size-5" />
111
+ </button>
112
+ </div>
113
+ <p v-if="filtered.length === 0" class="text-xs text-muted">
114
+ {{ labels.iconEmpty }}
115
+ </p>
116
+ <div v-if="props.modelValue" class="border-t border-default pt-2">
117
+ <UButton
118
+ size="xs"
119
+ color="neutral"
120
+ variant="ghost"
121
+ icon="i-lucide-x"
122
+ @click="clear"
123
+ >
124
+ {{ labels.iconClear }}
125
+ </UButton>
126
+ </div>
127
+ </div>
128
+ </template>
129
+ </UPopover>
130
+ </template>
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
3
+ import { useBaseKitLabels } from '../composables/useBaseKit'
4
+ import { Editor, EditorContent } from '@tiptap/vue-3'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import Link from '@tiptap/extension-link'
7
+ import { renderMarkdown } from '../utils/markdown'
8
+ import { htmlToMarkdown, prepareHtmlToMarkdown } from '../utils/html-to-markdown'
9
+
10
+ /**
11
+ * WYSIWYG-Editor für den Text-Block. v-model ist ein **Markdown-String**.
12
+ *
13
+ * Tiptap arbeitet intern mit HTML; die Brücke nach Markdown läuft über zwei
14
+ * etablierte Bibliotheken statt eines Tiptap-Markdown-Plugins (versionsrobust):
15
+ * - Laden: Markdown → HTML via markdown-it (`renderMarkdown`)
16
+ * - Speichern: HTML → Markdown via turndown
17
+ *
18
+ * Der Editor wird erst `onMounted` erzeugt (ProseMirror braucht DOM, SSR-sicher).
19
+ */
20
+ const props = defineProps<{ modelValue?: string | null }>()
21
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
22
+
23
+ const labels = useBaseKitLabels()
24
+
25
+ const editor = shallowRef<Editor>()
26
+
27
+ function toMarkdown(html: string): string {
28
+ return htmlToMarkdown(html)
29
+ }
30
+
31
+ onMounted(() => {
32
+ // turndown im Browser nachladen — Details in html-to-markdown.ts.
33
+ prepareHtmlToMarkdown()
34
+ editor.value = new Editor({
35
+ extensions: [
36
+ StarterKit,
37
+ Link.configure({ openOnClick: false }),
38
+ ],
39
+ content: renderMarkdown(props.modelValue),
40
+ onUpdate: ({ editor }) => emit('update:modelValue', toMarkdown(editor.getHTML())),
41
+ })
42
+ })
43
+
44
+ onBeforeUnmount(() => editor.value?.destroy())
45
+
46
+ // Externe Wertänderung übernehmen, ohne den Cursor zu stören (nur bei echtem Diff).
47
+ watch(() => props.modelValue, (v) => {
48
+ if (!editor.value) return
49
+ if ((v ?? '') !== toMarkdown(editor.value.getHTML())) {
50
+ editor.value.commands.setContent(renderMarkdown(v))
51
+ }
52
+ })
53
+
54
+ function setLink(): void {
55
+ if (!editor.value) return
56
+ const prev = editor.value.getAttributes('link').href as string | undefined
57
+ const url = window.prompt(labels.value.markdown.linkPrompt, prev ?? '')
58
+ if (url === null) return
59
+ if (url === '') { editor.value.chain().focus().extendMarkRange('link').unsetLink().run(); return }
60
+ editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
61
+ }
62
+
63
+ function isActive(name: string, attrs?: Record<string, unknown>): boolean {
64
+ return !!editor.value?.isActive(name, attrs)
65
+ }
66
+ </script>
67
+
68
+ <template>
69
+ <div class="md-editor">
70
+ <div v-if="editor" class="md-toolbar">
71
+ <button type="button" :class="{ on: isActive('bold') }" :aria-label="labels.markdown.bold" :title="labels.markdown.bold" @click="editor.chain().focus().toggleBold().run()"><UIcon name="i-lucide-bold" class="size-4" /></button>
72
+ <button type="button" :class="{ on: isActive('italic') }" :aria-label="labels.markdown.italic" :title="labels.markdown.italic" @click="editor.chain().focus().toggleItalic().run()"><UIcon name="i-lucide-italic" class="size-4" /></button>
73
+ <span class="md-sep" />
74
+ <button type="button" :class="{ on: isActive('heading', { level: 2 }) }" :aria-label="labels.markdown.h2" :title="labels.markdown.h2" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"><UIcon name="i-lucide-heading-2" class="size-4" /></button>
75
+ <button type="button" :class="{ on: isActive('heading', { level: 3 }) }" :aria-label="labels.markdown.h3" :title="labels.markdown.h3" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"><UIcon name="i-lucide-heading-3" class="size-4" /></button>
76
+ <span class="md-sep" />
77
+ <button type="button" :class="{ on: isActive('bulletList') }" :aria-label="labels.markdown.bullet" :title="labels.markdown.bullet" @click="editor.chain().focus().toggleBulletList().run()"><UIcon name="i-lucide-list" class="size-4" /></button>
78
+ <button type="button" :class="{ on: isActive('orderedList') }" :aria-label="labels.markdown.ordered" :title="labels.markdown.ordered" @click="editor.chain().focus().toggleOrderedList().run()"><UIcon name="i-lucide-list-ordered" class="size-4" /></button>
79
+ <button type="button" :class="{ on: isActive('blockquote') }" :aria-label="labels.markdown.quote" :title="labels.markdown.quote" @click="editor.chain().focus().toggleBlockquote().run()"><UIcon name="i-lucide-quote" class="size-4" /></button>
80
+ <button type="button" :class="{ on: isActive('code') }" :aria-label="labels.markdown.code" :title="labels.markdown.code" @click="editor.chain().focus().toggleCode().run()"><UIcon name="i-lucide-code" class="size-4" /></button>
81
+ <span class="md-sep" />
82
+ <button type="button" :class="{ on: isActive('link') }" :aria-label="labels.markdown.link" :title="labels.markdown.link" @click="setLink()"><UIcon name="i-lucide-link" class="size-4" /></button>
83
+ </div>
84
+ <EditorContent :editor="editor" class="md-content" />
85
+ </div>
86
+ </template>
87
+
88
+ <style scoped>
89
+ .md-editor { border: 1px solid var(--basekit-border-strong, #d6dbe4); border-radius: 8px; overflow: hidden; background: #fff; }
90
+ .md-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; padding: 6px 8px; border-bottom: 1px solid var(--basekit-border, #e5e8ee); background: var(--basekit-surface-muted, #f4f6fa); }
91
+ .md-toolbar button { min-width: 28px; height: 26px; padding: 0 7px; border: 1px solid transparent; border-radius: 6px; background: transparent; font: inherit; font-size: 13px; line-height: 1; color: var(--basekit-text, #101828); cursor: pointer; }
92
+ .md-toolbar button:hover { background: #fff; border-color: var(--basekit-border, #e5e8ee); }
93
+ .md-toolbar button.on { background: var(--basekit-accent, #2563eb); color: #fff; }
94
+ .md-sep { width: 1px; height: 18px; background: var(--basekit-border, #e5e8ee); margin: 0 3px; }
95
+ .md-content :deep(.ProseMirror) { min-height: 220px; padding: 12px 14px; outline: none; font-size: 0.9375rem; line-height: 1.6; }
96
+ .md-content :deep(.ProseMirror h2) { font-weight: 700; font-size: 1.25rem; margin: 0.5rem 0; }
97
+ .md-content :deep(.ProseMirror h3) { font-weight: 700; font-size: 1.1rem; margin: 0.5rem 0; }
98
+ .md-content :deep(.ProseMirror p) { margin: 0 0 0.6rem; }
99
+ .md-content :deep(.ProseMirror ul) { margin: 0 0 0.6rem 1.1rem; list-style: disc; }
100
+ .md-content :deep(.ProseMirror ol) { margin: 0 0 0.6rem 1.3rem; list-style: decimal; }
101
+ .md-content :deep(.ProseMirror blockquote) { margin: 0 0 0.6rem; padding-left: 0.9rem; border-left: 3px solid var(--basekit-accent, #2563eb); color: var(--basekit-text-muted, #667085); }
102
+ .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
+ .md-content :deep(.ProseMirror a) { color: var(--basekit-accent, #2563eb); text-decoration: underline; }
104
+
105
+ /* Dark-Mode: die hart hinterlegten Light-Fallbacks überschreiben. */
106
+ :where(.dark) .md-editor { background: #101828; border-color: #1f2937; }
107
+ :where(.dark) .md-toolbar { background: #0b1220; border-color: #1f2937; }
108
+ :where(.dark) .md-toolbar button { color: #e5e7eb; }
109
+ :where(.dark) .md-toolbar button:hover { background: #1f2937; border-color: #374151; }
110
+ :where(.dark) .md-sep { background: #1f2937; }
111
+ :where(.dark) .md-content :deep(.ProseMirror code) { background: #1f2937; }
112
+ </style>