fix(frontend): harden the streaming ui and extract copy and constants
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run

This commit is contained in:
Justin Visser 2026-08-10 15:42:18 +02:00
parent 036ef3cc6f
commit a3af8f45cc
36 changed files with 1662 additions and 226 deletions

View file

@ -1,29 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { messages } from '../lib/messages'
import type { AppMode, AuthState } from '../lib/models'
import AuthStatus from './AuthStatus.vue'
defineProps<{
const props = defineProps<{
auth: AuthState
mode: AppMode | null
healthFailed: boolean
}>()
defineEmits<{ logout: []; toggleDev: [] }>()
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
function openDevPanel(event: MouseEvent): void {
if (event.currentTarget instanceof HTMLElement) {
emit('toggleDev', event.currentTarget)
}
}
const modeLabel = computed(() => {
if (props.mode === 'demo') return messages.appHeaderDemoMode
if (props.mode === 'live') return messages.appHeaderLiveMode
if (props.healthFailed) return messages.appHeaderModeUnavailable
return messages.appHeaderCheckingMode
})
</script>
<template>
<header class="header">
<div class="inner">
<div class="brand">
<span class="wordmark">discovery<span class="accent">-by-</span>llm</span>
<span class="kicker">proof of concept</span>
<span class="wordmark"
>{{ messages.appHeaderBrandPrefix
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
>{{ messages.appHeaderBrandSuffix }}</span
>
<span class="kicker">{{ messages.appHeaderKicker }}</span>
</div>
<div class="controls">
<span class="mode" :class="{ failed: healthFailed }">
{{ mode ? `${mode} mode` : healthFailed ? 'mode unavailable' : 'checking mode' }}
{{ modeLabel }}
</span>
<AuthStatus :auth="auth" @logout="$emit('logout')" />
<button class="button" type="button" @click="$emit('toggleDev')">
Dev panel <span class="key">Ctrl+D</span>
<button class="button" type="button" data-dev-panel-opener @click="openDevPanel">
<span class="full-label">
{{ messages.appHeaderDevPanel }}
<span class="key">{{ messages.appHeaderDevPanelShortcut }}</span>
</span>
<span class="short-label">{{ messages.appHeaderDevPanelShort }}</span>
</button>
</div>
</div>
@ -116,6 +139,10 @@ defineEmits<{ logout: []; toggleDev: [] }>()
color: var(--c-text-ghost);
}
.short-label {
display: none;
}
@media (max-width: 760px) {
.kicker,
.mode,
@ -132,8 +159,12 @@ defineEmits<{ logout: []; toggleDev: [] }>()
font-size: 0;
}
.button::after {
content: 'Dev';
.full-label {
display: none;
}
.short-label {
display: inline;
font-size: var(--t-micro);
}
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn } from '../lib/models'
import EmptyResults from './EmptyResults.vue'
import PlaylistError from './PlaylistError.vue'
@ -15,13 +16,16 @@ const props = defineProps<{ turn: AssistantTurn; canSave: boolean }>()
defineEmits<{ save: []; retry: [query: string] }>()
const thinkingLabel = computed(() => {
if (props.turn.requestId === null) return 'Reading the request'
if (props.turn.requestId === null) return messages.assistantMessageReadingRequest
if (props.turn.tracks.length === 0) {
return props.turn.candidateCount === null
? 'Checking candidates'
: `Checking ${props.turn.candidateCount} candidates`
if (props.turn.candidateCount === null) {
return messages.assistantMessageCheckingCandidates
}
return formatMessage('assistantMessageCheckingCandidateCount', {
count: props.turn.candidateCount,
})
}
return 'Streaming verified tracks'
return messages.assistantMessageStreamingTracks
})
const isEmptyResult = computed(
@ -34,7 +38,7 @@ const isEmptyResult = computed(
<template>
<div class="assistant">
<div class="avatar" aria-hidden="true">d</div>
<div class="avatar" aria-hidden="true">{{ messages.assistantMessageAvatar }}</div>
<div class="column">
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
@ -76,7 +80,7 @@ const isEmptyResult = computed(
:message="turn.playlist.message"
/>
<PlaylistSaved
v-if="turn.playlist.status === 'saved' && turn.playlist.name && turn.playlist.url"
v-if="turn.playlist.status === 'saved' && turn.playlist.name"
:name="turn.playlist.name"
:url="turn.playlist.url"
:track-count="turn.tracks.length"

View file

@ -1,29 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { messages } from '../lib/messages'
import type { AuthState } from '../lib/models'
defineProps<{ auth: AuthState }>()
const props = defineProps<{ auth: AuthState }>()
defineEmits<{ logout: [] }>()
const logoutLabel = computed(() => {
if (props.auth.status === 'logging_out') return messages.authStatusLoggingOut
return messages.authStatusLogOut
})
</script>
<template>
<span
v-if="auth.status === 'checking'"
class="checking"
aria-label="Checking Spotify connection"
:aria-label="messages.authStatusCheckingConnection"
/>
<a v-else-if="auth.status === 'anonymous'" class="button" href="/api/auth/login">
<span class="full-label">Connect Spotify</span><span class="short-label">Connect</span>
<span class="full-label">{{ messages.authStatusConnectSpotify }}</span
><span class="short-label">{{ messages.authStatusConnect }}</span>
</a>
<span v-else-if="auth.status === 'failed'" class="failed" role="alert">
<span class="failure-message">{{ auth.message }}</span>
<a class="button" href="/api/auth/login">
<span class="full-label">Try Spotify again</span><span class="short-label">Retry</span>
<span class="full-label">{{ messages.authStatusTryAgain }}</span
><span class="short-label">{{ messages.authStatusRetry }}</span>
</a>
</span>
<span v-else class="authenticated">
<span class="user">
<span class="dot" aria-hidden="true" />
<span class="connected">Connected as </span>{{ auth.user.display_name }}
<span class="connected">{{ messages.authStatusConnectedAs }}</span
>{{ auth.user.display_name }}
</span>
<button
class="button"
@ -31,7 +41,7 @@ defineEmits<{ logout: [] }>()
:disabled="auth.status === 'logging_out'"
@click="$emit('logout')"
>
{{ auth.status === 'logging_out' ? 'Logging out...' : 'Log out' }}
{{ logoutLabel }}
</button>
</span>
</template>

View file

@ -2,6 +2,16 @@
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useApi } from '../composables/useApi'
import { useChatStream } from '../composables/useChatStream'
import {
FAST_TRANSITION_DURATION_MS,
MESSAGE_ENTRANCE_ANIMATION_DURATION_MS,
PLAYLIST_SAVED_ANIMATION_DURATION_MS,
REDUCED_MOTION_DURATION_MS,
THINKING_PULSE_ANIMATION_DURATION_MS,
THINKING_PULSE_STAGGER_MS,
TRACK_CARD_ANIMATION_DURATION_MS,
} from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import { DISCOVERY_SUGGESTIONS } from '../lib/suggestions'
import AppHeader from './AppHeader.vue'
import DevPanel from './DevPanel.vue'
@ -11,16 +21,32 @@ import ModeBanner from './ModeBanner.vue'
const draft = ref('')
const devOpen = ref(false)
const devOpener = ref<HTMLElement | null>(null)
const input = ref<InstanceType<typeof MessageInput> | null>(null)
const { auth, health, bootstrap, logout, createPlaylist } = useApi()
const { turns, eventLog, isStreaming, turnCount, latestAssistant, send, savePlaylist, reset } =
useChatStream(createPlaylist)
const mode = computed(() => (health.value.status === 'ready' ? health.value.mode : null))
const timingStyles: Record<string, string> = {
'--dur-fast': `${FAST_TRANSITION_DURATION_MS}ms`,
'--dur-card': `${TRACK_CARD_ANIMATION_DURATION_MS}ms`,
'--dur-message': `${MESSAGE_ENTRANCE_ANIMATION_DURATION_MS}ms`,
'--dur-playlist-saved': `${PLAYLIST_SAVED_ANIMATION_DURATION_MS}ms`,
'--dur-thinking-pulse': `${THINKING_PULSE_ANIMATION_DURATION_MS}ms`,
'--delay-thinking-pulse': `${THINKING_PULSE_STAGGER_MS}ms`,
'--dur-reduced-motion': `${REDUCED_MOTION_DURATION_MS}ms`,
}
const mode = computed(() => {
if (health.value.status === 'ready') return health.value.mode
return null
})
const canSave = computed(() => auth.value.status === 'authenticated')
const turnCounter = computed(() => {
if (turnCount.value === 0) return 'no turns yet'
return `${turnCount.value} ${turnCount.value === 1 ? 'turn' : 'turns'} in this session`
if (turnCount.value === 0) return messages.chatViewNoTurns
if (turnCount.value === 1) {
return formatMessage('chatViewOneTurn', { count: turnCount.value })
}
return formatMessage('chatViewManyTurns', { count: turnCount.value })
})
async function focusInput(): Promise<void> {
@ -44,10 +70,29 @@ function submit(query: string): void {
void send(query)
}
function openDevPanel(opener?: HTMLElement): void {
if (opener) {
devOpener.value = opener
devOpen.value = true
return
}
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
devOpener.value = activeElement
} else {
devOpener.value = null
}
devOpen.value = true
}
function onShortcut(event: KeyboardEvent): void {
if (event.ctrlKey && event.key.toLowerCase() === 'd') {
event.preventDefault()
devOpen.value = !devOpen.value
if (devOpen.value) {
devOpen.value = false
} else {
openDevPanel()
}
}
}
@ -60,14 +105,14 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
</script>
<template>
<div class="chat-view">
<div class="chat-view" :style="timingStyles">
<div class="shell" :inert="devOpen">
<AppHeader
:auth="auth"
:mode="mode"
:health-failed="health.status === 'failed'"
@logout="logout"
@toggle-dev="devOpen = true"
@toggle-dev="openDevPanel"
/>
<ModeBanner v-if="mode === 'demo'" />
@ -94,6 +139,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
v-if="devOpen"
:latest="latestAssistant"
:log="eventLog"
:opener="devOpener"
@close="devOpen = false"
@reset="reset"
/>

View file

@ -1,40 +1,49 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { messages } from '../lib/messages'
import type { AssistantTurn, EventLogEntry } from '../lib/models'
const props = defineProps<{
latest: AssistantTurn | undefined
log: EventLogEntry[]
opener: HTMLElement | null
}>()
const emit = defineEmits<{ close: []; reset: [] }>()
const panel = ref<HTMLElement | null>(null)
const closeButton = ref<HTMLButtonElement | null>(null)
let returnFocus: HTMLElement | null = null
function latestValue(value: string | null | undefined): string {
return value ?? messages.devPanelWaiting
}
const rows = computed(() => [
{ label: 'request id', value: props.latest?.requestId ?? 'waiting' },
{ label: messages.devPanelRequestId, value: latestValue(props.latest?.requestId) },
{
label: 'candidate count',
value: props.latest?.candidateCount?.toString() ?? 'waiting',
label: messages.devPanelCandidateCount,
value: latestValue(props.latest?.candidateCount?.toString()),
},
{
label: 'track count',
value:
props.latest?.completion?.track_count.toString() ??
props.latest?.tracks.length.toString() ??
'waiting',
label: messages.devPanelTrackCount,
value: latestValue(
props.latest?.completion?.track_count.toString() ?? props.latest?.tracks.length.toString(),
),
},
{
label: 'total ms',
value: props.latest?.completion?.total_ms.toString() ?? 'waiting',
label: messages.devPanelTotalMilliseconds,
value: latestValue(props.latest?.completion?.total_ms.toString()),
},
])
const entries = computed(() =>
props.log.length
? [...props.log].reverse()
: [{ timestamp: '--:--:--', type: 'idle', detail: 'waiting for a request' }],
)
const entries = computed(() => {
if (props.log.length) return [...props.log].reverse()
return [
{
timestamp: '--:--:--',
type: messages.devPanelIdle,
detail: messages.devPanelWaitingForRequest,
},
]
})
function focusableElements(): HTMLElement[] {
if (!panel.value) return []
@ -67,12 +76,15 @@ function onKeydown(event: KeyboardEvent): void {
}
onMounted(async () => {
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
closeButton.value?.focus()
})
onUnmounted(() => returnFocus?.focus())
onUnmounted(() => {
const fallback = document.querySelector<HTMLElement>('[data-dev-panel-opener]')
const target = props.opener?.isConnected ? props.opener : fallback
target?.focus()
})
</script>
<template>
@ -86,21 +98,21 @@ onUnmounted(() => returnFocus?.focus())
@keydown="onKeydown"
>
<div class="bar">
<h2 id="dev-panel-title" class="caps">Dev panel</h2>
<h2 id="dev-panel-title" class="caps">{{ messages.devPanelTitle }}</h2>
<button
ref="closeButton"
class="close"
type="button"
aria-label="Close dev panel"
:aria-label="messages.devPanelCloseLabel"
@click="$emit('close')"
>
<span aria-hidden="true">X</span>
<span aria-hidden="true">{{ messages.devPanelCloseSymbol }}</span>
</button>
</div>
<div class="body">
<section>
<h3 class="caps">Latest request</h3>
<p class="caption">Only counters supplied by the recommendation contract are shown.</p>
<h3 class="caps">{{ messages.devPanelLatestRequest }}</h3>
<p class="caption">{{ messages.devPanelContractCaption }}</p>
<div class="table">
<div v-for="row in rows" :key="row.label" class="row">
<span class="key">{{ row.label }}</span
@ -109,11 +121,13 @@ onUnmounted(() => returnFocus?.focus())
</div>
</section>
<section>
<h3 class="caps section-heading">Conversation</h3>
<button class="reset" type="button" @click="$emit('reset')">Clear conversation</button>
<h3 class="caps section-heading">{{ messages.devPanelConversation }}</h3>
<button class="reset" type="button" @click="$emit('reset')">
{{ messages.devPanelClearConversation }}
</button>
</section>
<section>
<h3 class="caps section-heading">Event stream</h3>
<h3 class="caps section-heading">{{ messages.devPanelEventStream }}</h3>
<div class="log">
<div v-for="(entry, index) in entries" :key="index" class="entry">
<span class="time">{{ entry.timestamp }}</span>

View file

@ -1,7 +1,11 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
</script>
<template>
<div class="empty-results">
<strong>No verified tracks matched this request.</strong>
<span>Try broadening the moment or removing one constraint.</span>
<strong>{{ messages.emptyResultsTitle }}</strong>
<span>{{ messages.emptyResultsGuidance }}</span>
</div>
</template>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
import SuggestionChips from './SuggestionChips.vue'
defineProps<{ suggestions: readonly string[] }>()
@ -7,13 +8,15 @@ defineEmits<{ pick: [suggestion: string] }>()
<template>
<div class="empty">
<h1>What should be playing<span class="accent"> right now?</span></h1>
<h1>
{{ messages.emptyStateHeading
}}<span class="accent">{{ messages.emptyStateHeadingAccent }}</span>
</h1>
<p class="lede">
Describe the moment, not the genre. The assistant reads the request, proposes candidates,
verifies each one on Spotify and explains why it picked them.
{{ messages.emptyStateDescription }}
</p>
<template v-if="suggestions.length">
<div class="label">Try one of these</div>
<div class="label">{{ messages.emptyStateSuggestionLabel }}</div>
<SuggestionChips :suggestions="suggestions" @pick="$emit('pick', $event)" />
</template>
</div>

View file

@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { NARROW_COMPOSER_MAX_WIDTH_PX, QUERY_MAX_LENGTH } from '../lib/constants'
import { messages } from '../lib/messages'
const model = defineModel<string>({ required: true })
const props = defineProps<{ disabled: boolean; turnCounter: string }>()
@ -7,10 +9,17 @@ const emit = defineEmits<{ send: [query: string] }>()
const input = ref<HTMLInputElement | null>(null)
const narrow = ref(false)
const placeholder = 'Describe the moment: "something calm while I am programming, but not boring"'
let mediaQuery: MediaQueryList | null = null
const sendDisabled = computed(() => props.disabled || model.value.trim().length === 0)
const placeholder = computed(() => {
if (narrow.value) return messages.messageInputNarrowPlaceholder
return messages.messageInputPlaceholder
})
const buttonLabel = computed(() => {
if (props.disabled) return messages.messageInputWorking
return messages.messageInputSend
})
function updateWidth(event?: MediaQueryListEvent): void {
narrow.value = event?.matches ?? mediaQuery?.matches ?? false
@ -27,7 +36,7 @@ function focus(): void {
}
onMounted(() => {
mediaQuery = window.matchMedia('(max-width: 559px)')
mediaQuery = window.matchMedia(`(max-width: ${NARROW_COMPOSER_MAX_WIDTH_PX}px)`)
updateWidth()
mediaQuery.addEventListener('change', updateWidth)
})
@ -43,17 +52,18 @@ defineExpose({ focus })
<input
ref="input"
v-model="model"
aria-label="Describe what you want to listen to"
:placeholder="narrow ? 'Describe the moment...' : placeholder"
:aria-label="messages.messageInputAriaLabel"
:maxlength="QUERY_MAX_LENGTH"
:placeholder="placeholder"
@keydown.enter.prevent="submit($event)"
/>
<button class="send" type="button" :disabled="sendDisabled" @click="submit()">
{{ disabled ? 'Working...' : 'Send' }}
{{ buttonLabel }}
</button>
</div>
<div class="hints">
<span class="hint">
Enter to send / refine with follow-ups like "more electronic and drop number 3"
{{ messages.messageInputHint }}
</span>
<span>{{ turnCounter }}</span>
</div>

View file

@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { MESSAGE_LIST_PIN_THRESHOLD_PX } from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn, ChatTurn } from '../lib/models'
import AssistantMessage from './AssistantMessage.vue'
import EmptyState from './EmptyState.vue'
@ -18,37 +20,49 @@ defineEmits<{
const list = ref<HTMLElement | null>(null)
const pinned = ref(true)
const fingerprint = computed(() =>
props.turns
.map((turn) =>
turn.role === 'user'
? turn.id
: `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`,
)
.join('|'),
)
function turnFingerprint(turn: ChatTurn): string {
if (turn.role === 'user') return turn.id
return `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`
}
function findLatestAssistant(): AssistantTurn | undefined {
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
const turn = props.turns[index]
if (turn?.role === 'assistant') return turn
}
return undefined
}
const fingerprint = computed(() => props.turns.map(turnFingerprint).join('|'))
const streaming = computed(() =>
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
)
const latestAssistant = computed(() =>
[...props.turns].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
)
const latestAssistant = computed(findLatestAssistant)
const announcement = computed(() => {
const turn = latestAssistant.value
if (!turn) return ''
if (turn.status === 'error') return 'The recommendation request failed.'
if (turn.status === 'error') return messages.messageListRequestFailed
if (turn.status === 'done') {
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
const trackCount = turn.completion?.track_count ?? turn.tracks.length
return formatMessage('messageListTracksReady', { count: trackCount })
}
if (turn.requestId === null) return 'Reading the request.'
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
return `${turn.tracks.length} verified tracks received.`
const warning = turn.warnings.at(-1)
if (warning) {
return formatMessage('messageListWarning', {
count: turn.warnings.length,
message: warning.message,
})
}
if (turn.requestId === null) return messages.messageListReadingRequest
if (turn.tracks.length === 0) return messages.messageListCheckingCandidates
return formatMessage('messageListVerifiedTracksReceived', { count: turn.tracks.length })
})
function onScroll(): void {
const element = list.value
if (!element) return
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
}
async function scrollToLatest(): Promise<void> {
@ -66,7 +80,12 @@ watch(streaming, (value) => {
</script>
<template>
<main ref="list" class="list" aria-label="Conversation" @scroll="onScroll">
<main
ref="list"
class="list"
:aria-label="messages.messageListConversationLabel"
@scroll="onScroll"
>
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
{{ announcement }}
</p>
@ -107,7 +126,7 @@ watch(streaming, (value) => {
}
.turn {
animation: message-in 0.3s ease both;
animation: message-in var(--dur-message) ease both;
}
@keyframes message-in {

View file

@ -1,8 +1,12 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
</script>
<template>
<div class="banner" role="status">
<div class="inner">
<span class="dot" aria-hidden="true" />
<span>Demo mode: replaying recorded recommendation sessions</span>
<span>{{ messages.modeBannerDemo }}</span>
</div>
</div>
</template>

View file

@ -1,17 +1,21 @@
<script setup lang="ts">
defineProps<{ name: string; url: string; trackCount: number }>()
import { computed } from 'vue'
import { formatMessage, messages } from '../lib/messages'
const props = defineProps<{ name: string; url: string | null; trackCount: number }>()
const metadata = computed(() => formatMessage('playlistSavedMetadata', { count: props.trackCount }))
</script>
<template>
<div class="saved" role="status">
<div>
<div class="line">
Saved <span class="name">{{ name }}</span>
{{ messages.playlistSavedConfirmationPrefix }}<span class="name">{{ name }}</span>
</div>
<div class="meta">{{ trackCount }} tracks / private / created just now</div>
<div class="meta">{{ metadata }}</div>
</div>
<a class="link" :href="url" target="_blank" rel="noreferrer">
Open in Spotify <span aria-hidden="true">&nearr;</span>
<a v-if="url" class="link" :href="url" target="_blank" rel="noreferrer">
{{ messages.playlistSavedOpenSpotify }} <span aria-hidden="true">&nearr;</span>
</a>
</div>
</template>
@ -27,7 +31,7 @@ defineProps<{ name: string; url: string; trackCount: number }>()
background: var(--c-saved-bg);
border: 1px solid var(--c-saved-line);
border-radius: var(--r-md);
animation: save-pop 0.26s ease both;
animation: save-pop var(--dur-playlist-saved) ease both;
}
.line {

View file

@ -1,16 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage } from '../lib/messages'
import type { AssistantTurn } from '../lib/models'
const props = defineProps<{ turn: AssistantTurn }>()
const parts = computed(() => {
const values: string[] = []
if (props.turn.candidateCount !== null) {
values.push(`${props.turn.candidateCount} candidates considered`)
values.push(formatMessage('requestMetadataCandidates', { count: props.turn.candidateCount }))
}
if (props.turn.completion) {
values.push(`${props.turn.completion.track_count} verified`)
values.push(`${props.turn.completion.total_ms} ms`)
values.push(
formatMessage('requestMetadataVerified', { count: props.turn.completion.track_count }),
)
values.push(
formatMessage('requestMetadataDuration', {
milliseconds: props.turn.completion.total_ms,
}),
)
}
return values
})

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { PlaylistState } from '../lib/models'
const props = defineProps<{
@ -10,10 +11,11 @@ const props = defineProps<{
defineEmits<{ save: [] }>()
const buttonLabel = computed(() => {
if (props.state.status === 'saving') return 'Saving...'
if (props.state.status === 'error') return 'Try saving again'
return 'Save as playlist'
if (props.state.status === 'saving') return messages.resultActionsSaving
if (props.state.status === 'error') return messages.resultActionsTryAgain
return messages.resultActionsSavePlaylist
})
const summary = computed(() => formatMessage('resultActionsSummary', { count: props.trackCount }))
</script>
<template>
@ -27,8 +29,8 @@ const buttonLabel = computed(() => {
>
{{ buttonLabel }}
</button>
<a v-else class="connect" href="/api/auth/login">Connect Spotify to save</a>
<span class="summary">{{ trackCount }} verified tracks ready for a private playlist</span>
<a v-else class="connect" href="/api/auth/login">{{ messages.resultActionsConnectSpotify }}</a>
<span class="summary">{{ summary }}</span>
</div>
</template>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
import type { TrackEvent } from '../lib/types'
import TrackCard from './TrackCard.vue'
@ -6,7 +7,7 @@ defineProps<{ tracks: TrackEvent[] }>()
</script>
<template>
<div class="set" aria-label="Recommended tracks">
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
</div>
</template>

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
defineProps<{ code: string; message: string }>()
defineEmits<{ retry: [] }>()
</script>
@ -6,7 +8,7 @@ defineEmits<{ retry: [] }>()
<template>
<div class="error" role="alert" :data-error-code="code">
<p>{{ message }}</p>
<button type="button" @click="$emit('retry')">Edit and retry</button>
<button type="button" @click="$emit('retry')">{{ messages.streamErrorRetry }}</button>
</div>
</template>

View file

@ -26,15 +26,15 @@ defineProps<{ label: string }>()
height: 5px;
background: var(--c-accent);
border-radius: 50%;
animation: pulse-dot 1.1s infinite;
animation: pulse-dot var(--dur-thinking-pulse) infinite;
}
.dot:nth-child(2) {
animation-delay: 0.18s;
animation-delay: var(--delay-thinking-pulse);
}
.dot:nth-child(3) {
animation-delay: 0.36s;
animation-delay: calc(2 * var(--delay-thinking-pulse));
}
.label {

View file

@ -1,10 +1,15 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { TrackEvent } from '../lib/types'
const props = defineProps<{ event: TrackEvent }>()
const artworkFailed = ref(false)
const artists = computed(() => props.event.track.artists.join(', '))
const spotifyUrl = computed(() => props.event.track.external_url)
const artworkAlt = computed(() =>
formatMessage('trackCardArtworkAlt', { album: props.event.track.album_name }),
)
</script>
<template>
@ -14,7 +19,7 @@ const artists = computed(() => props.event.track.artists.join(', '))
<img
v-if="event.track.album_art_url && !artworkFailed"
:src="event.track.album_art_url"
:alt="`${event.track.album_name} album artwork`"
:alt="artworkAlt"
@error="artworkFailed = true"
/>
</div>
@ -26,14 +31,8 @@ const artists = computed(() => props.event.track.artists.join(', '))
<div class="album">{{ event.track.album_name }}</div>
<div class="why">{{ event.justification }}</div>
</div>
<a
v-if="event.track.external_url"
class="link"
:href="event.track.external_url"
target="_blank"
rel="noreferrer"
>
Open in Spotify
<a v-if="spotifyUrl" class="link" :href="spotifyUrl" target="_blank" rel="noreferrer">
{{ messages.trackCardOpenSpotify }}
<span aria-hidden="true">&nearr;</span>
</a>
</article>