@joewinke/jatui 0.5.2 → 0.6.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/CLAUDE.md +7 -0
- package/dist/components/MediaCapture.svelte +372 -0
- package/dist/components/MediaCapture.svelte.d.ts +27 -0
- package/dist/components/MediaCaptureIcon.svelte +134 -0
- package/dist/components/MediaCaptureIcon.svelte.d.ts +8 -0
- package/dist/components/MultimodalCaptureField.svelte +168 -0
- package/dist/components/MultimodalCaptureField.svelte.d.ts +33 -0
- package/dist/components/table/DataTable.svelte +12 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +6 -3
package/CLAUDE.md
CHANGED
|
@@ -79,6 +79,8 @@ Then import:
|
|
|
79
79
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
80
80
|
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
81
81
|
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker) and talk-time legend. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and keyboard map, and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek) and zoom over the band. | `src`, `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
82
|
+
| `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
|
|
83
|
+
| `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
|
|
82
84
|
|
|
83
85
|
**`VideoPlayer` vs `TranscriptPlayer`:** VideoPlayer is the generalization — it does everything TranscriptPlayer does plus the timeline band, and its props are structurally compatible with TranscriptPlayer's (`TranscriptSegment[]`, `TranscriptChapter[]`, `TranscriptBookmark[]`, `TranscriptSpeakerTalkTime[]` all pass straight through). `TranscriptPlayer` stays exported and unchanged for the existing meeting-recordings viewer; use `VideoPlayer` for anything new.
|
|
84
86
|
|
|
@@ -289,6 +291,11 @@ import { DAYS_OF_WEEK, DAYS_SHORT, LOCATION_LABELS, LOCATION_ICONS } from "jatui
|
|
|
289
291
|
import type { FileDropzoneFile } from "jatui"
|
|
290
292
|
// FileDropzoneFile — { file?: File, name, mimeType, size?, url?, meta? }
|
|
291
293
|
|
|
294
|
+
// MediaCapture / MultimodalCaptureField types
|
|
295
|
+
import type { MediaCaptureKind, MediaCaptureResult } from "jatui"
|
|
296
|
+
// MediaCaptureKind — 'audio' | 'video' | 'screen'
|
|
297
|
+
// MediaCaptureResult — { kind, blob: Blob, mimeType, durationSeconds }
|
|
298
|
+
|
|
292
299
|
// SmartImageEditor types
|
|
293
300
|
import type {
|
|
294
301
|
ImageProcessingSpec, ImageVariantSpec,
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type MediaCaptureKind = 'audio' | 'video' | 'screen'
|
|
3
|
+
|
|
4
|
+
export interface MediaCaptureResult {
|
|
5
|
+
kind: MediaCaptureKind
|
|
6
|
+
blob: Blob
|
|
7
|
+
mimeType: string
|
|
8
|
+
durationSeconds: number
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
/**
|
|
14
|
+
* MediaCapture — one-tap audio/selfie-video/screen(+mic) recording.
|
|
15
|
+
*
|
|
16
|
+
* A host-agnostic recording PRIMITIVE: it owns permission requests, MIME
|
|
17
|
+
* negotiation, the record/stop state machine, the live self-preview for
|
|
18
|
+
* video, and mixing screen + mic audio into a single track for `screen`
|
|
19
|
+
* captures. It does NOT know about uploads, transcription, or storage —
|
|
20
|
+
* every finished clip is handed to `onCapture` as a plain Blob, and the
|
|
21
|
+
* host app decides what to do with it (upload endpoint, local preview,
|
|
22
|
+
* whatever). `onCapture` may be async; the component shows a "processing"
|
|
23
|
+
* state while it resolves so the host can do slow work (upload/transcribe)
|
|
24
|
+
* without the user re-triggering a second recording mid-flight.
|
|
25
|
+
*
|
|
26
|
+
* Screen capture mixes the display's own audio (when the browser/source
|
|
27
|
+
* offers it) with the microphone via a single AudioContext destination —
|
|
28
|
+
* MediaRecorder support for multiple simultaneous audio tracks on one
|
|
29
|
+
* MediaStream is inconsistent across browsers, so mixing to one track is
|
|
30
|
+
* the portable choice for "narrate over a shared tab."
|
|
31
|
+
*
|
|
32
|
+
* Purely additive: renders nothing when neither getUserMedia nor
|
|
33
|
+
* getDisplayMedia exists, and never throws — a permission denial or
|
|
34
|
+
* cancelled screen-share surfaces as inline `errorMessage`, and the
|
|
35
|
+
* component returns to idle so the caller can retry.
|
|
36
|
+
*/
|
|
37
|
+
import MediaCaptureIcon from './MediaCaptureIcon.svelte'
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
/** Which capture modes to offer. Default: all three. */
|
|
41
|
+
modes?: MediaCaptureKind[]
|
|
42
|
+
/** Auto-stop ceiling in ms. Default 2 minutes — "quick capture", not a full recording studio. */
|
|
43
|
+
maxDurationMs?: number
|
|
44
|
+
/** Called with the finished clip. May return a Promise — the component
|
|
45
|
+
* stays in "processing" until it settles. */
|
|
46
|
+
onCapture: (result: MediaCaptureResult) => void | Promise<void>
|
|
47
|
+
/** Prompt text above the mode buttons. */
|
|
48
|
+
label?: string
|
|
49
|
+
hint?: string
|
|
50
|
+
disabled?: boolean
|
|
51
|
+
/** Smaller footprint (icon-only mode buttons) for embedding inside a
|
|
52
|
+
* larger composite field like MultimodalCaptureField. */
|
|
53
|
+
compact?: boolean
|
|
54
|
+
class?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let {
|
|
58
|
+
modes = ['audio', 'video', 'screen'],
|
|
59
|
+
maxDurationMs = 120_000,
|
|
60
|
+
onCapture,
|
|
61
|
+
label = 'Record a note',
|
|
62
|
+
hint,
|
|
63
|
+
disabled = false,
|
|
64
|
+
compact = false,
|
|
65
|
+
class: className = ''
|
|
66
|
+
}: Props = $props()
|
|
67
|
+
|
|
68
|
+
type Phase = 'idle' | 'recording' | 'processing' | 'error'
|
|
69
|
+
let phase = $state<Phase>('idle')
|
|
70
|
+
let activeKind = $state<MediaCaptureKind | null>(null)
|
|
71
|
+
let errorMessage = $state('')
|
|
72
|
+
let elapsedSec = $state(0)
|
|
73
|
+
|
|
74
|
+
let recorder: MediaRecorder | null = null
|
|
75
|
+
let primaryStream: MediaStream | null = null
|
|
76
|
+
let secondaryStream: MediaStream | null = null
|
|
77
|
+
let audioCtx: AudioContext | null = null
|
|
78
|
+
let recordedStream: MediaStream | null = null
|
|
79
|
+
let chunks: Blob[] = []
|
|
80
|
+
let mimeUsed = ''
|
|
81
|
+
let startedAt = 0
|
|
82
|
+
let autoStopTimer: ReturnType<typeof setTimeout> | null = null
|
|
83
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null
|
|
84
|
+
|
|
85
|
+
let videoPreviewEl = $state<HTMLVideoElement | null>(null)
|
|
86
|
+
|
|
87
|
+
const hasUserMedia =
|
|
88
|
+
typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
|
89
|
+
const hasDisplayMedia =
|
|
90
|
+
typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getDisplayMedia
|
|
91
|
+
const hasRecorder = typeof MediaRecorder !== 'undefined'
|
|
92
|
+
const supported = hasRecorder && (hasUserMedia || hasDisplayMedia)
|
|
93
|
+
|
|
94
|
+
const showAudio = $derived(modes.includes('audio') && hasUserMedia)
|
|
95
|
+
const showVideo = $derived(modes.includes('video') && hasUserMedia)
|
|
96
|
+
const showScreen = $derived(modes.includes('screen') && hasDisplayMedia)
|
|
97
|
+
|
|
98
|
+
function pickMime(candidates: string[]): string {
|
|
99
|
+
for (const c of candidates) {
|
|
100
|
+
if (MediaRecorder.isTypeSupported(c)) return c
|
|
101
|
+
}
|
|
102
|
+
return ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stopAllTracks(stream: MediaStream | null) {
|
|
106
|
+
stream?.getTracks().forEach((t) => t.stop())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cleanup() {
|
|
110
|
+
stopAllTracks(primaryStream)
|
|
111
|
+
stopAllTracks(secondaryStream)
|
|
112
|
+
stopAllTracks(recordedStream)
|
|
113
|
+
primaryStream = null
|
|
114
|
+
secondaryStream = null
|
|
115
|
+
recordedStream = null
|
|
116
|
+
if (audioCtx) {
|
|
117
|
+
audioCtx.close().catch(() => {})
|
|
118
|
+
audioCtx = null
|
|
119
|
+
}
|
|
120
|
+
recorder = null
|
|
121
|
+
chunks = []
|
|
122
|
+
if (autoStopTimer) {
|
|
123
|
+
clearTimeout(autoStopTimer)
|
|
124
|
+
autoStopTimer = null
|
|
125
|
+
}
|
|
126
|
+
if (tickTimer) {
|
|
127
|
+
clearInterval(tickTimer)
|
|
128
|
+
tickTimer = null
|
|
129
|
+
}
|
|
130
|
+
if (videoPreviewEl) videoPreviewEl.srcObject = null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function start(kind: MediaCaptureKind) {
|
|
134
|
+
if (!supported || disabled || phase === 'recording' || phase === 'processing') return
|
|
135
|
+
errorMessage = ''
|
|
136
|
+
try {
|
|
137
|
+
let stream: MediaStream
|
|
138
|
+
if (kind === 'audio') {
|
|
139
|
+
primaryStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
140
|
+
stream = primaryStream
|
|
141
|
+
mimeUsed = pickMime([
|
|
142
|
+
'audio/webm;codecs=opus',
|
|
143
|
+
'audio/webm',
|
|
144
|
+
'audio/mp4',
|
|
145
|
+
'audio/ogg'
|
|
146
|
+
])
|
|
147
|
+
} else if (kind === 'video') {
|
|
148
|
+
primaryStream = await navigator.mediaDevices.getUserMedia({
|
|
149
|
+
audio: true,
|
|
150
|
+
video: { facingMode: 'user' }
|
|
151
|
+
})
|
|
152
|
+
stream = primaryStream
|
|
153
|
+
mimeUsed = pickMime([
|
|
154
|
+
'video/webm;codecs=vp9,opus',
|
|
155
|
+
'video/webm;codecs=vp8,opus',
|
|
156
|
+
'video/webm',
|
|
157
|
+
'video/mp4'
|
|
158
|
+
])
|
|
159
|
+
} else {
|
|
160
|
+
primaryStream = await navigator.mediaDevices.getDisplayMedia({
|
|
161
|
+
video: true,
|
|
162
|
+
audio: true
|
|
163
|
+
})
|
|
164
|
+
try {
|
|
165
|
+
secondaryStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
166
|
+
} catch {
|
|
167
|
+
secondaryStream = null
|
|
168
|
+
}
|
|
169
|
+
const AudioCtxCls =
|
|
170
|
+
window.AudioContext ||
|
|
171
|
+
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
|
172
|
+
.webkitAudioContext
|
|
173
|
+
audioCtx = new AudioCtxCls()
|
|
174
|
+
const dest = audioCtx.createMediaStreamDestination()
|
|
175
|
+
for (const s of [primaryStream, secondaryStream]) {
|
|
176
|
+
const tracks = s?.getAudioTracks() ?? []
|
|
177
|
+
if (tracks.length) {
|
|
178
|
+
audioCtx.createMediaStreamSource(new MediaStream(tracks)).connect(dest)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
stream = new MediaStream([
|
|
182
|
+
...primaryStream.getVideoTracks(),
|
|
183
|
+
...dest.stream.getAudioTracks()
|
|
184
|
+
])
|
|
185
|
+
primaryStream.getVideoTracks()[0]?.addEventListener('ended', () => {
|
|
186
|
+
if (phase === 'recording') stop()
|
|
187
|
+
})
|
|
188
|
+
mimeUsed = pickMime([
|
|
189
|
+
'video/webm;codecs=vp9,opus',
|
|
190
|
+
'video/webm;codecs=vp8,opus',
|
|
191
|
+
'video/webm'
|
|
192
|
+
])
|
|
193
|
+
}
|
|
194
|
+
recordedStream = stream
|
|
195
|
+
|
|
196
|
+
if (kind === 'video' && videoPreviewEl) {
|
|
197
|
+
videoPreviewEl.srcObject = stream
|
|
198
|
+
void videoPreviewEl.play().catch(() => {})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
chunks = []
|
|
202
|
+
const rec = new MediaRecorder(stream, mimeUsed ? { mimeType: mimeUsed } : undefined)
|
|
203
|
+
rec.ondataavailable = (e) => {
|
|
204
|
+
if (e.data.size > 0) chunks.push(e.data)
|
|
205
|
+
}
|
|
206
|
+
rec.onstop = () => void finish(kind)
|
|
207
|
+
rec.start(250)
|
|
208
|
+
recorder = rec
|
|
209
|
+
startedAt = performance.now()
|
|
210
|
+
elapsedSec = 0
|
|
211
|
+
activeKind = kind
|
|
212
|
+
phase = 'recording'
|
|
213
|
+
tickTimer = setInterval(() => {
|
|
214
|
+
elapsedSec = Math.floor((performance.now() - startedAt) / 1000)
|
|
215
|
+
}, 500)
|
|
216
|
+
autoStopTimer = setTimeout(stop, maxDurationMs)
|
|
217
|
+
} catch {
|
|
218
|
+
cleanup()
|
|
219
|
+
phase = 'error'
|
|
220
|
+
activeKind = null
|
|
221
|
+
errorMessage =
|
|
222
|
+
kind === 'screen'
|
|
223
|
+
? 'Screen sharing was cancelled or is not available.'
|
|
224
|
+
: 'Camera/microphone permission is needed to record.'
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stop() {
|
|
229
|
+
if (recorder && recorder.state !== 'inactive') {
|
|
230
|
+
recorder.stop() // → onstop → finish()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function finish(kind: MediaCaptureKind) {
|
|
235
|
+
const durationSeconds = (performance.now() - startedAt) / 1000
|
|
236
|
+
const blob = new Blob(chunks, { type: mimeUsed || 'video/webm' })
|
|
237
|
+
cleanup()
|
|
238
|
+
|
|
239
|
+
if (blob.size === 0) {
|
|
240
|
+
phase = 'idle'
|
|
241
|
+
activeKind = null
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
phase = 'processing'
|
|
246
|
+
try {
|
|
247
|
+
await onCapture({ kind, blob, mimeType: mimeUsed, durationSeconds })
|
|
248
|
+
phase = 'idle'
|
|
249
|
+
activeKind = null
|
|
250
|
+
} catch {
|
|
251
|
+
phase = 'error'
|
|
252
|
+
errorMessage = "Couldn't save that recording — please try again."
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function modeIcon(kind: MediaCaptureKind): string {
|
|
257
|
+
return kind === 'audio' ? 'mic' : kind === 'video' ? 'video' : 'monitor'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function modeLabel(kind: MediaCaptureKind): string {
|
|
261
|
+
return kind === 'audio' ? 'Voice' : kind === 'video' ? 'Video' : 'Show us'
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function formatElapsed(sec: number): string {
|
|
265
|
+
const m = Math.floor(sec / 60)
|
|
266
|
+
const s = sec % 60
|
|
267
|
+
return `${m}:${String(s).padStart(2, '0')}`
|
|
268
|
+
}
|
|
269
|
+
</script>
|
|
270
|
+
|
|
271
|
+
{#if supported}
|
|
272
|
+
<div class="media-capture {className}">
|
|
273
|
+
{#if !compact && (label || hint)}
|
|
274
|
+
<div class="mb-2">
|
|
275
|
+
{#if label}<p class="text-base-content text-sm font-medium">{label}</p>{/if}
|
|
276
|
+
{#if hint}<p class="text-base-content/60 text-xs">{hint}</p>{/if}
|
|
277
|
+
</div>
|
|
278
|
+
{/if}
|
|
279
|
+
|
|
280
|
+
{#if phase === 'recording'}
|
|
281
|
+
<div class="flex items-center gap-3">
|
|
282
|
+
<span class="media-capture-dot"></span>
|
|
283
|
+
<span class="text-base-content/70 text-xs tabular-nums">
|
|
284
|
+
{modeLabel(activeKind as MediaCaptureKind)} — {formatElapsed(elapsedSec)}
|
|
285
|
+
</span>
|
|
286
|
+
<button type="button" class="btn btn-error btn-xs rounded-field gap-1" onclick={stop}>
|
|
287
|
+
<MediaCaptureIcon name="square" size={12} /> Stop
|
|
288
|
+
</button>
|
|
289
|
+
</div>
|
|
290
|
+
{#if activeKind === 'video'}
|
|
291
|
+
<video
|
|
292
|
+
bind:this={videoPreviewEl}
|
|
293
|
+
class="border-base-300 mt-2 h-24 w-24 rounded-field border object-cover"
|
|
294
|
+
muted
|
|
295
|
+
playsinline
|
|
296
|
+
>
|
|
297
|
+
<track kind="captions" />
|
|
298
|
+
</video>
|
|
299
|
+
{/if}
|
|
300
|
+
{:else if phase === 'processing'}
|
|
301
|
+
<div class="flex items-center gap-2">
|
|
302
|
+
<MediaCaptureIcon name="loader" size={14} class="animate-spin" />
|
|
303
|
+
<span class="text-base-content/70 text-xs">Saving…</span>
|
|
304
|
+
</div>
|
|
305
|
+
{:else}
|
|
306
|
+
<div class="flex flex-wrap gap-2">
|
|
307
|
+
{#if showAudio}
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
class="btn btn-outline btn-xs rounded-field gap-1"
|
|
311
|
+
{disabled}
|
|
312
|
+
onclick={() => start('audio')}
|
|
313
|
+
>
|
|
314
|
+
<MediaCaptureIcon name="mic" size={12} /> {modeLabel('audio')}
|
|
315
|
+
</button>
|
|
316
|
+
{/if}
|
|
317
|
+
{#if showVideo}
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
class="btn btn-outline btn-xs rounded-field gap-1"
|
|
321
|
+
{disabled}
|
|
322
|
+
onclick={() => start('video')}
|
|
323
|
+
>
|
|
324
|
+
<MediaCaptureIcon name="video" size={12} /> {modeLabel('video')}
|
|
325
|
+
</button>
|
|
326
|
+
{/if}
|
|
327
|
+
{#if showScreen}
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
class="btn btn-outline btn-xs rounded-field gap-1"
|
|
331
|
+
{disabled}
|
|
332
|
+
onclick={() => start('screen')}
|
|
333
|
+
>
|
|
334
|
+
<MediaCaptureIcon name="monitor" size={12} /> {modeLabel('screen')}
|
|
335
|
+
</button>
|
|
336
|
+
{/if}
|
|
337
|
+
</div>
|
|
338
|
+
{#if phase === 'error' && errorMessage}
|
|
339
|
+
<p class="text-warning mt-1.5 text-xs" role="status" aria-live="polite">
|
|
340
|
+
{errorMessage}
|
|
341
|
+
</p>
|
|
342
|
+
{/if}
|
|
343
|
+
{/if}
|
|
344
|
+
</div>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
<style>
|
|
348
|
+
.media-capture-dot {
|
|
349
|
+
display: inline-block;
|
|
350
|
+
width: 0.5rem;
|
|
351
|
+
height: 0.5rem;
|
|
352
|
+
border-radius: 9999px;
|
|
353
|
+
background: var(--color-error);
|
|
354
|
+
animation: media-capture-pulse 1.4s ease-out infinite;
|
|
355
|
+
}
|
|
356
|
+
@keyframes media-capture-pulse {
|
|
357
|
+
0% {
|
|
358
|
+
box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-error) 55%, transparent);
|
|
359
|
+
}
|
|
360
|
+
70% {
|
|
361
|
+
box-shadow: 0 0 0 8px color-mix(in oklch, var(--color-error) 0%, transparent);
|
|
362
|
+
}
|
|
363
|
+
100% {
|
|
364
|
+
box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-error) 0%, transparent);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
@media (prefers-reduced-motion: reduce) {
|
|
368
|
+
.media-capture-dot {
|
|
369
|
+
animation: none;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type MediaCaptureKind = 'audio' | 'video' | 'screen';
|
|
2
|
+
export interface MediaCaptureResult {
|
|
3
|
+
kind: MediaCaptureKind;
|
|
4
|
+
blob: Blob;
|
|
5
|
+
mimeType: string;
|
|
6
|
+
durationSeconds: number;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Which capture modes to offer. Default: all three. */
|
|
10
|
+
modes?: MediaCaptureKind[];
|
|
11
|
+
/** Auto-stop ceiling in ms. Default 2 minutes — "quick capture", not a full recording studio. */
|
|
12
|
+
maxDurationMs?: number;
|
|
13
|
+
/** Called with the finished clip. May return a Promise — the component
|
|
14
|
+
* stays in "processing" until it settles. */
|
|
15
|
+
onCapture: (result: MediaCaptureResult) => void | Promise<void>;
|
|
16
|
+
/** Prompt text above the mode buttons. */
|
|
17
|
+
label?: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Smaller footprint (icon-only mode buttons) for embedding inside a
|
|
21
|
+
* larger composite field like MultimodalCaptureField. */
|
|
22
|
+
compact?: boolean;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
declare const MediaCapture: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type MediaCapture = ReturnType<typeof MediaCapture>;
|
|
27
|
+
export default MediaCapture;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Tiny internal icon helper for MediaCapture / MultimodalCaptureField —
|
|
4
|
+
* NOT exported from the package barrel. Inline lucide-style stroke paths,
|
|
5
|
+
* same house convention as Button.svelte/FileDropzone.svelte (no
|
|
6
|
+
* lucide-svelte dependency in this package).
|
|
7
|
+
*/
|
|
8
|
+
let {
|
|
9
|
+
name,
|
|
10
|
+
size = 14,
|
|
11
|
+
class: className = ''
|
|
12
|
+
}: {
|
|
13
|
+
name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash'
|
|
14
|
+
size?: number
|
|
15
|
+
class?: string
|
|
16
|
+
} = $props()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if name === 'mic'}
|
|
20
|
+
<svg
|
|
21
|
+
width={size}
|
|
22
|
+
height={size}
|
|
23
|
+
viewBox="0 0 24 24"
|
|
24
|
+
fill="none"
|
|
25
|
+
stroke="currentColor"
|
|
26
|
+
stroke-width="2"
|
|
27
|
+
stroke-linecap="round"
|
|
28
|
+
stroke-linejoin="round"
|
|
29
|
+
class={className}
|
|
30
|
+
>
|
|
31
|
+
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
|
32
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
33
|
+
<line x1="12" x2="12" y1="19" y2="22" />
|
|
34
|
+
</svg>
|
|
35
|
+
{:else if name === 'video'}
|
|
36
|
+
<svg
|
|
37
|
+
width={size}
|
|
38
|
+
height={size}
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
stroke-width="2"
|
|
43
|
+
stroke-linecap="round"
|
|
44
|
+
stroke-linejoin="round"
|
|
45
|
+
class={className}
|
|
46
|
+
>
|
|
47
|
+
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
|
48
|
+
<rect x="2" y="6" width="14" height="12" rx="2" />
|
|
49
|
+
</svg>
|
|
50
|
+
{:else if name === 'monitor'}
|
|
51
|
+
<svg
|
|
52
|
+
width={size}
|
|
53
|
+
height={size}
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
stroke-width="2"
|
|
58
|
+
stroke-linecap="round"
|
|
59
|
+
stroke-linejoin="round"
|
|
60
|
+
class={className}
|
|
61
|
+
>
|
|
62
|
+
<rect width="20" height="14" x="2" y="3" rx="2" />
|
|
63
|
+
<line x1="8" x2="16" y1="21" y2="21" />
|
|
64
|
+
<line x1="12" x2="12" y1="17" y2="21" />
|
|
65
|
+
</svg>
|
|
66
|
+
{:else if name === 'square'}
|
|
67
|
+
<svg
|
|
68
|
+
width={size}
|
|
69
|
+
height={size}
|
|
70
|
+
viewBox="0 0 24 24"
|
|
71
|
+
fill="currentColor"
|
|
72
|
+
class={className}
|
|
73
|
+
>
|
|
74
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
75
|
+
</svg>
|
|
76
|
+
{:else if name === 'loader'}
|
|
77
|
+
<svg
|
|
78
|
+
width={size}
|
|
79
|
+
height={size}
|
|
80
|
+
viewBox="0 0 24 24"
|
|
81
|
+
fill="none"
|
|
82
|
+
stroke="currentColor"
|
|
83
|
+
stroke-width="2"
|
|
84
|
+
stroke-linecap="round"
|
|
85
|
+
class={className}
|
|
86
|
+
>
|
|
87
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
88
|
+
</svg>
|
|
89
|
+
{:else if name === 'x'}
|
|
90
|
+
<svg
|
|
91
|
+
width={size}
|
|
92
|
+
height={size}
|
|
93
|
+
viewBox="0 0 24 24"
|
|
94
|
+
fill="none"
|
|
95
|
+
stroke="currentColor"
|
|
96
|
+
stroke-width="2"
|
|
97
|
+
stroke-linecap="round"
|
|
98
|
+
stroke-linejoin="round"
|
|
99
|
+
class={className}
|
|
100
|
+
>
|
|
101
|
+
<path d="M18 6 6 18" />
|
|
102
|
+
<path d="m6 6 12 12" />
|
|
103
|
+
</svg>
|
|
104
|
+
{:else if name === 'file'}
|
|
105
|
+
<svg
|
|
106
|
+
width={size}
|
|
107
|
+
height={size}
|
|
108
|
+
viewBox="0 0 24 24"
|
|
109
|
+
fill="none"
|
|
110
|
+
stroke="currentColor"
|
|
111
|
+
stroke-width="2"
|
|
112
|
+
stroke-linecap="round"
|
|
113
|
+
stroke-linejoin="round"
|
|
114
|
+
class={className}
|
|
115
|
+
>
|
|
116
|
+
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
117
|
+
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
118
|
+
</svg>
|
|
119
|
+
{:else if name === 'trash'}
|
|
120
|
+
<svg
|
|
121
|
+
width={size}
|
|
122
|
+
height={size}
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="currentColor"
|
|
126
|
+
stroke-width="2"
|
|
127
|
+
stroke-linecap="round"
|
|
128
|
+
stroke-linejoin="round"
|
|
129
|
+
class={className}
|
|
130
|
+
>
|
|
131
|
+
<path d="M3 6h18" />
|
|
132
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
133
|
+
</svg>
|
|
134
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash';
|
|
3
|
+
size?: number;
|
|
4
|
+
class?: string;
|
|
5
|
+
};
|
|
6
|
+
declare const MediaCaptureIcon: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type MediaCaptureIcon = ReturnType<typeof MediaCaptureIcon>;
|
|
8
|
+
export default MediaCaptureIcon;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MultimodalCaptureField — "upload a file, or record yourself going over
|
|
4
|
+
* it" as one input surface.
|
|
5
|
+
*
|
|
6
|
+
* Composes FileDropzone (capped to a single file — this is not a bulk
|
|
7
|
+
* upload zone) with MediaCapture, framed as one combined affordance rather
|
|
8
|
+
* than two components a consumer has to remember to place side by side.
|
|
9
|
+
* The two halves are independent: a file, a recording, both, or neither
|
|
10
|
+
* can be attached. Neither half performs an upload — this component hands
|
|
11
|
+
* back a plain `File` and/or a `MediaCaptureResult` blob via callbacks
|
|
12
|
+
* (`onFileSelected` / `onCapture`), same "validate and hand back, don't
|
|
13
|
+
* own the network" contract as FileDropzone itself. The host app owns
|
|
14
|
+
* whatever upload/transcription pipeline it needs.
|
|
15
|
+
*/
|
|
16
|
+
import FileDropzone from './FileDropzone.svelte'
|
|
17
|
+
import MediaCapture from './MediaCapture.svelte'
|
|
18
|
+
import MediaCaptureIcon from './MediaCaptureIcon.svelte'
|
|
19
|
+
import type { MediaCaptureKind, MediaCaptureResult } from './MediaCapture.svelte'
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
label?: string
|
|
23
|
+
hint?: string
|
|
24
|
+
/** File MIME allow-list, forwarded to FileDropzone. */
|
|
25
|
+
accept?: string[]
|
|
26
|
+
maxFileSize?: number
|
|
27
|
+
dropLabel?: string
|
|
28
|
+
/** Which recording modes to offer. Default: all three. */
|
|
29
|
+
modes?: MediaCaptureKind[]
|
|
30
|
+
maxDurationMs?: number
|
|
31
|
+
/** Currently attached file, for re-rendering an already-selected/uploaded
|
|
32
|
+
* state (e.g. after a page reload with a persisted answer). */
|
|
33
|
+
file?: File | null
|
|
34
|
+
fileName?: string
|
|
35
|
+
fileUrl?: string
|
|
36
|
+
/** Currently attached recording's display info, if the host wants to
|
|
37
|
+
* show a persisted-answer summary (bindable is left to the host — this
|
|
38
|
+
* is a display prop, not a live capture in progress). */
|
|
39
|
+
recordingSummary?: { kind: MediaCaptureKind; durationSeconds: number | null } | null
|
|
40
|
+
onFileSelected?: (file: File) => void
|
|
41
|
+
onFileRemoved?: () => void
|
|
42
|
+
onCapture?: (result: MediaCaptureResult) => void | Promise<void>
|
|
43
|
+
onRecordingRemoved?: () => void
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
class?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let {
|
|
49
|
+
label = 'Attach a file, or record yourself going over it',
|
|
50
|
+
hint,
|
|
51
|
+
accept = ['image/png', 'image/jpeg', 'application/pdf'],
|
|
52
|
+
maxFileSize = 8 * 1024 * 1024,
|
|
53
|
+
dropLabel = 'Upload a file',
|
|
54
|
+
modes = ['audio', 'video', 'screen'],
|
|
55
|
+
maxDurationMs = 120_000,
|
|
56
|
+
file = null,
|
|
57
|
+
fileName,
|
|
58
|
+
fileUrl,
|
|
59
|
+
recordingSummary = null,
|
|
60
|
+
onFileSelected,
|
|
61
|
+
onFileRemoved,
|
|
62
|
+
onCapture,
|
|
63
|
+
onRecordingRemoved,
|
|
64
|
+
disabled = false,
|
|
65
|
+
class: className = ''
|
|
66
|
+
}: Props = $props()
|
|
67
|
+
|
|
68
|
+
function handleFilesSelected(files: File[], errors: string[]) {
|
|
69
|
+
if (errors.length) fileError = errors[0]
|
|
70
|
+
else fileError = ''
|
|
71
|
+
if (files[0]) onFileSelected?.(files[0])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let fileError = $state('')
|
|
75
|
+
|
|
76
|
+
async function handleCapture(result: MediaCaptureResult) {
|
|
77
|
+
await onCapture?.(result)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function modeLabel(kind: MediaCaptureKind): string {
|
|
81
|
+
return kind === 'audio' ? 'Voice note' : kind === 'video' ? 'Video note' : 'Screen recording'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatDuration(sec: number | null): string {
|
|
85
|
+
if (sec == null) return ''
|
|
86
|
+
const m = Math.floor(sec / 60)
|
|
87
|
+
const s = Math.round(sec % 60)
|
|
88
|
+
return ` (${m}:${String(s).padStart(2, '0')})`
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<div class="multimodal-capture-field {className}">
|
|
93
|
+
{#if label || hint}
|
|
94
|
+
<div class="mb-2">
|
|
95
|
+
{#if label}<p class="text-base-content text-sm font-medium">{label}</p>{/if}
|
|
96
|
+
{#if hint}<p class="text-base-content/60 text-xs">{hint}</p>{/if}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
{#if file || fileUrl}
|
|
101
|
+
<div class="rounded-field bg-base-200 border-base-300 flex items-center gap-2 border p-2.5">
|
|
102
|
+
<MediaCaptureIcon name="file" size={16} class="text-base-content/50 flex-shrink-0" />
|
|
103
|
+
<span class="text-base-content/80 min-w-0 flex-1 truncate text-sm">
|
|
104
|
+
{fileName || file?.name}
|
|
105
|
+
</span>
|
|
106
|
+
{#if onFileRemoved}
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
class="text-base-content/40 hover:text-error flex-shrink-0"
|
|
110
|
+
onclick={onFileRemoved}
|
|
111
|
+
aria-label="Remove file"
|
|
112
|
+
>
|
|
113
|
+
<MediaCaptureIcon name="x" size={14} />
|
|
114
|
+
</button>
|
|
115
|
+
{/if}
|
|
116
|
+
</div>
|
|
117
|
+
{:else}
|
|
118
|
+
<FileDropzone
|
|
119
|
+
{accept}
|
|
120
|
+
maxSize={maxFileSize}
|
|
121
|
+
maxFiles={1}
|
|
122
|
+
multiple={false}
|
|
123
|
+
{dropLabel}
|
|
124
|
+
{disabled}
|
|
125
|
+
onFilesSelected={handleFilesSelected}
|
|
126
|
+
/>
|
|
127
|
+
{#if fileError}
|
|
128
|
+
<p class="text-warning mt-1.5 text-xs">{fileError}</p>
|
|
129
|
+
{/if}
|
|
130
|
+
{/if}
|
|
131
|
+
|
|
132
|
+
<div class="divider text-base-content/40 my-3 text-xs">or</div>
|
|
133
|
+
|
|
134
|
+
{#if recordingSummary}
|
|
135
|
+
<div class="rounded-field bg-base-200 border-base-300 flex items-center gap-2 border p-2.5">
|
|
136
|
+
<MediaCaptureIcon
|
|
137
|
+
name={recordingSummary.kind === 'audio'
|
|
138
|
+
? 'mic'
|
|
139
|
+
: recordingSummary.kind === 'video'
|
|
140
|
+
? 'video'
|
|
141
|
+
: 'monitor'}
|
|
142
|
+
size={16}
|
|
143
|
+
class="text-base-content/50 flex-shrink-0"
|
|
144
|
+
/>
|
|
145
|
+
<span class="text-base-content/80 min-w-0 flex-1 truncate text-sm">
|
|
146
|
+
{modeLabel(recordingSummary.kind)}{formatDuration(recordingSummary.durationSeconds)}
|
|
147
|
+
</span>
|
|
148
|
+
{#if onRecordingRemoved}
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
class="text-base-content/40 hover:text-error flex-shrink-0"
|
|
152
|
+
onclick={onRecordingRemoved}
|
|
153
|
+
aria-label="Remove recording"
|
|
154
|
+
>
|
|
155
|
+
<MediaCaptureIcon name="x" size={14} />
|
|
156
|
+
</button>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
{:else}
|
|
160
|
+
<MediaCapture
|
|
161
|
+
{modes}
|
|
162
|
+
{maxDurationMs}
|
|
163
|
+
{disabled}
|
|
164
|
+
label=""
|
|
165
|
+
onCapture={handleCapture}
|
|
166
|
+
/>
|
|
167
|
+
{/if}
|
|
168
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MediaCaptureKind, MediaCaptureResult } from './MediaCapture.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
label?: string;
|
|
4
|
+
hint?: string;
|
|
5
|
+
/** File MIME allow-list, forwarded to FileDropzone. */
|
|
6
|
+
accept?: string[];
|
|
7
|
+
maxFileSize?: number;
|
|
8
|
+
dropLabel?: string;
|
|
9
|
+
/** Which recording modes to offer. Default: all three. */
|
|
10
|
+
modes?: MediaCaptureKind[];
|
|
11
|
+
maxDurationMs?: number;
|
|
12
|
+
/** Currently attached file, for re-rendering an already-selected/uploaded
|
|
13
|
+
* state (e.g. after a page reload with a persisted answer). */
|
|
14
|
+
file?: File | null;
|
|
15
|
+
fileName?: string;
|
|
16
|
+
fileUrl?: string;
|
|
17
|
+
/** Currently attached recording's display info, if the host wants to
|
|
18
|
+
* show a persisted-answer summary (bindable is left to the host — this
|
|
19
|
+
* is a display prop, not a live capture in progress). */
|
|
20
|
+
recordingSummary?: {
|
|
21
|
+
kind: MediaCaptureKind;
|
|
22
|
+
durationSeconds: number | null;
|
|
23
|
+
} | null;
|
|
24
|
+
onFileSelected?: (file: File) => void;
|
|
25
|
+
onFileRemoved?: () => void;
|
|
26
|
+
onCapture?: (result: MediaCaptureResult) => void | Promise<void>;
|
|
27
|
+
onRecordingRemoved?: () => void;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
declare const MultimodalCaptureField: import("svelte").Component<Props, {}, "">;
|
|
32
|
+
type MultimodalCaptureField = ReturnType<typeof MultimodalCaptureField>;
|
|
33
|
+
export default MultimodalCaptureField;
|
|
@@ -32,11 +32,22 @@
|
|
|
32
32
|
// ── State ──────────────────────────────────────────────────────────────
|
|
33
33
|
let sorting = $state<SortingState>([]);
|
|
34
34
|
let rowSelection = $state<RowSelectionState>({});
|
|
35
|
+
// svelte-ignore state_referenced_locally
|
|
35
36
|
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize });
|
|
36
37
|
|
|
38
|
+
// Reset to the first page whenever the data set changes.
|
|
39
|
+
//
|
|
40
|
+
// This must NOT read `pagination`. It used to preserve the current page size
|
|
41
|
+
// with `pageSize: pagination.pageSize`, which made the effect its own
|
|
42
|
+
// dependency — assigning a fresh object re-dirtied the state it had just read,
|
|
43
|
+
// so EVERY mount (paginated or not) died with effect_update_depth_exceeded
|
|
44
|
+
// during hydration and took the rest of the page's hydration with it
|
|
45
|
+
// (mos-9i88e.26: /admin/contacts, /admin/vendors, /admin/fleet-health).
|
|
46
|
+
// `pageSize` is a prop, never written here, so tracking it is safe — and it
|
|
47
|
+
// also fixes the page size being frozen at its initial value.
|
|
37
48
|
$effect(() => {
|
|
38
49
|
void data;
|
|
39
|
-
pagination = { pageIndex: 0, pageSize
|
|
50
|
+
pagination = { pageIndex: 0, pageSize };
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
const checkboxCol: ColumnDef<AnyData, unknown> = {
|
package/dist/index.d.ts
CHANGED
|
@@ -89,6 +89,9 @@ export { EMOJI_DATA, ALL_EMOJIS, searchEmojis } from './data/emojis';
|
|
|
89
89
|
export type { EmojiEntry, EmojiGroup } from './data/emojis';
|
|
90
90
|
export { default as LinkShortener } from './components/LinkShortener.svelte';
|
|
91
91
|
export { default as MediaWorkbench } from './components/MediaWorkbench.svelte';
|
|
92
|
+
export { default as MediaCapture } from './components/MediaCapture.svelte';
|
|
93
|
+
export { default as MultimodalCaptureField } from './components/MultimodalCaptureField.svelte';
|
|
94
|
+
export type { MediaCaptureKind, MediaCaptureResult } from './components/MediaCapture.svelte';
|
|
92
95
|
export { default as SignaturePad } from './components/SignaturePad.svelte';
|
|
93
96
|
export { default as MilestoneCard } from './components/MilestoneCard.svelte';
|
|
94
97
|
export { default as MilestoneTimeline } from './components/MilestoneTimeline.svelte';
|
package/dist/index.js
CHANGED
|
@@ -94,6 +94,8 @@ export { EMOJI_DATA, ALL_EMOJIS, searchEmojis } from './data/emojis';
|
|
|
94
94
|
export { default as LinkShortener } from './components/LinkShortener.svelte';
|
|
95
95
|
// Components — Media
|
|
96
96
|
export { default as MediaWorkbench } from './components/MediaWorkbench.svelte';
|
|
97
|
+
export { default as MediaCapture } from './components/MediaCapture.svelte';
|
|
98
|
+
export { default as MultimodalCaptureField } from './components/MultimodalCaptureField.svelte';
|
|
97
99
|
// Components — from Marduk
|
|
98
100
|
export { default as SignaturePad } from './components/SignaturePad.svelte';
|
|
99
101
|
export { default as MilestoneCard } from './components/MilestoneCard.svelte';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joewinke/jatui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Shared Svelte 5 component library for JAT projects",
|
|
6
6
|
"type": "module",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"package": "svelte-package --input src/lib --output dist && node scripts/compile-rune-modules.mjs",
|
|
76
76
|
"check:demos": "node scripts/check-demo-coverage.mjs",
|
|
77
77
|
"guard": "node scripts/check-demo-coverage.mjs",
|
|
78
|
-
"test:dist": "vitest run --config vitest.dist.config.ts",
|
|
78
|
+
"test:dist": "svelte-kit sync && vitest run --config vitest.dist.config.ts",
|
|
79
79
|
"prepublishOnly": "node scripts/guard-ci-publish.mjs && npm run package"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
@@ -158,6 +158,7 @@
|
|
|
158
158
|
"@tailwindcss/vite": "^4.0.0",
|
|
159
159
|
"@tanstack/table-core": "^8.21.3",
|
|
160
160
|
"@types/d3": "^7.4.3",
|
|
161
|
+
"@vitest/browser": "^3.2.7",
|
|
161
162
|
"@xyflow/svelte": "^1.6.2",
|
|
162
163
|
"cobe": "^2.0.1",
|
|
163
164
|
"d3": "^7.9.0",
|
|
@@ -166,10 +167,12 @@
|
|
|
166
167
|
"lenis": "^1.3.25",
|
|
167
168
|
"livekit-client": "^2.20.1",
|
|
168
169
|
"motion": "^12.42.2",
|
|
170
|
+
"playwright": "^1.62.1",
|
|
169
171
|
"svelte": "^5.0.0",
|
|
170
172
|
"tailwindcss": "^4.0.0",
|
|
171
173
|
"typescript": "^5.0.0",
|
|
172
|
-
"vite": "^6.0.0"
|
|
174
|
+
"vite": "^6.0.0",
|
|
175
|
+
"vitest": "^3.2.7"
|
|
173
176
|
},
|
|
174
177
|
"dependencies": {
|
|
175
178
|
"@floating-ui/dom": "^1.6.0",
|