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,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Ladezustand für eine Seite oder einen Abschnitt.
|
|
4
|
+
*
|
|
5
|
+
* Die Admin-Seiten holen ihre Daten in `onMounted` nach. Ohne Zwischenschritt
|
|
6
|
+
* steht das Formular sofort da — mit leeren Feldern, die sich einen Wimpernschlag
|
|
7
|
+
* später von selbst füllen. Wer schnell tippt, schreibt in ein Feld, das gleich
|
|
8
|
+
* überschrieben wird; wer langsam liest, hält den Datensatz für leer.
|
|
9
|
+
*
|
|
10
|
+
* Deshalb: erst ein Kreisel, dann der Inhalt. Der Kreisel sagt „gleich", das
|
|
11
|
+
* leere Formular sagt „nichts da" — und das eine ist wahr, das andere nicht.
|
|
12
|
+
*
|
|
13
|
+
* Zwei Wege, beide gültig:
|
|
14
|
+
*
|
|
15
|
+
* <BaseKitPending :pending="pending">
|
|
16
|
+
* <UForm …> <!-- erscheint erst, wenn die Daten stehen -->
|
|
17
|
+
* </BaseKitPending>
|
|
18
|
+
*
|
|
19
|
+
* <BaseKitPending v-if="pending" />
|
|
20
|
+
*
|
|
21
|
+
* `label` nur setzen, wo der Vorgang länger dauert und einen Namen hat („Suche
|
|
22
|
+
* läuft"). Bei einem gewöhnlichen Formular ist der Kreisel allein ehrlicher als
|
|
23
|
+
* ein Wort, das niemand liest.
|
|
24
|
+
*/
|
|
25
|
+
withDefaults(defineProps<{
|
|
26
|
+
/** Solange wahr, steht der Kreisel statt des Inhalts. */
|
|
27
|
+
pending?: boolean
|
|
28
|
+
/** Optionale Beschriftung unter dem Kreisel. */
|
|
29
|
+
label?: string
|
|
30
|
+
/**
|
|
31
|
+
* Höhe des Bereichs. `page` für eine ganze Seite, `inline` für einen
|
|
32
|
+
* Abschnitt darin — sonst springt das Layout, wenn der Inhalt erscheint.
|
|
33
|
+
*/
|
|
34
|
+
size?: 'page' | 'inline'
|
|
35
|
+
}>(), {
|
|
36
|
+
pending: true,
|
|
37
|
+
label: undefined,
|
|
38
|
+
size: 'page',
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div
|
|
44
|
+
v-if="pending"
|
|
45
|
+
class="flex flex-col items-center justify-center gap-3 text-muted"
|
|
46
|
+
:class="size === 'page' ? 'min-h-64 py-12' : 'min-h-24 py-6'"
|
|
47
|
+
role="status"
|
|
48
|
+
aria-live="polite"
|
|
49
|
+
>
|
|
50
|
+
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
|
|
51
|
+
<span v-if="label" class="text-sm">{{ label }}</span>
|
|
52
|
+
</div>
|
|
53
|
+
<slot v-else />
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Auswahl eines Datensatzes über (potenziell mehrere) Modelle — die Auswahl
|
|
7
|
+
* läuft in einem Modal, das per „Auswählen" / „Ändern" aufgeht. Im Modal ist
|
|
8
|
+
* die Liste nach Text UND Typ filterbar.
|
|
9
|
+
*
|
|
10
|
+
* - **Nichts gewählt** → Button „Auswählen".
|
|
11
|
+
* - **Gewählt** → Titel der Auswahl (Link zum Bearbeiten des Inhalts) + Typ-
|
|
12
|
+
* Badge + Button „Ändern".
|
|
13
|
+
*
|
|
14
|
+
* Modell-agnostisch — die `items` liefert der Aufrufer. `v-model` = `value`
|
|
15
|
+
* der Option (oder `null`).
|
|
16
|
+
*/
|
|
17
|
+
export interface BaseKitRecordOption {
|
|
18
|
+
value: number
|
|
19
|
+
label: string
|
|
20
|
+
/** Typ-Badge (bereits übersetzt). */
|
|
21
|
+
type?: string
|
|
22
|
+
/** Zusatz rechts in der Liste (z. B. Slug). */
|
|
23
|
+
hint?: string
|
|
24
|
+
/** Interner Link zum Bearbeiten des Inhalts. */
|
|
25
|
+
editHref?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
modelValue?: number | null
|
|
30
|
+
items: BaseKitRecordOption[]
|
|
31
|
+
/** Titel des Auswahl-Modals. */
|
|
32
|
+
title?: string
|
|
33
|
+
}>(), {
|
|
34
|
+
modelValue: null,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
'update:modelValue': [value: number | null]
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const labels = useBaseKitLabels()
|
|
42
|
+
|
|
43
|
+
const open = ref(false)
|
|
44
|
+
const search = ref('')
|
|
45
|
+
const typeFilter = ref('')
|
|
46
|
+
|
|
47
|
+
const selected = computed(() => props.items.find(item => item.value === props.modelValue) ?? null)
|
|
48
|
+
|
|
49
|
+
const types = computed(() => [...new Set(props.items.map(item => item.type).filter((x): x is string => !!x))])
|
|
50
|
+
const showTypeFilter = computed(() => types.value.length > 1)
|
|
51
|
+
|
|
52
|
+
const filtered = computed(() => {
|
|
53
|
+
const q = search.value.trim().toLowerCase()
|
|
54
|
+
return props.items.filter((item) => {
|
|
55
|
+
if (typeFilter.value && item.type !== typeFilter.value) return false
|
|
56
|
+
if (!q) return true
|
|
57
|
+
return item.label.toLowerCase().includes(q) || (item.hint?.toLowerCase().includes(q) ?? false)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function openModal(): void {
|
|
62
|
+
search.value = ''
|
|
63
|
+
typeFilter.value = ''
|
|
64
|
+
open.value = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function choose(item: BaseKitRecordOption): void {
|
|
68
|
+
emit('update:modelValue', item.value)
|
|
69
|
+
open.value = false
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div>
|
|
75
|
+
<!-- Gewählt: Titel (Bearbeiten-Link) + Typ-Badge + Ändern -->
|
|
76
|
+
<div v-if="selected" class="flex items-center gap-2">
|
|
77
|
+
<NuxtLink
|
|
78
|
+
v-if="selected.editHref"
|
|
79
|
+
:to="selected.editHref"
|
|
80
|
+
class="truncate font-medium text-primary-700 hover:underline dark:text-primary-300"
|
|
81
|
+
:title="labels.edit"
|
|
82
|
+
>
|
|
83
|
+
{{ selected.label }}
|
|
84
|
+
</NuxtLink>
|
|
85
|
+
<span v-else class="truncate font-medium">{{ selected.label }}</span>
|
|
86
|
+
|
|
87
|
+
<UBadge v-if="selected.type" color="neutral" variant="soft" size="xs">
|
|
88
|
+
{{ selected.type }}
|
|
89
|
+
</UBadge>
|
|
90
|
+
|
|
91
|
+
<UButton class="ml-auto" size="xs" color="neutral" variant="subtle" @click="openModal">
|
|
92
|
+
{{ labels.change }}
|
|
93
|
+
</UButton>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Nichts gewählt: Auswählen -->
|
|
97
|
+
<UButton v-else color="neutral" variant="subtle" icon="i-lucide-list" @click="openModal">
|
|
98
|
+
{{ labels.select }}
|
|
99
|
+
</UButton>
|
|
100
|
+
|
|
101
|
+
<UModal v-model:open="open" :title="title ?? labels.select">
|
|
102
|
+
<template #body>
|
|
103
|
+
<div class="space-y-3">
|
|
104
|
+
<UInput
|
|
105
|
+
v-model="search"
|
|
106
|
+
icon="i-lucide-search"
|
|
107
|
+
:placeholder="labels.search"
|
|
108
|
+
autofocus
|
|
109
|
+
class="w-full"
|
|
110
|
+
/>
|
|
111
|
+
|
|
112
|
+
<div v-if="showTypeFilter" class="flex flex-wrap gap-1">
|
|
113
|
+
<UButton
|
|
114
|
+
size="xs"
|
|
115
|
+
color="neutral"
|
|
116
|
+
:variant="typeFilter === '' ? 'solid' : 'soft'"
|
|
117
|
+
@click="typeFilter = ''"
|
|
118
|
+
>
|
|
119
|
+
{{ labels.all }}
|
|
120
|
+
</UButton>
|
|
121
|
+
<UButton
|
|
122
|
+
v-for="ty in types"
|
|
123
|
+
:key="ty"
|
|
124
|
+
size="xs"
|
|
125
|
+
color="neutral"
|
|
126
|
+
:variant="typeFilter === ty ? 'solid' : 'soft'"
|
|
127
|
+
@click="typeFilter = ty"
|
|
128
|
+
>
|
|
129
|
+
{{ ty }}
|
|
130
|
+
</UButton>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<ul class="max-h-80 divide-y divide-neutral-100 overflow-auto dark:divide-neutral-800">
|
|
134
|
+
<li v-for="item in filtered" :key="item.value">
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
class="flex w-full items-center gap-2 rounded px-2 py-2 text-left hover:bg-neutral-50 dark:hover:bg-neutral-900"
|
|
138
|
+
:class="item.value === modelValue ? 'text-primary-700 dark:text-primary-300' : ''"
|
|
139
|
+
@click="choose(item)"
|
|
140
|
+
>
|
|
141
|
+
<span class="truncate">{{ item.label }}</span>
|
|
142
|
+
<UBadge v-if="item.type" color="neutral" variant="soft" size="xs">{{ item.type }}</UBadge>
|
|
143
|
+
<span v-if="item.hint" class="ml-auto truncate text-xs font-mono text-dimmed">{{ item.hint }}</span>
|
|
144
|
+
</button>
|
|
145
|
+
</li>
|
|
146
|
+
<li v-if="!filtered.length" class="px-2 py-6 text-center text-sm text-muted">
|
|
147
|
+
{{ labels.noResults }}
|
|
148
|
+
</li>
|
|
149
|
+
</ul>
|
|
150
|
+
</div>
|
|
151
|
+
</template>
|
|
152
|
+
</UModal>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Setting-Row — Pattern für eine einzelne Einstellung in einer Settings-Card.
|
|
4
|
+
*
|
|
5
|
+
* ┌──────────────────────────────────────────────────────┐
|
|
6
|
+
* │ Titel [Action] │
|
|
7
|
+
* │ Beschreibungstext (optional, mehrzeilig) │
|
|
8
|
+
* └──────────────────────────────────────────────────────┘
|
|
9
|
+
*
|
|
10
|
+
* Titel + Action stehen auf einer Zeile (Action rechts, Titel links).
|
|
11
|
+
* Die Beschreibung steht darunter, linksbündig zum Titel.
|
|
12
|
+
*
|
|
13
|
+
* Verwendung:
|
|
14
|
+
*
|
|
15
|
+
* <BaseKitSettingRow title="Benachrichtigungen" description="E-Mails und Push erhalten">
|
|
16
|
+
* <USwitch v-model="notifications" />
|
|
17
|
+
* </BaseKitSettingRow>
|
|
18
|
+
*
|
|
19
|
+
* <BaseKitSettingRow title="Sprache" description="Wird beim Speichern übernommen">
|
|
20
|
+
* <USelect v-model="staged" :items="locales" />
|
|
21
|
+
* </BaseKitSettingRow>
|
|
22
|
+
*
|
|
23
|
+
* Der `meta`-Slot nimmt eine Zusatzzeile unter der Beschreibung auf — Bestand,
|
|
24
|
+
* Sperrgrund, Hinweis. Sie steht bewusst getrennt von `description`: das Feld
|
|
25
|
+
* trägt den einen erklärenden Satz, und eine Zahl ist keine Erklärung.
|
|
26
|
+
*
|
|
27
|
+
* <BaseKitSettingRow title="E-Learning" description="Kurse und Nachweise.">
|
|
28
|
+
* <USwitch v-model="on" />
|
|
29
|
+
* <template #meta><span>4 Kurse</span></template>
|
|
30
|
+
* </BaseKitSettingRow>
|
|
31
|
+
*
|
|
32
|
+
* Mehrere Rows in einer Card werden über Tailwind divider getrennt:
|
|
33
|
+
*
|
|
34
|
+
* <UCard>
|
|
35
|
+
* <div class="divide-y divide-default">
|
|
36
|
+
* <BaseKitSettingRow ... />
|
|
37
|
+
* <BaseKitSettingRow ... />
|
|
38
|
+
* </div>
|
|
39
|
+
* </UCard>
|
|
40
|
+
*/
|
|
41
|
+
defineProps<{
|
|
42
|
+
title: string
|
|
43
|
+
description?: string
|
|
44
|
+
}>()
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div class="py-3 first:pt-0 last:pb-0">
|
|
49
|
+
<div class="flex items-start justify-between gap-4">
|
|
50
|
+
<div class="min-w-0 flex-1">
|
|
51
|
+
<h3 class="text-sm font-medium">
|
|
52
|
+
{{ title }}
|
|
53
|
+
</h3>
|
|
54
|
+
<p v-if="description" class="mt-1 text-xs text-muted">
|
|
55
|
+
{{ description }}
|
|
56
|
+
</p>
|
|
57
|
+
<div v-if="$slots.meta" class="mt-1.5 flex flex-wrap items-center gap-2 text-xs">
|
|
58
|
+
<slot name="meta" />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="shrink-0">
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Eine Zahl mit Beschriftung — die richtige Form für einen einzelnen Wert.
|
|
4
|
+
*
|
|
5
|
+
* Ein Balkendiagramm mit genau einem Balken sagt nicht mehr als die Zahl
|
|
6
|
+
* selbst und kostet die vierfache Fläche. Deshalb sind Bestand und
|
|
7
|
+
* Handlungsliste Kacheln und keine Diagramme.
|
|
8
|
+
*
|
|
9
|
+
* `tone` färbt nur, wenn die Zahl über null liegt: „0 offene Vorgänge" ist
|
|
10
|
+
* keine Warnung, sondern der Normalzustand.
|
|
11
|
+
*/
|
|
12
|
+
import { computed } from 'vue'
|
|
13
|
+
import { useChartFormat } from '../composables/useChartPalette'
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<{
|
|
16
|
+
label: string
|
|
17
|
+
value: number
|
|
18
|
+
hint?: string
|
|
19
|
+
href?: string
|
|
20
|
+
icon?: string
|
|
21
|
+
tone?: 'alert' | 'info' | 'neutral'
|
|
22
|
+
}>(), {
|
|
23
|
+
hint: undefined,
|
|
24
|
+
href: undefined,
|
|
25
|
+
icon: undefined,
|
|
26
|
+
tone: 'neutral',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const { number } = useChartFormat()
|
|
30
|
+
|
|
31
|
+
const valueClass = computed(() => {
|
|
32
|
+
if (props.value === 0 || props.tone === 'neutral') return 'text-highlighted'
|
|
33
|
+
return props.tone === 'alert' ? 'text-error' : 'text-highlighted'
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** Der Punkt neben der Zahl trägt dieselbe Aussage wie die Farbe — die Farbe
|
|
37
|
+
* allein wäre für eine Rot-Grün-Schwäche keine. */
|
|
38
|
+
const flagged = computed(() => props.value > 0 && props.tone === 'alert')
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<component
|
|
43
|
+
:is="props.href ? 'NuxtLink' : 'div'"
|
|
44
|
+
:to="props.href"
|
|
45
|
+
class="block rounded-lg border border-default bg-default p-4 transition-colors"
|
|
46
|
+
:class="props.href ? 'hover:bg-elevated/60' : ''"
|
|
47
|
+
>
|
|
48
|
+
<div class="flex items-center gap-2">
|
|
49
|
+
<UIcon v-if="props.icon" :name="props.icon" class="size-4 shrink-0 text-dimmed" />
|
|
50
|
+
<p class="truncate text-xs font-medium text-muted" :title="props.label">
|
|
51
|
+
{{ props.label }}
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<p class="mt-2 flex items-center gap-1.5 text-2xl font-semibold" :class="valueClass">
|
|
56
|
+
<UIcon v-if="flagged" name="i-lucide-alert-triangle" class="size-4 shrink-0" />
|
|
57
|
+
{{ number(props.value) }}
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
<p v-if="props.hint" class="mt-1 text-xs text-dimmed">
|
|
61
|
+
{{ props.hint }}
|
|
62
|
+
</p>
|
|
63
|
+
</component>
|
|
64
|
+
</template>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from '#imports'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tab-Gruppierung — Pattern, um auf einer Seite mehrere Bereiche (z.B.
|
|
7
|
+
* Einstellungs-Gruppen) zu gliedern, gerade bei vielen Feldern.
|
|
8
|
+
*
|
|
9
|
+
* Generisch: pro Tab ein benannter Slot (= dessen `key`). Aktiver Tab optional
|
|
10
|
+
* über `v-model` steuerbar, sonst der erste.
|
|
11
|
+
*
|
|
12
|
+
* <BaseKitTabs :items="[
|
|
13
|
+
* { key: 'general', label: 'Allgemein', icon: 'i-lucide-settings' },
|
|
14
|
+
* { key: 'access', label: 'Zugriff', icon: 'i-lucide-lock' },
|
|
15
|
+
* ]">
|
|
16
|
+
* <template #general> … </template>
|
|
17
|
+
* <template #access> … </template>
|
|
18
|
+
* </BaseKitTabs>
|
|
19
|
+
*
|
|
20
|
+
* Deep-Linking: mit `hash-nav` ist der aktive Tab über den URL-Hash adressierbar
|
|
21
|
+
* (`…/seite#access`). Beim Laden gewinnt ein passender Hash; ein Tab-Wechsel
|
|
22
|
+
* schreibt den Hash via `router.replace` (kein History-Spam). Bei mehreren
|
|
23
|
+
* Tab-Gruppen auf einer Seite nur EINE mit `hash-nav` betreiben.
|
|
24
|
+
*
|
|
25
|
+
* Der Hash greift bewusst erst `onMounted`: der Server sieht ihn gar nicht
|
|
26
|
+
* (Browser schicken ihn nicht mit), und ein davon abweichender erster Render
|
|
27
|
+
* im Client zerlegt die Hydration — dann stand die Markierung auf dem einen
|
|
28
|
+
* Tab und der Inhalt kam vom anderen. Aus demselben Grund läuft er über
|
|
29
|
+
* `select()`, damit ein gebundenes `v-model` den Tab aus dem Hash mitbekommt.
|
|
30
|
+
*
|
|
31
|
+
* A11y: tablist/tab/tabpanel-Rollen, Pfeiltasten links/rechts wechseln.
|
|
32
|
+
*
|
|
33
|
+
* Layout-Konvention: die Tab-Leiste NICHT in eine `UCard` wrappen — Tabs +
|
|
34
|
+
* Inhalt liegen direkt auf der Seite. Feinere Struktur je Tab-Inhalt (Gruppen,
|
|
35
|
+
* Fieldset, einzelne Cards) nach Bedarf INNERHALB des jeweiligen Slots.
|
|
36
|
+
*/
|
|
37
|
+
const props = withDefaults(defineProps<{
|
|
38
|
+
/** Tab-Definitionen (Reihenfolge = Anzeigereihenfolge). */
|
|
39
|
+
items: Array<{ key: string, label: string, icon?: string }>
|
|
40
|
+
/** Aktiver Tab-Key (v-model). Ohne Bindung wird der erste Tab aktiv. */
|
|
41
|
+
modelValue?: string
|
|
42
|
+
/** Aktiven Tab über den URL-Hash adressierbar machen (`#<key>`). */
|
|
43
|
+
hashNav?: boolean
|
|
44
|
+
}>(), {
|
|
45
|
+
modelValue: undefined,
|
|
46
|
+
hashNav: false,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const emit = defineEmits<{
|
|
50
|
+
'update:modelValue': [value: string]
|
|
51
|
+
}>()
|
|
52
|
+
|
|
53
|
+
const route = useRoute()
|
|
54
|
+
const router = useRouter()
|
|
55
|
+
|
|
56
|
+
/** Gültiger Tab-Key aus dem aktuellen URL-Hash, sonst null. */
|
|
57
|
+
function keyFromHash(): string | null {
|
|
58
|
+
if (!props.hashNav) return null
|
|
59
|
+
const key = String(route.hash ?? '').replace(/^#/, '')
|
|
60
|
+
return props.items.some(i => i.key === key) ? key : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const active = ref<string>(props.modelValue ?? props.items[0]?.key ?? '')
|
|
64
|
+
|
|
65
|
+
/** Hat der Nutzer selbst gewählt? Dann gewinnt seine Wahl über den Hash. */
|
|
66
|
+
const picked = ref(false)
|
|
67
|
+
|
|
68
|
+
// Hash erst im Client anwenden — siehe Kommentar oben.
|
|
69
|
+
onMounted(() => applyHash())
|
|
70
|
+
|
|
71
|
+
// v-model rein: Auswahl von außen übernehmen.
|
|
72
|
+
watch(() => props.modelValue, (next) => {
|
|
73
|
+
if (next !== undefined && next !== active.value) active.value = next
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Hash ändert sich (Direktlink, Zurück/Vorwärts) → passenden Tab aktivieren.
|
|
77
|
+
watch(() => route.hash, () => {
|
|
78
|
+
const key = keyFromHash()
|
|
79
|
+
if (key !== null && key !== active.value) active.value = key
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Items wechseln. Zwei Fälle: der Tab aus dem Hash taucht erst jetzt auf
|
|
83
|
+
// (rechte-abhängige Tabs kommen mit dem Bootstrap nach) — dann übernehmen,
|
|
84
|
+
// solange der Nutzer nicht selbst gewählt hat. Sonst: fällt der aktive Tab
|
|
85
|
+
// weg, zurück auf den ersten.
|
|
86
|
+
watch(() => props.items, (items) => {
|
|
87
|
+
if (!picked.value && applyHash()) return
|
|
88
|
+
if (!items.some(i => i.key === active.value)) active.value = items[0]?.key ?? ''
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tab aus dem Hash übernehmen, falls er zu einem Item passt und nicht schon
|
|
93
|
+
* aktiv ist. Zählt nicht als Nutzer-Wahl. Liefert `true`, wenn gewechselt
|
|
94
|
+
* wurde.
|
|
95
|
+
*/
|
|
96
|
+
function applyHash(): boolean {
|
|
97
|
+
const key = keyFromHash()
|
|
98
|
+
if (key === null || key === active.value) return false
|
|
99
|
+
active.value = key
|
|
100
|
+
emit('update:modelValue', key)
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function select(key: string): void {
|
|
105
|
+
picked.value = true
|
|
106
|
+
active.value = key
|
|
107
|
+
emit('update:modelValue', key)
|
|
108
|
+
|
|
109
|
+
if (props.hashNav && String(route.hash ?? '').replace(/^#/, '') !== key) {
|
|
110
|
+
router.replace({ hash: `#${key}` })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const activeIndex = computed(() => props.items.findIndex(i => i.key === active.value))
|
|
115
|
+
|
|
116
|
+
function onKey(e: KeyboardEvent): void {
|
|
117
|
+
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
const len = props.items.length
|
|
120
|
+
if (len === 0) return
|
|
121
|
+
const dir = e.key === 'ArrowRight' ? 1 : -1
|
|
122
|
+
const next = (activeIndex.value + dir + len) % len
|
|
123
|
+
select(props.items[next]!.key)
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<div class="basekit-tabs">
|
|
129
|
+
<div
|
|
130
|
+
role="tablist"
|
|
131
|
+
class="flex flex-wrap gap-1 border-b border-neutral-200 dark:border-neutral-800"
|
|
132
|
+
@keydown="onKey"
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
v-for="item in items"
|
|
136
|
+
:id="`basekit-tab-${item.key}`"
|
|
137
|
+
:key="item.key"
|
|
138
|
+
type="button"
|
|
139
|
+
role="tab"
|
|
140
|
+
:aria-selected="item.key === active"
|
|
141
|
+
:tabindex="item.key === active ? 0 : -1"
|
|
142
|
+
class="-mb-px inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors"
|
|
143
|
+
:class="item.key === active
|
|
144
|
+
? 'border-primary-500 text-primary-700 dark:text-primary-300'
|
|
145
|
+
: 'border-transparent text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200'"
|
|
146
|
+
@click="select(item.key)"
|
|
147
|
+
>
|
|
148
|
+
<UIcon v-if="item.icon" :name="item.icon" class="size-4" />
|
|
149
|
+
{{ item.label }}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div
|
|
154
|
+
role="tabpanel"
|
|
155
|
+
:aria-labelledby="`basekit-tab-${active}`"
|
|
156
|
+
class="pt-4"
|
|
157
|
+
>
|
|
158
|
+
<slot :name="active" />
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Weg vom Bearbeiten-Formular zur Ansicht derselben Sache.
|
|
4
|
+
*
|
|
5
|
+
* Die Gegenrichtung gab es längst: In der Ansicht stehen die Kontext-Aktionen
|
|
6
|
+
* („Bearbeiten", „Inhalt pflegen"). Der Rückweg fehlte — wer eine Rubrik
|
|
7
|
+
* bearbeitet hatte, kam nur über den Umweg Übersicht → Frontend → Rubrik
|
|
8
|
+
* dorthin, um das Ergebnis zu sehen.
|
|
9
|
+
*
|
|
10
|
+
* Steht neben dem Rücksprung im Seitenkopf, an derselben Stelle wie dort:
|
|
11
|
+
* Erst die Ansicht, dann der Rücksprung zur Liste — die Reihenfolge folgt der
|
|
12
|
+
* Häufigkeit, mit der man beides braucht.
|
|
13
|
+
*
|
|
14
|
+
* **Nur zeigen, wenn es die Ansicht gibt.** Ein neu angelegter Datensatz hat
|
|
15
|
+
* noch keine, und ein Link ins Leere ist schlechter als kein Link. Deshalb
|
|
16
|
+
* gehört das `v-if` an die Aufrufstelle, wo bekannt ist, ob gespeichert wurde.
|
|
17
|
+
*/
|
|
18
|
+
import { computed } from 'vue'
|
|
19
|
+
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(defineProps<{
|
|
22
|
+
to: string
|
|
23
|
+
/**
|
|
24
|
+
* Öffnet die Ansicht in einem neuen Reiter. Voreingestellt, weil man beim
|
|
25
|
+
* Bearbeiten meist nachsehen und weiterarbeiten will — nicht wechseln und
|
|
26
|
+
* die ungespeicherten Änderungen verlieren.
|
|
27
|
+
*/
|
|
28
|
+
newTab?: boolean
|
|
29
|
+
label?: string
|
|
30
|
+
}>(), {
|
|
31
|
+
newTab: true,
|
|
32
|
+
label: undefined,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const labels = useBaseKitLabels()
|
|
36
|
+
|
|
37
|
+
const text = computed(() => props.label ?? labels.value.view)
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<UButton
|
|
42
|
+
:to="props.to"
|
|
43
|
+
:target="props.newTab ? '_blank' : undefined"
|
|
44
|
+
:rel="props.newTab ? 'noopener' : undefined"
|
|
45
|
+
icon="i-lucide-external-link"
|
|
46
|
+
size="sm"
|
|
47
|
+
color="neutral"
|
|
48
|
+
variant="ghost"
|
|
49
|
+
class="shrink-0"
|
|
50
|
+
>
|
|
51
|
+
{{ text }}
|
|
52
|
+
</UButton>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Waagerechte Balken für nominale Kategorien — Rollen, Typen, Herkünfte.
|
|
4
|
+
*
|
|
5
|
+
* Waagerecht, weil die Beschriftung aus Namen besteht und Namen unter einer
|
|
6
|
+
* senkrechten Säule schräg gestellt werden müssten. Alle Balken tragen
|
|
7
|
+
* **dieselbe** Farbe: die Kategorien haben keine eigene Ordnung, und ein
|
|
8
|
+
* Farbverlauf nach Größe würde die Länge ein zweites Mal erzählen, statt eine
|
|
9
|
+
* neue Auskunft zu geben.
|
|
10
|
+
*/
|
|
11
|
+
import { computed, ref } from 'vue'
|
|
12
|
+
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
13
|
+
|
|
14
|
+
interface BarItem {
|
|
15
|
+
key: string | number
|
|
16
|
+
label: string
|
|
17
|
+
value: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<{
|
|
21
|
+
items: BarItem[]
|
|
22
|
+
/** Sichtbare Zeilen; der Rest wandert in „Weitere". */
|
|
23
|
+
limit?: number
|
|
24
|
+
moreLabel?: string
|
|
25
|
+
emptyLabel?: string
|
|
26
|
+
}>(), {
|
|
27
|
+
limit: 6,
|
|
28
|
+
moreLabel: undefined,
|
|
29
|
+
emptyLabel: undefined,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const { number } = useChartFormat()
|
|
33
|
+
const active = ref<string | number | null>(null)
|
|
34
|
+
|
|
35
|
+
const rows = computed<BarItem[]>(() => {
|
|
36
|
+
const sorted = [...props.items].sort((a, b) => b.value - a.value)
|
|
37
|
+
if (!props.moreLabel || sorted.length <= props.limit) return sorted
|
|
38
|
+
|
|
39
|
+
const head = sorted.slice(0, props.limit)
|
|
40
|
+
const tail = sorted.slice(props.limit)
|
|
41
|
+
return [...head, {
|
|
42
|
+
key: '__more__',
|
|
43
|
+
label: props.moreLabel,
|
|
44
|
+
value: tail.reduce((sum, item) => sum + item.value, 0),
|
|
45
|
+
}]
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const max = computed(() => Math.max(1, ...rows.value.map(row => row.value)))
|
|
49
|
+
const color = baseKitChartColor(0)
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div>
|
|
54
|
+
<p v-if="!rows.length && props.emptyLabel" class="text-sm text-muted">
|
|
55
|
+
{{ props.emptyLabel }}
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<ul v-else class="space-y-2.5">
|
|
59
|
+
<li
|
|
60
|
+
v-for="row in rows"
|
|
61
|
+
:key="row.key"
|
|
62
|
+
class="grid grid-cols-[minmax(0,7rem)_1fr_auto] items-center gap-3"
|
|
63
|
+
@pointerenter="active = row.key"
|
|
64
|
+
@pointerleave="active = null"
|
|
65
|
+
>
|
|
66
|
+
<span class="truncate text-sm text-muted" :title="row.label">{{ row.label }}</span>
|
|
67
|
+
|
|
68
|
+
<!-- Die Spur ist die Fläche, der Balken die Aussage; deshalb trägt die
|
|
69
|
+
Spur nur eine Andeutung und keine zweite Farbe. -->
|
|
70
|
+
<span class="h-2.5 w-full rounded-full bg-elevated">
|
|
71
|
+
<span
|
|
72
|
+
class="block h-2.5 rounded-full transition-opacity"
|
|
73
|
+
:style="{
|
|
74
|
+
width: `${Math.max(row.value > 0 ? 2 : 0, (row.value / max) * 100)}%`,
|
|
75
|
+
backgroundColor: color,
|
|
76
|
+
opacity: active === null || active === row.key ? 1 : 0.55,
|
|
77
|
+
}"
|
|
78
|
+
/>
|
|
79
|
+
</span>
|
|
80
|
+
|
|
81
|
+
<span class="w-10 text-right text-sm font-medium tabular-nums text-highlighted">
|
|
82
|
+
{{ number(row.value) }}
|
|
83
|
+
</span>
|
|
84
|
+
</li>
|
|
85
|
+
</ul>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|