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