@bemaestro/views 0.0.2

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.
@@ -0,0 +1,485 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
3
+ import { EditorContent, useEditor } from '@tiptap/vue-3'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import Image from '@tiptap/extension-image'
6
+ import { Placeholder } from '@tiptap/extension-placeholder'
7
+ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
8
+ import type { JSONContent } from '@tiptap/core'
9
+ import {
10
+ Bold,
11
+ Code,
12
+ Heading1,
13
+ Heading2,
14
+ Heading3,
15
+ Image as ImageIcon,
16
+ Italic,
17
+ Link as LinkIcon,
18
+ List,
19
+ ListOrdered,
20
+ Quote,
21
+ Table as TableIcon,
22
+ } from '@lucide/vue'
23
+ import type { MaestroClient } from '@bemaestro/sdk/runtime'
24
+ import { cn } from '../lib/cn.js'
25
+
26
+ /**
27
+ * Tiptap "doc + blocks" rich-text primitive (spec 19 US-19.2). Emits the raw
28
+ * Tiptap doc JSON via v-model — the server never depends on Tiptap (spec 19
29
+ * US-19.1 derives a plaintext shadow from this JSON with a pure traversal).
30
+ */
31
+
32
+ const props = withDefaults(
33
+ defineProps<{
34
+ modelValue?: JSONContent | null
35
+ client: MaestroClient
36
+ editable?: boolean
37
+ placeholder?: string
38
+ }>(),
39
+ {
40
+ modelValue: null,
41
+ editable: true,
42
+ placeholder: 'Write something, or press "/" for commands…',
43
+ },
44
+ )
45
+
46
+ const emit = defineEmits<{ 'update:modelValue': [doc: JSONContent] }>()
47
+
48
+ const EMPTY_DOC: JSONContent = { type: 'doc', content: [{ type: 'paragraph' }] }
49
+
50
+ function normalizeDoc(value: JSONContent | null | undefined): JSONContent {
51
+ return value && typeof value === 'object' && value.type === 'doc' ? value : EMPTY_DOC
52
+ }
53
+
54
+ const fileInputRef = ref<HTMLInputElement | null>(null)
55
+ const editorRootRef = ref<HTMLElement | null>(null)
56
+ const uploading = ref(false)
57
+ const uploadError = ref<string | null>(null)
58
+ /** Bumped on every transaction so toolbar `isActive` computations re-run
59
+ * (Tiptap's internal state isn't itself reactive to Vue). */
60
+ const revision = ref(0)
61
+
62
+ interface SlashItem {
63
+ key: string
64
+ label: string
65
+ icon: typeof Heading1
66
+ run: () => void
67
+ }
68
+
69
+ const slashMenu = reactive({
70
+ open: false,
71
+ query: '',
72
+ from: 0,
73
+ to: 0,
74
+ index: 0,
75
+ left: 0,
76
+ top: 0,
77
+ })
78
+
79
+ function closeSlashMenu() {
80
+ slashMenu.open = false
81
+ slashMenu.query = ''
82
+ slashMenu.index = 0
83
+ }
84
+
85
+ function slashItems(): SlashItem[] {
86
+ if (!editor.value) return []
87
+ const ed = editor.value
88
+ return [
89
+ {
90
+ key: 'h1',
91
+ label: 'Heading 1',
92
+ icon: Heading1,
93
+ run: () => ed.chain().focus().toggleHeading({ level: 1 }).run(),
94
+ },
95
+ {
96
+ key: 'h2',
97
+ label: 'Heading 2',
98
+ icon: Heading2,
99
+ run: () => ed.chain().focus().toggleHeading({ level: 2 }).run(),
100
+ },
101
+ {
102
+ key: 'h3',
103
+ label: 'Heading 3',
104
+ icon: Heading3,
105
+ run: () => ed.chain().focus().toggleHeading({ level: 3 }).run(),
106
+ },
107
+ {
108
+ key: 'bulletList',
109
+ label: 'Bullet list',
110
+ icon: List,
111
+ run: () => ed.chain().focus().toggleBulletList().run(),
112
+ },
113
+ {
114
+ key: 'orderedList',
115
+ label: 'Numbered list',
116
+ icon: ListOrdered,
117
+ run: () => ed.chain().focus().toggleOrderedList().run(),
118
+ },
119
+ {
120
+ key: 'blockquote',
121
+ label: 'Quote',
122
+ icon: Quote,
123
+ run: () => ed.chain().focus().toggleBlockquote().run(),
124
+ },
125
+ {
126
+ key: 'codeBlock',
127
+ label: 'Code block',
128
+ icon: Code,
129
+ run: () => ed.chain().focus().toggleCodeBlock().run(),
130
+ },
131
+ {
132
+ key: 'table',
133
+ label: 'Table',
134
+ icon: TableIcon,
135
+ run: () =>
136
+ ed.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
137
+ },
138
+ {
139
+ key: 'image',
140
+ label: 'Image',
141
+ icon: ImageIcon,
142
+ run: () => fileInputRef.value?.click(),
143
+ },
144
+ ]
145
+ }
146
+
147
+ const filteredSlashItems = computed(() => {
148
+ const q = slashMenu.query.trim().toLowerCase()
149
+ const items = slashItems()
150
+ if (!q) return items
151
+ return items.filter((item) => item.label.toLowerCase().includes(q))
152
+ })
153
+
154
+ function updateSlashMenu() {
155
+ if (!editor.value || !props.editable) return
156
+ const { state, view } = editor.value
157
+ const { $from } = state.selection
158
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, '')
159
+ const match = /(?:^|\s)\/(\w*)$/.exec(textBefore)
160
+ if (!match) {
161
+ closeSlashMenu()
162
+ return
163
+ }
164
+ const query = match[1] ?? ''
165
+ const from = $from.pos - query.length - 1
166
+ slashMenu.open = true
167
+ slashMenu.query = query
168
+ slashMenu.from = from
169
+ slashMenu.to = $from.pos
170
+ slashMenu.index = 0
171
+ const coords = view.coordsAtPos(from)
172
+ const root = editorRootRef.value?.getBoundingClientRect()
173
+ slashMenu.left = coords.left - (root?.left ?? 0)
174
+ slashMenu.top = coords.bottom - (root?.top ?? 0)
175
+ }
176
+
177
+ function runSlashItem(item: SlashItem | undefined) {
178
+ if (!editor.value || !item) return
179
+ editor.value.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
180
+ closeSlashMenu()
181
+ item.run()
182
+ }
183
+
184
+ const editor = useEditor({
185
+ content: normalizeDoc(props.modelValue),
186
+ editable: props.editable,
187
+ extensions: [
188
+ StarterKit,
189
+ Placeholder.configure({ placeholder: props.placeholder }),
190
+ Image.configure({ inline: false }),
191
+ Table.configure({ resizable: false }),
192
+ TableRow,
193
+ TableHeader,
194
+ TableCell,
195
+ ],
196
+ editorProps: {
197
+ attributes: { class: 'maestro-richtext-content' },
198
+ handleKeyDown: (_view, event) => {
199
+ if (!slashMenu.open) return false
200
+ const items = filteredSlashItems.value
201
+ if (event.key === 'ArrowDown') {
202
+ slashMenu.index = (slashMenu.index + 1) % Math.max(items.length, 1)
203
+ event.preventDefault()
204
+ return true
205
+ }
206
+ if (event.key === 'ArrowUp') {
207
+ slashMenu.index = (slashMenu.index - 1 + items.length) % Math.max(items.length, 1)
208
+ event.preventDefault()
209
+ return true
210
+ }
211
+ if (event.key === 'Enter' || event.key === 'Tab') {
212
+ runSlashItem(items[slashMenu.index])
213
+ event.preventDefault()
214
+ return true
215
+ }
216
+ if (event.key === 'Escape') {
217
+ closeSlashMenu()
218
+ event.preventDefault()
219
+ return true
220
+ }
221
+ return false
222
+ },
223
+ },
224
+ onUpdate: ({ editor: ed }) => {
225
+ revision.value++
226
+ updateSlashMenu()
227
+ emit('update:modelValue', ed.getJSON())
228
+ },
229
+ onSelectionUpdate: () => {
230
+ revision.value++
231
+ updateSlashMenu()
232
+ },
233
+ onTransaction: () => {
234
+ revision.value++
235
+ },
236
+ })
237
+
238
+ watch(
239
+ () => props.modelValue,
240
+ (value) => {
241
+ const ed = editor.value
242
+ if (!ed) return
243
+ const next = normalizeDoc(value)
244
+ if (JSON.stringify(ed.getJSON()) === JSON.stringify(next)) return
245
+ ed.commands.setContent(next, { emitUpdate: false })
246
+ },
247
+ )
248
+
249
+ watch(
250
+ () => props.editable,
251
+ (value) => editor.value?.setEditable(value),
252
+ )
253
+
254
+ onBeforeUnmount(() => editor.value?.destroy())
255
+
256
+ function isActive(name: string, attrs?: Record<string, unknown>): boolean {
257
+ void revision.value
258
+ return editor.value?.isActive(name, attrs) ?? false
259
+ }
260
+
261
+ function setLink() {
262
+ const ed = editor.value
263
+ if (!ed) return
264
+ const previous = ed.getAttributes('link').href as string | undefined
265
+ const url = window.prompt('Link URL', previous ?? 'https://')
266
+ if (url === null) return
267
+ if (url === '') {
268
+ ed.chain().focus().extendMarkRange('link').unsetLink().run()
269
+ return
270
+ }
271
+ ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
272
+ }
273
+
274
+ /** Uploads via the injected client's Files capability (spec 06): sign a
275
+ * direct-to-storage PUT, then upload the bytes to the returned URL. The
276
+ * `sign` response is documented as `{ fileId, uploadUrl }` but the server
277
+ * currently wraps it in `{ data }` — unwrap defensively either way. */
278
+ async function uploadImage(file: File): Promise<string | null> {
279
+ uploading.value = true
280
+ uploadError.value = null
281
+ try {
282
+ const res = (await props.client.files.sign({
283
+ filename: file.name,
284
+ mime: file.type || 'application/octet-stream',
285
+ size: file.size,
286
+ })) as unknown as {
287
+ data?: { fileId: string; uploadMode?: string; uploadUrl?: string }
288
+ fileId?: string
289
+ uploadMode?: string
290
+ uploadUrl?: string
291
+ }
292
+ const info = res.data ?? res
293
+ if (!info.fileId || !info.uploadUrl || (info.uploadMode && info.uploadMode !== 'single')) {
294
+ throw new Error('Image is too large for inline upload')
295
+ }
296
+ const putResponse = await fetch(info.uploadUrl, {
297
+ method: 'PUT',
298
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
299
+ body: file,
300
+ })
301
+ if (!putResponse.ok) throw new Error('Upload failed')
302
+ const params = new URLSearchParams({ width: '1200', format: 'webp' })
303
+ return `/v1/${props.client.app}/files/${info.fileId}/raw?${params.toString()}`
304
+ } catch (err) {
305
+ uploadError.value = err instanceof Error ? err.message : String(err)
306
+ return null
307
+ } finally {
308
+ uploading.value = false
309
+ }
310
+ }
311
+
312
+ async function onFileSelected(event: Event) {
313
+ const input = event.target as HTMLInputElement
314
+ const file = input.files?.[0]
315
+ input.value = ''
316
+ if (!file || !editor.value) return
317
+ const url = await uploadImage(file)
318
+ if (url) editor.value.chain().focus().setImage({ src: url, alt: file.name }).run()
319
+ }
320
+
321
+ interface ToolbarButton {
322
+ key: string
323
+ label: string
324
+ icon: typeof Bold
325
+ active?: () => boolean
326
+ run: () => void
327
+ }
328
+
329
+ const toolbarButtons = computed<ToolbarButton[]>(() => {
330
+ const ed = editor.value
331
+ if (!ed) return []
332
+ return [
333
+ { key: 'bold', label: 'Bold', icon: Bold, active: () => isActive('bold'), run: () => ed.chain().focus().toggleBold().run() },
334
+ { key: 'italic', label: 'Italic', icon: Italic, active: () => isActive('italic'), run: () => ed.chain().focus().toggleItalic().run() },
335
+ { key: 'h1', label: 'Heading 1', icon: Heading1, active: () => isActive('heading', { level: 1 }), run: () => ed.chain().focus().toggleHeading({ level: 1 }).run() },
336
+ { key: 'h2', label: 'Heading 2', icon: Heading2, active: () => isActive('heading', { level: 2 }), run: () => ed.chain().focus().toggleHeading({ level: 2 }).run() },
337
+ { key: 'h3', label: 'Heading 3', icon: Heading3, active: () => isActive('heading', { level: 3 }), run: () => ed.chain().focus().toggleHeading({ level: 3 }).run() },
338
+ { key: 'bulletList', label: 'Bullet list', icon: List, active: () => isActive('bulletList'), run: () => ed.chain().focus().toggleBulletList().run() },
339
+ { key: 'orderedList', label: 'Numbered list', icon: ListOrdered, active: () => isActive('orderedList'), run: () => ed.chain().focus().toggleOrderedList().run() },
340
+ { key: 'blockquote', label: 'Quote', icon: Quote, active: () => isActive('blockquote'), run: () => ed.chain().focus().toggleBlockquote().run() },
341
+ { key: 'codeBlock', label: 'Code block', icon: Code, active: () => isActive('codeBlock'), run: () => ed.chain().focus().toggleCodeBlock().run() },
342
+ { key: 'link', label: 'Link', icon: LinkIcon, active: () => isActive('link'), run: setLink },
343
+ { key: 'table', label: 'Table', icon: TableIcon, run: () => ed.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
344
+ { key: 'image', label: 'Image', icon: ImageIcon, run: () => fileInputRef.value?.click() },
345
+ ]
346
+ })
347
+ </script>
348
+
349
+ <template>
350
+ <div ref="editorRootRef" class="relative flex flex-col gap-2">
351
+ <div
352
+ v-if="editable"
353
+ class="flex flex-wrap items-center gap-1 rounded-md border bg-background p-1"
354
+ >
355
+ <button
356
+ v-for="button in toolbarButtons"
357
+ :key="button.key"
358
+ type="button"
359
+ :aria-label="button.label"
360
+ :title="button.label"
361
+ :disabled="uploading && button.key === 'image'"
362
+ :class="
363
+ cn(
364
+ 'inline-flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50',
365
+ button.active?.() && 'bg-muted text-foreground',
366
+ )
367
+ "
368
+ @click="button.run"
369
+ >
370
+ <component :is="button.icon" class="size-4" />
371
+ </button>
372
+ <span v-if="uploading" class="ml-2 text-xs text-muted-foreground">Uploading…</span>
373
+ </div>
374
+
375
+ <EditorContent
376
+ :editor="editor"
377
+ :class="cn('rounded-md border bg-background px-3 py-2 text-sm', !editable && 'border-none p-0')"
378
+ />
379
+
380
+ <p v-if="uploadError" class="text-xs text-destructive">{{ uploadError }}</p>
381
+
382
+ <input
383
+ ref="fileInputRef"
384
+ type="file"
385
+ accept="image/*"
386
+ class="hidden"
387
+ @change="onFileSelected"
388
+ />
389
+
390
+ <ul
391
+ v-if="slashMenu.open && filteredSlashItems.length > 0"
392
+ class="absolute z-50 w-56 rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
393
+ :style="{ left: `${slashMenu.left}px`, top: `${slashMenu.top}px` }"
394
+ >
395
+ <li v-for="(item, index) in filteredSlashItems" :key="item.key">
396
+ <button
397
+ type="button"
398
+ :class="
399
+ cn(
400
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted',
401
+ index === slashMenu.index && 'bg-muted',
402
+ )
403
+ "
404
+ @mousedown.prevent="runSlashItem(item)"
405
+ >
406
+ <component :is="item.icon" class="size-4 text-muted-foreground" />
407
+ {{ item.label }}
408
+ </button>
409
+ </li>
410
+ </ul>
411
+ </div>
412
+ </template>
413
+
414
+ <style>
415
+ .maestro-richtext-content {
416
+ outline: none;
417
+ min-height: 8rem;
418
+ }
419
+ .maestro-richtext-content p.is-editor-empty:first-child::before {
420
+ content: attr(data-placeholder);
421
+ float: left;
422
+ height: 0;
423
+ pointer-events: none;
424
+ color: var(--muted-foreground, #9ca3af);
425
+ }
426
+ .maestro-richtext-content h1 {
427
+ font-size: 1.5rem;
428
+ font-weight: 700;
429
+ margin: 0.75rem 0 0.5rem;
430
+ }
431
+ .maestro-richtext-content h2 {
432
+ font-size: 1.25rem;
433
+ font-weight: 700;
434
+ margin: 0.75rem 0 0.5rem;
435
+ }
436
+ .maestro-richtext-content h3 {
437
+ font-size: 1.1rem;
438
+ font-weight: 600;
439
+ margin: 0.75rem 0 0.5rem;
440
+ }
441
+ .maestro-richtext-content p {
442
+ margin: 0.4rem 0;
443
+ }
444
+ .maestro-richtext-content ul,
445
+ .maestro-richtext-content ol {
446
+ padding-left: 1.5rem;
447
+ margin: 0.4rem 0;
448
+ }
449
+ .maestro-richtext-content blockquote {
450
+ border-left: 3px solid var(--border, #d1d5db);
451
+ padding-left: 0.75rem;
452
+ color: var(--muted-foreground, #6b7280);
453
+ margin: 0.5rem 0;
454
+ }
455
+ .maestro-richtext-content pre {
456
+ background: var(--muted, #f3f4f6);
457
+ border-radius: 0.375rem;
458
+ padding: 0.75rem;
459
+ overflow-x: auto;
460
+ font-family: ui-monospace, monospace;
461
+ font-size: 0.85rem;
462
+ }
463
+ .maestro-richtext-content code {
464
+ font-family: ui-monospace, monospace;
465
+ }
466
+ .maestro-richtext-content img {
467
+ max-width: 100%;
468
+ border-radius: 0.375rem;
469
+ }
470
+ .maestro-richtext-content table {
471
+ border-collapse: collapse;
472
+ width: 100%;
473
+ margin: 0.5rem 0;
474
+ }
475
+ .maestro-richtext-content th,
476
+ .maestro-richtext-content td {
477
+ border: 1px solid var(--border, #d1d5db);
478
+ padding: 0.375rem 0.5rem;
479
+ text-align: left;
480
+ }
481
+ .maestro-richtext-content th {
482
+ background: var(--muted, #f3f4f6);
483
+ font-weight: 600;
484
+ }
485
+ </style>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { cn } from '../lib/cn.js'
3
+
4
+ defineProps<{
5
+ /** Visual intent — drives the icon glyph and tone. */
6
+ variant?: 'empty' | 'loading' | 'error'
7
+ title: string
8
+ description?: string
9
+ }>()
10
+ </script>
11
+
12
+ <template>
13
+ <div
14
+ :class="
15
+ cn(
16
+ 'flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed px-6 py-12 text-center',
17
+ variant === 'error' ? 'border-destructive/40 text-destructive' : 'text-muted-foreground',
18
+ )
19
+ "
20
+ role="status"
21
+ >
22
+ <div
23
+ v-if="variant === 'loading'"
24
+ class="mb-2 size-5 animate-spin rounded-full border-2 border-current border-t-transparent"
25
+ />
26
+ <p class="text-sm font-medium">{{ title }}</p>
27
+ <p v-if="description" class="text-xs">{{ description }}</p>
28
+ </div>
29
+ </template>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, toRef } from 'vue'
3
+ import {
4
+ addMonths,
5
+ eachDayOfInterval,
6
+ endOfMonth,
7
+ endOfWeek,
8
+ format,
9
+ isSameDay,
10
+ isSameMonth,
11
+ startOfMonth,
12
+ startOfWeek,
13
+ } from 'date-fns'
14
+ import type { ListParams, MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
15
+ import { useViewData, type DataRecord } from '../composables/use-view-data.js'
16
+ import { checkViewBinding, configString } from '../lib/view-binding.js'
17
+ import { recordLabel } from '../lib/format.js'
18
+ import Badge from '../primitives/Badge.vue'
19
+ import StateMessage from '../primitives/StateMessage.vue'
20
+
21
+ const props = defineProps<{ view: RuntimeView; client: MaestroClient }>()
22
+
23
+ const binding = computed(() => checkViewBinding(props.view))
24
+ const dateField = computed(() => configString(props.view, 'dateField'))
25
+ const titleField = computed(() => configString(props.view, 'titleField'))
26
+ const colorField = computed(() => configString(props.view, 'colorField'))
27
+
28
+ const cursor = ref(startOfMonth(new Date()))
29
+ const gridStart = computed(() => startOfWeek(startOfMonth(cursor.value)))
30
+ const gridEnd = computed(() => endOfWeek(endOfMonth(cursor.value)))
31
+ const days = computed(() => eachDayOfInterval({ start: gridStart.value, end: gridEnd.value }))
32
+
33
+ // Fetch only the visible window (spec 17 NFR: no whole-collection loads).
34
+ const collectionKey = toRef(() => props.view.collectionKey)
35
+ const params = computed<ListParams>(() => {
36
+ const field = dateField.value
37
+ if (!field) return { limit: 500 }
38
+ return {
39
+ limit: 500,
40
+ sort: field,
41
+ filter: {
42
+ [field]: { gte: gridStart.value.toISOString(), lt: gridEnd.value.toISOString() },
43
+ },
44
+ }
45
+ })
46
+ const { rows, loading, error } = useViewData(props.client, { collectionKey, params })
47
+
48
+ function eventsOn(day: Date): DataRecord[] {
49
+ const field = dateField.value
50
+ if (!field) return []
51
+ return rows.value.filter((r) => {
52
+ const raw = r[field]
53
+ if (typeof raw !== 'string' && !(raw instanceof Date)) return false
54
+ const d = new Date(raw as string)
55
+ return !Number.isNaN(d.getTime()) && isSameDay(d, day)
56
+ })
57
+ }
58
+
59
+ const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
60
+ function shift(delta: number) {
61
+ cursor.value = addMonths(cursor.value, delta)
62
+ }
63
+ </script>
64
+
65
+ <template>
66
+ <StateMessage
67
+ v-if="!binding.ok"
68
+ variant="error"
69
+ title="Misconfigured calendar"
70
+ :description="binding.reason"
71
+ />
72
+ <div v-else class="flex flex-col gap-3">
73
+ <div class="flex items-center justify-between">
74
+ <h3 class="text-sm font-semibold">{{ format(cursor, 'MMMM yyyy') }}</h3>
75
+ <div class="flex items-center gap-2 text-xs">
76
+ <button class="rounded-md border px-2 py-1" @click="shift(-1)">‹</button>
77
+ <button class="rounded-md border px-2 py-1" @click="cursor = startOfMonth(new Date())">
78
+ Today
79
+ </button>
80
+ <button class="rounded-md border px-2 py-1" @click="shift(1)">›</button>
81
+ </div>
82
+ </div>
83
+
84
+ <StateMessage v-if="error" variant="error" title="Failed to load" :description="error.message" />
85
+ <div v-else class="overflow-hidden rounded-lg border">
86
+ <div class="grid grid-cols-7 border-b bg-muted/50 text-xs text-muted-foreground">
87
+ <div v-for="w in weekdayLabels" :key="w" class="px-2 py-1 text-center font-medium">
88
+ {{ w }}
89
+ </div>
90
+ </div>
91
+ <div class="grid grid-cols-7">
92
+ <div
93
+ v-for="day in days"
94
+ :key="day.toISOString()"
95
+ class="min-h-24 border-b border-r p-1 last:border-r-0"
96
+ :class="isSameMonth(day, cursor) ? '' : 'bg-muted/30 text-muted-foreground'"
97
+ >
98
+ <div class="mb-1 flex items-center justify-between px-1">
99
+ <span class="text-xs" :class="isSameDay(day, new Date()) ? 'font-bold text-primary' : ''">
100
+ {{ format(day, 'd') }}
101
+ </span>
102
+ <span v-if="loading" class="size-1.5 animate-pulse rounded-full bg-muted-foreground/40" />
103
+ </div>
104
+ <div class="flex flex-col gap-1">
105
+ <Badge
106
+ v-for="ev in eventsOn(day)"
107
+ :key="String(ev.id)"
108
+ class="block w-full truncate text-[11px]"
109
+ :color="colorField ? String(ev[colorField] ?? '') : undefined"
110
+ >
111
+ {{ recordLabel(ev, titleField) }}
112
+ </Badge>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watchEffect } from 'vue'
3
+ import type { FilterSpec, MaestroClient, RuntimeView } from '@bemaestro/sdk/runtime'
4
+ import Card from '../primitives/Card.vue'
5
+ import StateMessage from '../primitives/StateMessage.vue'
6
+
7
+ interface Widget {
8
+ type: 'metric' | 'count' | 'sum' | 'chart'
9
+ label?: string
10
+ collection: string
11
+ filter?: FilterSpec
12
+ }
13
+
14
+ const props = defineProps<{ view: RuntimeView; client: MaestroClient }>()
15
+
16
+ const widgets = computed<Widget[]>(() => (props.view.config as { widgets?: Widget[] })?.widgets ?? [])
17
+
18
+ interface WidgetState {
19
+ value: number | null
20
+ loading: boolean
21
+ supported: boolean
22
+ error?: string
23
+ }
24
+ const states = ref<WidgetState[]>([])
25
+
26
+ watchEffect(async () => {
27
+ const list = widgets.value
28
+ states.value = list.map(() => ({ value: null, loading: true, supported: true }))
29
+ await Promise.all(
30
+ list.map(async (w, i) => {
31
+ // Only counts have a bounded server aggregate today (?withTotal=1); sum/
32
+ // chart need server-side aggregates not yet exposed by the Data API.
33
+ if (w.type === 'sum' || w.type === 'chart') {
34
+ states.value[i] = { value: null, loading: false, supported: false }
35
+ return
36
+ }
37
+ try {
38
+ const res = await props.client
39
+ .collection(w.collection)
40
+ .list({ limit: 1, withTotal: 1, filter: w.filter })
41
+ states.value[i] = { value: res.meta?.total ?? 0, loading: false, supported: true }
42
+ } catch (err) {
43
+ states.value[i] = {
44
+ value: null,
45
+ loading: false,
46
+ supported: true,
47
+ error: err instanceof Error ? err.message : String(err),
48
+ }
49
+ }
50
+ }),
51
+ )
52
+ })
53
+ </script>
54
+
55
+ <template>
56
+ <StateMessage
57
+ v-if="widgets.length === 0"
58
+ variant="empty"
59
+ title="No widgets configured"
60
+ description="This dashboard has no metrics defined yet."
61
+ />
62
+ <div v-else class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
63
+ <Card v-for="(w, i) in widgets" :key="i" class="p-4">
64
+ <p class="text-xs text-muted-foreground">{{ w.label ?? w.collection }}</p>
65
+ <p v-if="states[i]?.loading" class="mt-2 h-8 w-16 animate-pulse rounded bg-muted" />
66
+ <p v-else-if="!states[i]?.supported" class="mt-2 text-sm text-muted-foreground">
67
+ Not available yet
68
+ </p>
69
+ <p v-else-if="states[i]?.error" class="mt-2 text-sm text-destructive">Error</p>
70
+ <p v-else class="mt-1 text-3xl font-semibold tabular-nums">{{ states[i]?.value }}</p>
71
+ </Card>
72
+ </div>
73
+ </template>