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,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>
|