@joewinke/jatui 0.1.22 → 0.1.24
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 +244 -0
- package/package.json +23 -10
- package/src/lib/actions/collapse.ts +0 -134
- package/src/lib/actions/railNav.ts +0 -473
- package/src/lib/components/AnnotationLayer.svelte +0 -108
- package/src/lib/components/AnnotationPanel.svelte +0 -319
- package/src/lib/components/AudioWaveform.svelte +0 -698
- package/src/lib/components/AvailabilityModal.svelte +0 -177
- package/src/lib/components/AvatarUpload.svelte +0 -234
- package/src/lib/components/Badge.svelte +0 -38
- package/src/lib/components/BookingForm.svelte +0 -278
- package/src/lib/components/BurndownChart.svelte +0 -778
- package/src/lib/components/Button.svelte +0 -81
- package/src/lib/components/CalendarPicker.svelte +0 -284
- package/src/lib/components/Card.svelte +0 -67
- package/src/lib/components/CharacterCounter.svelte +0 -82
- package/src/lib/components/ChipInput.svelte +0 -609
- package/src/lib/components/Collapse.svelte +0 -82
- package/src/lib/components/ColorSelector.svelte +0 -167
- package/src/lib/components/CommentThread.svelte +0 -773
- package/src/lib/components/ConfirmDialog.svelte +0 -348
- package/src/lib/components/ConfirmModal.svelte +0 -142
- package/src/lib/components/ContextMenu.svelte +0 -188
- package/src/lib/components/CountdownTimer.svelte +0 -94
- package/src/lib/components/DateRangePicker.svelte +0 -194
- package/src/lib/components/Drawer.svelte +0 -143
- package/src/lib/components/EmojiPicker.svelte +0 -159
- package/src/lib/components/EntityPreviewCard.svelte +0 -104
- package/src/lib/components/FileDropzone.svelte +0 -493
- package/src/lib/components/FilePicker.svelte +0 -488
- package/src/lib/components/FileThumbnail.svelte +0 -80
- package/src/lib/components/FilterDropdown.svelte +0 -203
- package/src/lib/components/FloatingActionBar.svelte +0 -354
- package/src/lib/components/GPSTracker.svelte +0 -202
- package/src/lib/components/HunkDiffView.svelte +0 -348
- package/src/lib/components/ImageLightbox.svelte +0 -274
- package/src/lib/components/ImageUpload.svelte +0 -147
- package/src/lib/components/InlineEdit.svelte +0 -291
- package/src/lib/components/InputDialog.svelte +0 -327
- package/src/lib/components/KeyboardShortcutsOverlay.svelte +0 -296
- package/src/lib/components/LazyImage.svelte +0 -123
- package/src/lib/components/LinkShortener.svelte +0 -274
- package/src/lib/components/LoadingSpinner.svelte +0 -106
- package/src/lib/components/LocationMap.svelte +0 -186
- package/src/lib/components/MapView.svelte +0 -341
- package/src/lib/components/MarkupEditor.svelte +0 -485
- package/src/lib/components/MarkupOverlay.svelte +0 -55
- package/src/lib/components/MediaWorkbench.svelte +0 -871
- package/src/lib/components/MilestoneCard.svelte +0 -98
- package/src/lib/components/MilestoneTimeline.svelte +0 -91
- package/src/lib/components/Modal.svelte +0 -244
- package/src/lib/components/PDFViewer.svelte +0 -105
- package/src/lib/components/PdfThumbnail.svelte +0 -63
- package/src/lib/components/PhoneInput.svelte +0 -212
- package/src/lib/components/ResizableDivider.svelte +0 -307
- package/src/lib/components/ResizablePanel.svelte +0 -305
- package/src/lib/components/SearchDropdown.svelte +0 -421
- package/src/lib/components/SelectInput.svelte +0 -237
- package/src/lib/components/SidebarUserFooter.svelte +0 -34
- package/src/lib/components/SignaturePad.svelte +0 -175
- package/src/lib/components/SmartImageEditor.svelte +0 -720
- package/src/lib/components/SortDropdown.svelte +0 -156
- package/src/lib/components/Sparkline.svelte +0 -116
- package/src/lib/components/SpeechForm.svelte +0 -114
- package/src/lib/components/StatusBadge.svelte +0 -157
- package/src/lib/components/TaskTypeIcon.svelte +0 -54
- package/src/lib/components/TextArea.svelte +0 -162
- package/src/lib/components/TextInput.svelte +0 -131
- package/src/lib/components/ThemeSelector.svelte +0 -206
- package/src/lib/components/TimeSlotPicker.svelte +0 -162
- package/src/lib/components/UserAvatar.svelte +0 -72
- package/src/lib/components/VariablePicker.svelte +0 -170
- package/src/lib/components/VoicePlayer.svelte +0 -422
- package/src/lib/components/linked-columns/LinkedColumns.svelte +0 -526
- package/src/lib/components/markup.ts +0 -287
- package/src/lib/components/messaging/Avatar.svelte +0 -81
- package/src/lib/components/messaging/ChannelInfoModal.svelte +0 -163
- package/src/lib/components/messaging/ChannelList.svelte +0 -107
- package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +0 -69
- package/src/lib/components/messaging/ChannelMembersModal.svelte +0 -182
- package/src/lib/components/messaging/CreateChannelModal.svelte +0 -190
- package/src/lib/components/messaging/DirectMessageList.svelte +0 -145
- package/src/lib/components/messaging/EmojiSelector.svelte +0 -261
- package/src/lib/components/messaging/MentionAutocomplete.svelte +0 -193
- package/src/lib/components/messaging/MessageAttachment.svelte +0 -273
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +0 -243
- package/src/lib/components/messaging/MessageInput.svelte +0 -452
- package/src/lib/components/messaging/MessageItem.svelte +0 -341
- package/src/lib/components/messaging/MessageThread.svelte +0 -306
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +0 -234
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +0 -123
- package/src/lib/components/messaging/StartDMModal.svelte +0 -100
- package/src/lib/components/messaging/ThreadPanel.svelte +0 -153
- package/src/lib/components/pipeline/DESIGN.md +0 -361
- package/src/lib/components/pipeline/Pipeline.svelte +0 -391
- package/src/lib/components/pipeline/PipelineCard.svelte +0 -94
- package/src/lib/components/pipeline/PipelineColumn.svelte +0 -163
- package/src/lib/components/replay/ChapterTimeline.svelte +0 -326
- package/src/lib/components/session-nav/transcriptModel.ts +0 -352
- package/src/lib/data/emojis.ts +0 -1208
- package/src/lib/index.ts +0 -405
- package/src/lib/stores/confirmDialog.svelte.ts +0 -48
- package/src/lib/stores/inputDialog.svelte.ts +0 -51
- package/src/lib/styles/rail.css +0 -63
- package/src/lib/types/annotation.ts +0 -38
- package/src/lib/types/booking.ts +0 -143
- package/src/lib/types/comments.ts +0 -97
- package/src/lib/types/entityPreview.ts +0 -45
- package/src/lib/types/filePicker.ts +0 -16
- package/src/lib/types/googleMaps.d.ts +0 -51
- package/src/lib/types/maps.ts +0 -43
- package/src/lib/types/messaging.ts +0 -459
- package/src/lib/types/milestone.ts +0 -22
- package/src/lib/types/pipeline.ts +0 -150
- package/src/lib/types/smartImageEditor.ts +0 -39
- package/src/lib/types/templateVars.ts +0 -36
- package/src/lib/utils/currency.ts +0 -20
- package/src/lib/utils/daisyuiColors.ts +0 -243
- package/src/lib/utils/dateFormatters.ts +0 -196
- package/src/lib/utils/googleMapsLoader.ts +0 -84
- package/src/lib/utils/mentionParser.ts +0 -188
- package/src/lib/utils/phone.ts +0 -80
- package/src/lib/utils/phoneFormat.ts +0 -74
- package/src/lib/utils/taskUtils.ts +0 -75
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
UserAvatar — shows a user's avatar image or initials fallback.
|
|
3
|
-
Generic component with no DB type coupling.
|
|
4
|
-
For use anywhere a user needs to be visually represented.
|
|
5
|
-
-->
|
|
6
|
-
<script lang="ts">
|
|
7
|
-
interface Props {
|
|
8
|
-
name?: string | null
|
|
9
|
-
email?: string | null
|
|
10
|
-
avatarUrl?: string | null
|
|
11
|
-
size?: 'xs' | 'sm' | 'md' | 'lg'
|
|
12
|
-
tooltip?: boolean
|
|
13
|
-
class?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let {
|
|
17
|
-
name = null,
|
|
18
|
-
email = null,
|
|
19
|
-
avatarUrl = null,
|
|
20
|
-
size = 'sm',
|
|
21
|
-
tooltip = true,
|
|
22
|
-
class: className = '',
|
|
23
|
-
}: Props = $props()
|
|
24
|
-
|
|
25
|
-
const sizeClasses: Record<string, string> = {
|
|
26
|
-
xs: 'w-5 h-5 text-[9px]',
|
|
27
|
-
sm: 'w-7 h-7 text-xs',
|
|
28
|
-
md: 'w-9 h-9 text-sm',
|
|
29
|
-
lg: 'w-12 h-12 text-base',
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const initials = $derived.by(() => {
|
|
33
|
-
const source = name || email || ''
|
|
34
|
-
if (!source) return '?'
|
|
35
|
-
// For email, use first char before @
|
|
36
|
-
const parts = source.includes('@')
|
|
37
|
-
? [source.split('@')[0]]
|
|
38
|
-
: source.split(' ').filter(Boolean)
|
|
39
|
-
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
40
|
-
return parts[0]?.charAt(0).toUpperCase() || '?'
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
const label = $derived(name || email || 'Unknown')
|
|
44
|
-
|
|
45
|
-
// Derive a consistent color per user from their name/email
|
|
46
|
-
const avatarBg = $derived.by(() => {
|
|
47
|
-
const source = name || email || ''
|
|
48
|
-
if (!source) return 'oklch(0.65 0.10 240)'
|
|
49
|
-
let hash = 0
|
|
50
|
-
for (let i = 0; i < source.length; i++) {
|
|
51
|
-
hash = source.charCodeAt(i) + ((hash << 5) - hash)
|
|
52
|
-
}
|
|
53
|
-
const hues = [30, 85, 145, 200, 240, 270, 310, 350]
|
|
54
|
-
const hue = hues[Math.abs(hash) % hues.length]
|
|
55
|
-
return `oklch(0.62 0.14 ${hue})`
|
|
56
|
-
})
|
|
57
|
-
</script>
|
|
58
|
-
|
|
59
|
-
<div
|
|
60
|
-
class="inline-flex shrink-0 {className}"
|
|
61
|
-
title={tooltip ? label : undefined}
|
|
62
|
-
>
|
|
63
|
-
<div class="rounded-full overflow-hidden {sizeClasses[size]}">
|
|
64
|
-
{#if avatarUrl}
|
|
65
|
-
<img src={avatarUrl} alt={label} class="w-full h-full object-cover" />
|
|
66
|
-
{:else}
|
|
67
|
-
<div class="w-full h-full flex items-center justify-center font-semibold text-white" style="background: {avatarBg}">
|
|
68
|
-
{initials}
|
|
69
|
-
</div>
|
|
70
|
-
{/if}
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* VariablePicker — browse and insert template merge variables.
|
|
4
|
-
*
|
|
5
|
-
* Renders the available merge variables for a document template, grouped by
|
|
6
|
-
* context (client / org / financial / custom). Clicking a variable inserts
|
|
7
|
-
* its `{{name}}` token at the caret of a bound textarea/input and/or fires
|
|
8
|
-
* an `onInsert` callback.
|
|
9
|
-
*
|
|
10
|
-
* Source of `groups`: the JST server-side registry
|
|
11
|
-
* `getVarsForCategory(category, template.available_vars)` in
|
|
12
|
-
* `src/lib/server/template-vars.ts`. Compute it in a `+page.server.ts` load
|
|
13
|
-
* (the registry is server-only) and pass the result straight in.
|
|
14
|
-
*
|
|
15
|
-
* This is a self-contained panel (not a modal) so it can sit beside a
|
|
16
|
-
* template editor as a sidebar. Wrap it in your own Drawer/Modal if needed.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { TemplateVar, TemplateVarGroup } from "../types/templateVars"
|
|
20
|
-
|
|
21
|
-
interface Props {
|
|
22
|
-
/** Variable groups to show. From `getVarsForCategory(...)`. */
|
|
23
|
-
groups: TemplateVarGroup[]
|
|
24
|
-
/**
|
|
25
|
-
* Optional textarea/input to insert into. When set, clicking a variable
|
|
26
|
-
* splices `{{name}}` at the current caret, restores focus + caret after
|
|
27
|
-
* the token, and dispatches an `input` event so `bind:value` updates.
|
|
28
|
-
*/
|
|
29
|
-
targetEl?: HTMLTextAreaElement | HTMLInputElement | null
|
|
30
|
-
/** Called with the inserted token (`{{name}}`) and the bare var name. */
|
|
31
|
-
onInsert?: (token: string, varName: string) => void
|
|
32
|
-
/** Show the filter box. Default true. */
|
|
33
|
-
searchable?: boolean
|
|
34
|
-
/** Denser layout for narrow sidebars. Default false. */
|
|
35
|
-
compact?: boolean
|
|
36
|
-
/** Panel heading. Default "Variables". */
|
|
37
|
-
title?: string
|
|
38
|
-
/** Shown when there are no groups/vars at all. */
|
|
39
|
-
emptyText?: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let {
|
|
43
|
-
groups,
|
|
44
|
-
targetEl = null,
|
|
45
|
-
onInsert,
|
|
46
|
-
searchable = true,
|
|
47
|
-
compact = false,
|
|
48
|
-
title = "Variables",
|
|
49
|
-
emptyText = "No variables available for this template.",
|
|
50
|
-
}: Props = $props()
|
|
51
|
-
|
|
52
|
-
let query = $state("")
|
|
53
|
-
|
|
54
|
-
const totalVars = $derived(
|
|
55
|
-
groups.reduce((n, g) => n + g.vars.length, 0),
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
// Filter vars by name/label/description, dropping groups left empty.
|
|
59
|
-
const filteredGroups = $derived.by<TemplateVarGroup[]>(() => {
|
|
60
|
-
const q = query.trim().toLowerCase()
|
|
61
|
-
if (!q) return groups
|
|
62
|
-
const out: TemplateVarGroup[] = []
|
|
63
|
-
for (const g of groups) {
|
|
64
|
-
const vars = g.vars.filter(
|
|
65
|
-
(v) =>
|
|
66
|
-
v.name.toLowerCase().includes(q) ||
|
|
67
|
-
v.label.toLowerCase().includes(q) ||
|
|
68
|
-
(v.description?.toLowerCase().includes(q) ?? false),
|
|
69
|
-
)
|
|
70
|
-
if (vars.length > 0) out.push({ ...g, vars })
|
|
71
|
-
}
|
|
72
|
-
return out
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
function tokenFor(v: TemplateVar): string {
|
|
76
|
-
return `{{${v.name}}}`
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function insert(v: TemplateVar) {
|
|
80
|
-
const token = tokenFor(v)
|
|
81
|
-
|
|
82
|
-
if (targetEl) {
|
|
83
|
-
const el = targetEl
|
|
84
|
-
const start = el.selectionStart ?? el.value.length
|
|
85
|
-
const end = el.selectionEnd ?? el.value.length
|
|
86
|
-
el.value = el.value.slice(0, start) + token + el.value.slice(end)
|
|
87
|
-
const caret = start + token.length
|
|
88
|
-
// Restore focus + caret after the inserted token.
|
|
89
|
-
el.focus()
|
|
90
|
-
el.setSelectionRange(caret, caret)
|
|
91
|
-
// Notify Svelte bindings / listeners.
|
|
92
|
-
el.dispatchEvent(new Event("input", { bubbles: true }))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
onInsert?.(token, v.name)
|
|
96
|
-
}
|
|
97
|
-
</script>
|
|
98
|
-
|
|
99
|
-
<div
|
|
100
|
-
class="border-base-300 bg-base-100 flex flex-col rounded-box border"
|
|
101
|
-
class:text-sm={compact}
|
|
102
|
-
>
|
|
103
|
-
<div class="border-base-300 flex items-center justify-between border-b px-4 py-3">
|
|
104
|
-
<h3 class="font-semibold">{title}</h3>
|
|
105
|
-
{#if totalVars > 0}
|
|
106
|
-
<span class="badge badge-ghost badge-sm">{totalVars}</span>
|
|
107
|
-
{/if}
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
{#if searchable && totalVars > 0}
|
|
111
|
-
<div class="border-base-300 border-b p-3">
|
|
112
|
-
<input
|
|
113
|
-
type="text"
|
|
114
|
-
class="input input-bordered input-sm w-full"
|
|
115
|
-
placeholder="Filter variables…"
|
|
116
|
-
aria-label="Filter variables"
|
|
117
|
-
bind:value={query}
|
|
118
|
-
/>
|
|
119
|
-
</div>
|
|
120
|
-
{/if}
|
|
121
|
-
|
|
122
|
-
<div class="flex-1 overflow-y-auto p-2" class:max-h-96={!compact}>
|
|
123
|
-
{#if totalVars === 0}
|
|
124
|
-
<p class="text-base-content/60 px-2 py-6 text-center text-sm">
|
|
125
|
-
{emptyText}
|
|
126
|
-
</p>
|
|
127
|
-
{:else if filteredGroups.length === 0}
|
|
128
|
-
<p class="text-base-content/60 px-2 py-6 text-center text-sm">
|
|
129
|
-
No variables match "{query}".
|
|
130
|
-
</p>
|
|
131
|
-
{:else}
|
|
132
|
-
{#each filteredGroups as group (group.id)}
|
|
133
|
-
<div class="mb-3 last:mb-0">
|
|
134
|
-
<div
|
|
135
|
-
class="text-base-content/45 px-2 pb-1 text-[0.8125rem] tracking-[0.005em]"
|
|
136
|
-
>
|
|
137
|
-
{group.label}
|
|
138
|
-
</div>
|
|
139
|
-
<div class="flex flex-col gap-0.5">
|
|
140
|
-
{#each group.vars as v (v.name)}
|
|
141
|
-
<button
|
|
142
|
-
type="button"
|
|
143
|
-
class="hover:bg-base-200 focus-visible:bg-base-200 group flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors"
|
|
144
|
-
onclick={() => insert(v)}
|
|
145
|
-
title={`Insert ${tokenFor(v)}`}
|
|
146
|
-
>
|
|
147
|
-
<div class="flex w-full items-baseline gap-2">
|
|
148
|
-
<span class="font-medium flex-1 min-w-0 truncate">{v.label}</span>
|
|
149
|
-
<code
|
|
150
|
-
class="text-base-content/60 group-hover:text-primary shrink-0 font-mono text-xs"
|
|
151
|
-
>
|
|
152
|
-
{tokenFor(v)}
|
|
153
|
-
</code>
|
|
154
|
-
</div>
|
|
155
|
-
{#if v.description && !compact}
|
|
156
|
-
<span class="text-base-content/45 text-[0.75rem]">
|
|
157
|
-
{v.description}{#if v.example}
|
|
158
|
-
<span class="text-base-content/45">
|
|
159
|
-
— e.g. {v.example}</span
|
|
160
|
-
>{/if}
|
|
161
|
-
</span>
|
|
162
|
-
{/if}
|
|
163
|
-
</button>
|
|
164
|
-
{/each}
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
{/each}
|
|
168
|
-
{/if}
|
|
169
|
-
</div>
|
|
170
|
-
</div>
|
|
@@ -1,422 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* VoicePlayer — TTS playback widget with voice selection, volume, speed, and provider switching.
|
|
4
|
-
*
|
|
5
|
-
* Requires backend endpoints:
|
|
6
|
-
* - voicesUrl (GET) — returns { voices: [{ voice_id, name, labels?, preview_url? }] }
|
|
7
|
-
* - ttsUrl (POST) — accepts { text, voiceId, provider, speed } and returns audio/mpeg blob
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
let {
|
|
11
|
-
text,
|
|
12
|
-
voiceId,
|
|
13
|
-
voiceName,
|
|
14
|
-
onVoiceChange,
|
|
15
|
-
expandDirection = "left",
|
|
16
|
-
autoPlay = false,
|
|
17
|
-
onPlaybackComplete,
|
|
18
|
-
onError,
|
|
19
|
-
// External control
|
|
20
|
-
messageId,
|
|
21
|
-
onRegisterController,
|
|
22
|
-
onStateChange,
|
|
23
|
-
// API configuration
|
|
24
|
-
voicesUrl = "/api/voices",
|
|
25
|
-
ttsUrl = "/api/tts",
|
|
26
|
-
} = $props<{
|
|
27
|
-
text?: string
|
|
28
|
-
voiceId?: string
|
|
29
|
-
voiceName?: string
|
|
30
|
-
onVoiceChange?: (voiceId: string, voiceName: string, provider: string) => void
|
|
31
|
-
expandDirection?: "left" | "right"
|
|
32
|
-
autoPlay?: boolean
|
|
33
|
-
onPlaybackComplete?: () => void
|
|
34
|
-
onError?: (message: string) => void
|
|
35
|
-
messageId?: string
|
|
36
|
-
onRegisterController?: (messageId: string, controller: any) => void
|
|
37
|
-
onStateChange?: (playing: boolean, loading: boolean) => void
|
|
38
|
-
voicesUrl?: string
|
|
39
|
-
ttsUrl?: string
|
|
40
|
-
}>()
|
|
41
|
-
|
|
42
|
-
let audioEl = $state<HTMLAudioElement | null>(null)
|
|
43
|
-
let isPlaying = $state(false)
|
|
44
|
-
let isPaused = $state(false)
|
|
45
|
-
let isLoadingAudio = $state(false)
|
|
46
|
-
let audioUrl = $state<string | null>(null)
|
|
47
|
-
|
|
48
|
-
let showControls = $state(false)
|
|
49
|
-
let showSettings = $state(false)
|
|
50
|
-
|
|
51
|
-
let provider = $state<"openai" | "elevenlabs">("openai")
|
|
52
|
-
|
|
53
|
-
let voices = $state<any[]>([])
|
|
54
|
-
let voicesLoading = $state(false)
|
|
55
|
-
let showVoices = $state(false)
|
|
56
|
-
// svelte-ignore state_referenced_locally
|
|
57
|
-
let selectedVoiceId = $state<string | undefined>(voiceId || "alloy")
|
|
58
|
-
|
|
59
|
-
let speed = $state(1.0)
|
|
60
|
-
|
|
61
|
-
let autoPlayAttempted = $state(false)
|
|
62
|
-
let manuallyPaused = $state(false)
|
|
63
|
-
|
|
64
|
-
const controller = {
|
|
65
|
-
play: () => playTextAudio(),
|
|
66
|
-
pause: () => pauseTextAudio(),
|
|
67
|
-
isPlaying: () => isPlaying,
|
|
68
|
-
isLoading: () => isLoadingAudio
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function reportError(msg: string) {
|
|
72
|
-
if (onError) onError(msg)
|
|
73
|
-
else console.error(msg)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
$effect(() => {
|
|
77
|
-
if (voiceId && voiceId !== selectedVoiceId) {
|
|
78
|
-
selectedVoiceId = voiceId
|
|
79
|
-
}
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
async function fetchVoices() {
|
|
83
|
-
voicesLoading = true
|
|
84
|
-
try {
|
|
85
|
-
const res = await fetch(`${voicesUrl}?provider=${provider}`)
|
|
86
|
-
if (!res.ok) throw new Error("Failed to fetch voices")
|
|
87
|
-
const data = await res.json()
|
|
88
|
-
voices = data.voices || []
|
|
89
|
-
} catch (e: any) {
|
|
90
|
-
reportError("Failed to load voices: " + (e.message || e))
|
|
91
|
-
} finally {
|
|
92
|
-
voicesLoading = false
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function switchProvider(newProvider: "openai" | "elevenlabs") {
|
|
97
|
-
provider = newProvider
|
|
98
|
-
selectedVoiceId = newProvider === "openai" ? "alloy" : undefined
|
|
99
|
-
if (voices.length === 0 || provider !== newProvider) {
|
|
100
|
-
fetchVoices()
|
|
101
|
-
}
|
|
102
|
-
showSettings = false
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function playTextAudio() {
|
|
106
|
-
try {
|
|
107
|
-
manuallyPaused = false
|
|
108
|
-
|
|
109
|
-
if (audioEl && isPaused) {
|
|
110
|
-
audioEl.play()
|
|
111
|
-
isPlaying = true
|
|
112
|
-
isPaused = false
|
|
113
|
-
onStateChange?.(true, false)
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
if (isPlaying || isLoadingAudio) return
|
|
117
|
-
|
|
118
|
-
if (!text || !text.trim()) {
|
|
119
|
-
reportError("No text to play")
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (audioEl) {
|
|
124
|
-
audioEl.pause()
|
|
125
|
-
audioEl = null
|
|
126
|
-
audioUrl && URL.revokeObjectURL(audioUrl)
|
|
127
|
-
audioUrl = null
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
isLoadingAudio = true
|
|
131
|
-
onStateChange?.(false, true)
|
|
132
|
-
|
|
133
|
-
const res = await fetch(ttsUrl, {
|
|
134
|
-
method: "POST",
|
|
135
|
-
headers: { "Content-Type": "application/json" },
|
|
136
|
-
body: JSON.stringify({ text, voiceId: selectedVoiceId, provider, speed }),
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
if (!res.ok) {
|
|
140
|
-
isLoadingAudio = false
|
|
141
|
-
onStateChange?.(false, false)
|
|
142
|
-
const err = await res.json().catch(() => ({}))
|
|
143
|
-
reportError("TTS error: " + (err.error || res.statusText))
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const audioData = await res.arrayBuffer()
|
|
148
|
-
audioUrl = URL.createObjectURL(new Blob([audioData], { type: "audio/mpeg" }))
|
|
149
|
-
audioEl = new Audio(audioUrl)
|
|
150
|
-
|
|
151
|
-
audioEl.onended = () => {
|
|
152
|
-
isPlaying = false
|
|
153
|
-
isPaused = false
|
|
154
|
-
isLoadingAudio = false
|
|
155
|
-
onStateChange?.(false, false)
|
|
156
|
-
audioEl = null
|
|
157
|
-
audioUrl && URL.revokeObjectURL(audioUrl)
|
|
158
|
-
audioUrl = null
|
|
159
|
-
onPlaybackComplete?.()
|
|
160
|
-
}
|
|
161
|
-
audioEl.onpause = () => {
|
|
162
|
-
if (audioEl && !audioEl.ended) {
|
|
163
|
-
isPlaying = false
|
|
164
|
-
isPaused = true
|
|
165
|
-
onStateChange?.(false, false)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
audioEl.onplay = () => {
|
|
169
|
-
isPlaying = true
|
|
170
|
-
isPaused = false
|
|
171
|
-
isLoadingAudio = false
|
|
172
|
-
onStateChange?.(true, false)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
await audioEl.play()
|
|
176
|
-
} catch (e: any) {
|
|
177
|
-
reportError("Failed to play audio: " + (e.message || e))
|
|
178
|
-
isPlaying = false
|
|
179
|
-
isPaused = false
|
|
180
|
-
isLoadingAudio = false
|
|
181
|
-
onStateChange?.(false, false)
|
|
182
|
-
audioEl = null
|
|
183
|
-
if (audioUrl) {
|
|
184
|
-
URL.revokeObjectURL(audioUrl)
|
|
185
|
-
audioUrl = null
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function pauseTextAudio() {
|
|
191
|
-
if (audioEl && isPlaying) {
|
|
192
|
-
manuallyPaused = true
|
|
193
|
-
audioEl.pause()
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function toggleVoices() {
|
|
198
|
-
showVoices = !showVoices
|
|
199
|
-
if (showVoices && voices.length === 0 && !voicesLoading) {
|
|
200
|
-
fetchVoices()
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function toggleSettings() {
|
|
205
|
-
showSettings = !showSettings
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function selectVoice(id: string, name: string) {
|
|
209
|
-
selectedVoiceId = id
|
|
210
|
-
showVoices = false
|
|
211
|
-
if (onVoiceChange) onVoiceChange(id, name, provider)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
$effect(() => {
|
|
215
|
-
if (messageId && onRegisterController && autoPlay) {
|
|
216
|
-
onRegisterController(messageId, controller)
|
|
217
|
-
}
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
$effect(() => {
|
|
221
|
-
if (text) {
|
|
222
|
-
autoPlayAttempted = false
|
|
223
|
-
manuallyPaused = false
|
|
224
|
-
}
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
$effect(() => {
|
|
228
|
-
if (autoPlay && text?.trim() && !isPlaying && !isLoadingAudio && !autoPlayAttempted && !manuallyPaused) {
|
|
229
|
-
autoPlayAttempted = true
|
|
230
|
-
setTimeout(() => playTextAudio(), 100)
|
|
231
|
-
}
|
|
232
|
-
})
|
|
233
|
-
</script>
|
|
234
|
-
|
|
235
|
-
{#if text && text.trim()}
|
|
236
|
-
<div class="flex items-center gap-1 justify-end">
|
|
237
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
238
|
-
<div
|
|
239
|
-
class="relative"
|
|
240
|
-
onmouseenter={() => (showControls = true)}
|
|
241
|
-
onmouseleave={() => (showControls = false)}
|
|
242
|
-
onfocus={() => (showControls = true)}
|
|
243
|
-
onblur={() => (showControls = false)}
|
|
244
|
-
>
|
|
245
|
-
{#if !showControls}
|
|
246
|
-
<button
|
|
247
|
-
class="badge badge-ghost p-2 rounded-full flex items-center justify-center"
|
|
248
|
-
aria-label="Audio controls"
|
|
249
|
-
onclick={() => (showControls = !showControls)}
|
|
250
|
-
style="min-width: 32px; min-height: 32px;"
|
|
251
|
-
>
|
|
252
|
-
<!-- Volume2 icon -->
|
|
253
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
|
|
254
|
-
</button>
|
|
255
|
-
{/if}
|
|
256
|
-
{#if showControls}
|
|
257
|
-
<div
|
|
258
|
-
class="absolute flex items-center gap-2 bg-base-200 border border-base-300 rounded-full z-50 transition-all {expandDirection === 'right' ? 'right-0' : 'left-0'}"
|
|
259
|
-
style="min-width: 180px;"
|
|
260
|
-
>
|
|
261
|
-
<!-- Play/Pause -->
|
|
262
|
-
<button
|
|
263
|
-
class="btn btn-xs btn-ghost flex items-center justify-center"
|
|
264
|
-
title={isLoadingAudio ? "Loading audio" : isPlaying && !isPaused ? "Pause" : isPaused ? "Resume" : "Play audio"}
|
|
265
|
-
aria-label={isLoadingAudio ? "Loading audio" : isPlaying && !isPaused ? "Pause" : isPaused ? "Resume" : "Play audio"}
|
|
266
|
-
onclick={isLoadingAudio ? undefined : isPlaying && !isPaused ? pauseTextAudio : playTextAudio}
|
|
267
|
-
disabled={isLoadingAudio}
|
|
268
|
-
tabindex="-1"
|
|
269
|
-
style="min-width: 28px; min-height: 28px;"
|
|
270
|
-
>
|
|
271
|
-
{#if isLoadingAudio}
|
|
272
|
-
<span class="loading loading-dots loading-xs"></span>
|
|
273
|
-
{:else if isPlaying && !isPaused}
|
|
274
|
-
<!-- PauseCircle -->
|
|
275
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="10" x2="10" y1="15" y2="9"/><line x1="14" x2="14" y1="15" y2="9"/></svg>
|
|
276
|
-
{:else}
|
|
277
|
-
<!-- PlayCircle -->
|
|
278
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
|
|
279
|
-
{/if}
|
|
280
|
-
</button>
|
|
281
|
-
|
|
282
|
-
<!-- Volume slider -->
|
|
283
|
-
<input
|
|
284
|
-
type="range"
|
|
285
|
-
min="0"
|
|
286
|
-
max="1"
|
|
287
|
-
step="0.01"
|
|
288
|
-
value={audioEl ? audioEl.volume : 1}
|
|
289
|
-
oninput={(e) => {
|
|
290
|
-
const target = e.target as HTMLInputElement
|
|
291
|
-
if (audioEl && target) audioEl.volume = +target.value
|
|
292
|
-
}}
|
|
293
|
-
aria-label="Volume"
|
|
294
|
-
class="range range-xs w-16"
|
|
295
|
-
tabindex="-1"
|
|
296
|
-
style="min-width: 48px;"
|
|
297
|
-
/>
|
|
298
|
-
|
|
299
|
-
<!-- Settings -->
|
|
300
|
-
<button
|
|
301
|
-
class="btn btn-xs btn-ghost flex items-center justify-center"
|
|
302
|
-
title="TTS Settings"
|
|
303
|
-
aria-label="TTS Settings"
|
|
304
|
-
onclick={(e) => { e.stopPropagation(); toggleSettings() }}
|
|
305
|
-
tabindex="-1"
|
|
306
|
-
style="min-width: 28px; min-height: 28px;"
|
|
307
|
-
>
|
|
308
|
-
<!-- Settings icon -->
|
|
309
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
310
|
-
</button>
|
|
311
|
-
|
|
312
|
-
<!-- Voice Dropdown -->
|
|
313
|
-
<div class="relative">
|
|
314
|
-
<button
|
|
315
|
-
class="btn btn-xs btn-ghost flex items-center justify-between border border-base-300 rounded-full px-2 py-1 bg-base-200 hover:bg-base-300 transition-colors w-24"
|
|
316
|
-
title="Select voice"
|
|
317
|
-
aria-label="Select voice"
|
|
318
|
-
onclick={toggleVoices}
|
|
319
|
-
disabled={voicesLoading}
|
|
320
|
-
style="min-width: 64px;"
|
|
321
|
-
tabindex="-1"
|
|
322
|
-
>
|
|
323
|
-
<span class="text-xs font-semibold truncate">
|
|
324
|
-
{voicesLoading ? "..." : voiceName ? voiceName : "Voice"}
|
|
325
|
-
</span>
|
|
326
|
-
<span class="flex-1"></span>
|
|
327
|
-
<!-- ChevronDown -->
|
|
328
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70"><path d="m6 9 6 6 6-6"/></svg>
|
|
329
|
-
</button>
|
|
330
|
-
|
|
331
|
-
<!-- Settings panel -->
|
|
332
|
-
{#if showSettings}
|
|
333
|
-
<div
|
|
334
|
-
class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg p-3 w-64"
|
|
335
|
-
style="margin-top: 0.25rem;"
|
|
336
|
-
>
|
|
337
|
-
<div class="mb-3">
|
|
338
|
-
<h3 class="font-semibold mb-2">TTS Provider</h3>
|
|
339
|
-
<div class="flex gap-2">
|
|
340
|
-
<button class={`btn btn-xs ${provider === "openai" ? "btn-primary" : "btn-ghost"}`} onclick={() => switchProvider("openai")}>OpenAI</button>
|
|
341
|
-
<button class={`btn btn-xs ${provider === "elevenlabs" ? "btn-primary" : "btn-ghost"}`} onclick={() => switchProvider("elevenlabs")}>ElevenLabs</button>
|
|
342
|
-
</div>
|
|
343
|
-
</div>
|
|
344
|
-
|
|
345
|
-
{#if provider === "openai"}
|
|
346
|
-
<div>
|
|
347
|
-
<h3 class="font-semibold mb-1">Speed</h3>
|
|
348
|
-
<div class="flex items-center gap-2">
|
|
349
|
-
<input
|
|
350
|
-
type="range"
|
|
351
|
-
min="0.25"
|
|
352
|
-
max="4.0"
|
|
353
|
-
step="0.05"
|
|
354
|
-
value={speed}
|
|
355
|
-
oninput={(e) => { speed = +(e.target as HTMLInputElement).value }}
|
|
356
|
-
class="range range-xs flex-1"
|
|
357
|
-
/>
|
|
358
|
-
<span class="text-xs w-8 text-right">{speed.toFixed(2)}x</span>
|
|
359
|
-
</div>
|
|
360
|
-
</div>
|
|
361
|
-
{/if}
|
|
362
|
-
</div>
|
|
363
|
-
{/if}
|
|
364
|
-
|
|
365
|
-
<!-- Voice selection dropdown -->
|
|
366
|
-
{#if showVoices}
|
|
367
|
-
<div
|
|
368
|
-
class="absolute right-0 top-full z-50 bg-base-200 border border-base-300 rounded-lg p-2 max-h-64 overflow-y-auto w-64"
|
|
369
|
-
style="margin-top: 0.25rem;"
|
|
370
|
-
>
|
|
371
|
-
{#if voicesLoading}
|
|
372
|
-
<div class="flex items-center gap-2 p-2">
|
|
373
|
-
<span class="loading loading-dots loading-xs"></span>
|
|
374
|
-
<span>Loading voices...</span>
|
|
375
|
-
</div>
|
|
376
|
-
{:else if voices.length === 0}
|
|
377
|
-
<div class="p-2 text-xs text-base-content/60">No voices found</div>
|
|
378
|
-
{:else}
|
|
379
|
-
<ul>
|
|
380
|
-
{#each voices as voice}
|
|
381
|
-
<button
|
|
382
|
-
class="w-full text-left p-2 hover:bg-base-300 rounded-lg cursor-pointer transition-colors"
|
|
383
|
-
onclick={() => selectVoice(voice.voice_id, voice.name)}
|
|
384
|
-
>
|
|
385
|
-
<div class="mb-1">
|
|
386
|
-
<span class="font-semibold text-base-content truncate block">{voice.name}</span>
|
|
387
|
-
{#if voice.labels}
|
|
388
|
-
<span class="text-xs text-base-content/70 truncate block">
|
|
389
|
-
{[voice.labels.accent, voice.labels.gender, voice.labels.age, voice.labels.description, voice.labels.use_case]
|
|
390
|
-
.filter(Boolean)
|
|
391
|
-
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
392
|
-
.join(", ")}
|
|
393
|
-
</span>
|
|
394
|
-
{/if}
|
|
395
|
-
</div>
|
|
396
|
-
<div class="flex items-center gap-1">
|
|
397
|
-
{#if voice.preview_url}
|
|
398
|
-
<audio
|
|
399
|
-
src={voice.preview_url}
|
|
400
|
-
controls
|
|
401
|
-
style="height:1.5em;"
|
|
402
|
-
preload="none"
|
|
403
|
-
onclick={(e) => e.stopPropagation()}
|
|
404
|
-
aria-label="Preview {voice.name} voice"
|
|
405
|
-
></audio>
|
|
406
|
-
{/if}
|
|
407
|
-
{#if selectedVoiceId === voice.voice_id}
|
|
408
|
-
<span class="badge badge-primary badge-xs ml-2">Selected</span>
|
|
409
|
-
{/if}
|
|
410
|
-
</div>
|
|
411
|
-
</button>
|
|
412
|
-
{/each}
|
|
413
|
-
</ul>
|
|
414
|
-
{/if}
|
|
415
|
-
</div>
|
|
416
|
-
{/if}
|
|
417
|
-
</div>
|
|
418
|
-
</div>
|
|
419
|
-
{/if}
|
|
420
|
-
</div>
|
|
421
|
-
</div>
|
|
422
|
-
{/if}
|