@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.
- package/LICENSE +193 -0
- package/README.md +45 -0
- package/package.json +55 -0
- package/src/ViewRenderer.vue +47 -0
- package/src/composables/use-view-data.ts +53 -0
- package/src/index.ts +29 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/format.ts +31 -0
- package/src/lib/view-binding.ts +40 -0
- package/src/primitives/Badge.vue +34 -0
- package/src/primitives/Card.vue +11 -0
- package/src/primitives/RichTextField.vue +485 -0
- package/src/primitives/StateMessage.vue +29 -0
- package/src/renderers/CalendarView.vue +118 -0
- package/src/renderers/DashboardView.vue +73 -0
- package/src/renderers/DetailView.vue +79 -0
- package/src/renderers/FormView.vue +160 -0
- package/src/renderers/GalleryView.vue +72 -0
- package/src/renderers/KanbanView.vue +87 -0
- package/src/renderers/TableView.vue +117 -0
- package/src/styles.css +5 -0
|
@@ -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>
|