feat(frontend): assemble accessible application shell
This commit is contained in:
parent
96c69507ec
commit
036ef3cc6f
27 changed files with 1852 additions and 13 deletions
|
|
@ -77,14 +77,14 @@ Wat ik deed:
|
|||
|
||||
- Domeinbasis: titel/artiest-matching (normalisatie, met tolerantie voor
|
||||
Spotify's versie-suffixen zoals "- Remaster 2023"), compressie van het
|
||||
Spotify-smaakprofiel naar prompttekst plus een set bekende track-ids,
|
||||
Spotify taste profile naar prompttekst plus een set bekende track-ids,
|
||||
prompts als data in een eigen module, en alle instelbare waarden
|
||||
gesectioneerd in de config met per waarde het waarom.
|
||||
- Twee LLM-calls achter een eigen interface: call 1 interpreteert de
|
||||
vraag (stemming, activiteit, taal, bekendheid) en stelt 30-40 echte
|
||||
vraag (mood, activity, language, familiarity) en stelt 30-40 echte
|
||||
nummers voor als gestructureerde output; call 2 herordent uitsluitend
|
||||
geverifieerde nummers en streamt per nummer een eerlijke onderbouwing.
|
||||
Elke output wordt gevalideerd, met hooguit 1 herstelpoging.
|
||||
geverifieerde nummers en streamt per nummer een eerlijke justification.
|
||||
Elke output wordt gevalideerd, met hooguit 1 correctie-retry.
|
||||
- Grounding: begrensde parallelle search fan-out met early stop, een deadline,
|
||||
een naam-naar-id cache en twee aparte metrieken: niet gevonden versus
|
||||
wel gevonden maar afgekeurd door de match-check. Een track-id dat niet in
|
||||
|
|
@ -96,7 +96,7 @@ Wat ik deed:
|
|||
|
||||
Waarom:
|
||||
|
||||
- De LLM is hier de aanbeveler, maar mag alleen creatief zijn tussen twee
|
||||
- De LLM is hier de recommender, maar mag alleen creatief zijn tussen twee
|
||||
deterministische muren: alles wat hij ziet is echte data, alles wat de
|
||||
gebruiker ziet is geverifieerd op Spotify. Een verzonnen nummer valt
|
||||
stilletjes af en verschijnt nooit.
|
||||
|
|
@ -165,7 +165,7 @@ Wat ik deed:
|
|||
- Per unresolved kandidaat wordt titel, artiest en status gelogd, zodat
|
||||
zichtbaar is WAT het model verzon in plaats van alleen hoeveel.
|
||||
|
||||
### Eerlijke gedeeltelijke resultaten
|
||||
### Eerlijke partial results
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
|
|
@ -201,3 +201,41 @@ Waarom:
|
|||
- De frontend is de tweede consument van het bevroren contract: elke event
|
||||
wordt tegen de TypeScript-union gevalideerd in plaats van los geparst;
|
||||
wat niet valideert is een transport failure, geen gok.
|
||||
|
||||
### Frontend: componenten en app shell
|
||||
|
||||
Dit deel is grotendeels geautomatiseerde omzetting: het UI bouwpakket uit
|
||||
Open Design heb ik door een cli agent laten converteren naar Vue single file components
|
||||
tegen het bevroren contract. De transportlaag eronder (stream client,
|
||||
validatie) komt uit de vorige stap. Review- en hardeningpassen op dit
|
||||
resultaat volgen hierna als eigen stappen; wat hier staat is de ruwe
|
||||
conversie.
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Alle componenten uit het bouwpakket laten omzetten: chat view,
|
||||
composer met suggestion chips, streamende track cards met album covers
|
||||
en een justification per nummer, warning banners, error states, login-status,
|
||||
mode banner en een dev panel.
|
||||
- App shell met keyboard-navigatie, focus management en reduced-motion
|
||||
support.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Het bouwpakket was hierop ontworpen; de componenten volgen de tokens en
|
||||
states daaruit, en elke regel gaat alsnog door review voordat die op
|
||||
main landt.
|
||||
- Styling is scoped CSS per component bovenop globale design tokens
|
||||
(custom properties). Scoped omdat machine-geconverteerde componenten
|
||||
om style leakage te voorkomen, en omdat het bouwpakket al gewone CSS
|
||||
per component leverde: een utility
|
||||
framework had een rewrite plus een extra dependency gekost. De
|
||||
tokens houden het thema op 1 plek, dus het bekende scoped-CSS risico
|
||||
(waarden die per component uiteenlopen) is afgedekt.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Een lijst met eerdere chats. Gesprekken leven bewust alleen client-side
|
||||
en de server bewaart niets; een history-lijst kan later contract-schoon
|
||||
via localStorage, zonder server-state. Potentiele extra als er tijd
|
||||
over is, geen onderdeel van de kern.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0b0c0b" />
|
||||
<title>discovery-by-llm</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 324 B |
|
|
@ -1,8 +1,7 @@
|
|||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import ChatView from './components/ChatView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>discovery-by-llm</h1>
|
||||
<p>Chat interface under construction.</p>
|
||||
</main>
|
||||
<ChatView />
|
||||
</template>
|
||||
|
|
|
|||
140
frontend/src/components/AppHeader.vue
Normal file
140
frontend/src/components/AppHeader.vue
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<script setup lang="ts">
|
||||
import type { AppMode, AuthState } from '../lib/models'
|
||||
import AuthStatus from './AuthStatus.vue'
|
||||
|
||||
defineProps<{
|
||||
auth: AuthState
|
||||
mode: AppMode | null
|
||||
healthFailed: boolean
|
||||
}>()
|
||||
defineEmits<{ logout: []; toggleDev: [] }>()
|
||||
</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>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span class="mode" :class="{ failed: healthFailed }">
|
||||
{{ mode ? `${mode} mode` : healthFailed ? 'mode unavailable' : 'checking mode' }}
|
||||
</span>
|
||||
<AuthStatus :auth="auth" @logout="$emit('logout')" />
|
||||
<button class="button" type="button" @click="$emit('toggleDev')">
|
||||
Dev panel <span class="key">Ctrl+D</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
z-index: 20;
|
||||
flex: none;
|
||||
padding: var(--s-5) var(--gutter);
|
||||
background: var(--c-bg);
|
||||
border-bottom: 1px solid var(--c-line);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: var(--s-5);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--f-display);
|
||||
font-size: var(--t-brand);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.mode,
|
||||
.button {
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-caps);
|
||||
letter-spacing: var(--ls-caps);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: var(--s-4);
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode {
|
||||
color: var(--c-accent-soft);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mode.failed {
|
||||
color: var(--c-error);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 31px;
|
||||
padding: 6px 11px;
|
||||
color: var(--c-text-muted);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: var(--c-text);
|
||||
border-color: var(--c-focus-border);
|
||||
}
|
||||
|
||||
.key {
|
||||
color: var(--c-text-ghost);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.kicker,
|
||||
.mode,
|
||||
.key {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.button {
|
||||
min-width: var(--tap-min);
|
||||
min-height: var(--tap-min);
|
||||
padding: 8px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.button::after {
|
||||
content: 'Dev';
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
frontend/src/components/AssistantMessage.vue
Normal file
132
frontend/src/components/AssistantMessage.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AssistantTurn } from '../lib/models'
|
||||
import EmptyResults from './EmptyResults.vue'
|
||||
import PlaylistError from './PlaylistError.vue'
|
||||
import PlaylistSaved from './PlaylistSaved.vue'
|
||||
import RequestMetadata from './RequestMetadata.vue'
|
||||
import ResultActions from './ResultActions.vue'
|
||||
import ResultSet from './ResultSet.vue'
|
||||
import StreamError from './StreamError.vue'
|
||||
import StreamWarning from './StreamWarning.vue'
|
||||
import ThinkingIndicator from './ThinkingIndicator.vue'
|
||||
|
||||
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.tracks.length === 0) {
|
||||
return props.turn.candidateCount === null
|
||||
? 'Checking candidates'
|
||||
: `Checking ${props.turn.candidateCount} candidates`
|
||||
}
|
||||
return 'Streaming verified tracks'
|
||||
})
|
||||
|
||||
const isEmptyResult = computed(
|
||||
() =>
|
||||
props.turn.status === 'done' &&
|
||||
props.turn.completion?.track_count === 0 &&
|
||||
props.turn.tracks.length === 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="assistant">
|
||||
<div class="avatar" aria-hidden="true">d</div>
|
||||
<div class="column">
|
||||
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
||||
|
||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||
|
||||
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
|
||||
|
||||
<StreamWarning
|
||||
v-for="(warning, index) in turn.warnings"
|
||||
:key="`${warning.code}-${index}`"
|
||||
:warning="warning"
|
||||
/>
|
||||
|
||||
<StreamError
|
||||
v-if="turn.error"
|
||||
:code="turn.error.code"
|
||||
:message="turn.error.message"
|
||||
@retry="$emit('retry', turn.query)"
|
||||
/>
|
||||
<StreamError
|
||||
v-else-if="turn.transportFailure"
|
||||
:code="turn.transportFailure.kind"
|
||||
:message="turn.transportFailure.message"
|
||||
@retry="$emit('retry', turn.query)"
|
||||
/>
|
||||
|
||||
<EmptyResults v-if="isEmptyResult" />
|
||||
<RequestMetadata :turn="turn" />
|
||||
|
||||
<ResultActions
|
||||
v-if="turn.status === 'done' && turn.tracks.length && turn.playlist.status !== 'saved'"
|
||||
:state="turn.playlist"
|
||||
:track-count="turn.tracks.length"
|
||||
:can-save="canSave"
|
||||
@save="$emit('save')"
|
||||
/>
|
||||
<PlaylistError
|
||||
v-if="turn.playlist.status === 'error' && turn.playlist.message"
|
||||
:message="turn.playlist.message"
|
||||
/>
|
||||
<PlaylistSaved
|
||||
v-if="turn.playlist.status === 'saved' && turn.playlist.name && turn.playlist.url"
|
||||
:name="turn.playlist.name"
|
||||
:url="turn.playlist.url"
|
||||
:track-count="turn.tracks.length"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assistant {
|
||||
display: flex;
|
||||
gap: var(--s-5);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-top: 1px;
|
||||
color: var(--c-accent);
|
||||
font-family: var(--f-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--s-7);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.intent {
|
||||
margin: 0;
|
||||
font-size: var(--t-lead);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.assistant {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
128
frontend/src/components/AuthStatus.vue
Normal file
128
frontend/src/components/AuthStatus.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script setup lang="ts">
|
||||
import type { AuthState } from '../lib/models'
|
||||
|
||||
defineProps<{ auth: AuthState }>()
|
||||
defineEmits<{ logout: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="auth.status === 'checking'"
|
||||
class="checking"
|
||||
aria-label="Checking Spotify connection"
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
:disabled="auth.status === 'logging_out'"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
{{ auth.status === 'logging_out' ? 'Logging out...' : 'Log out' }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.checking {
|
||||
width: 116px;
|
||||
height: 31px;
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-pill);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.authenticated,
|
||||
.failed {
|
||||
display: inline-flex;
|
||||
gap: var(--s-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 5px 11px;
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--c-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.failure-message {
|
||||
max-width: 24ch;
|
||||
color: var(--c-error);
|
||||
font-size: var(--t-micro);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
min-height: 31px;
|
||||
padding: 6px 11px;
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.short-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
border-color: var(--c-focus-border);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.user,
|
||||
.failure-message {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.checking,
|
||||
.button {
|
||||
min-height: var(--tap-min);
|
||||
}
|
||||
|
||||
.full-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.short-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/components/ChatView.vue
Normal file
123
frontend/src/components/ChatView.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi'
|
||||
import { useChatStream } from '../composables/useChatStream'
|
||||
import { DISCOVERY_SUGGESTIONS } from '../lib/suggestions'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import DevPanel from './DevPanel.vue'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import ModeBanner from './ModeBanner.vue'
|
||||
|
||||
const draft = ref('')
|
||||
const devOpen = ref(false)
|
||||
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 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`
|
||||
})
|
||||
|
||||
async function focusInput(): Promise<void> {
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function pickSuggestion(suggestion: string): void {
|
||||
draft.value = suggestion
|
||||
void focusInput()
|
||||
}
|
||||
|
||||
function retryQuery(query: string): void {
|
||||
draft.value = query
|
||||
void focusInput()
|
||||
}
|
||||
|
||||
function submit(query: string): void {
|
||||
if (isStreaming.value) return
|
||||
draft.value = ''
|
||||
void send(query)
|
||||
}
|
||||
|
||||
function onShortcut(event: KeyboardEvent): void {
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'd') {
|
||||
event.preventDefault()
|
||||
devOpen.value = !devOpen.value
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void bootstrap()
|
||||
window.addEventListener('keydown', onShortcut)
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="shell" :inert="devOpen">
|
||||
<AppHeader
|
||||
:auth="auth"
|
||||
:mode="mode"
|
||||
:health-failed="health.status === 'failed'"
|
||||
@logout="logout"
|
||||
@toggle-dev="devOpen = true"
|
||||
/>
|
||||
|
||||
<ModeBanner v-if="mode === 'demo'" />
|
||||
|
||||
<MessageList
|
||||
:turns="turns"
|
||||
:suggestions="DISCOVERY_SUGGESTIONS"
|
||||
:can-save="canSave"
|
||||
@pick="pickSuggestion"
|
||||
@save="savePlaylist"
|
||||
@retry="retryQuery"
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
ref="input"
|
||||
v-model="draft"
|
||||
:disabled="isStreaming"
|
||||
:turn-counter="turnCounter"
|
||||
@send="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DevPanel
|
||||
v-if="devOpen"
|
||||
:latest="latestAssistant"
|
||||
:log="eventLog"
|
||||
@close="devOpen = false"
|
||||
@reset="reset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-view,
|
||||
.shell {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-view {
|
||||
color: var(--c-text);
|
||||
font-family: var(--f-body);
|
||||
font-size: var(--t-body);
|
||||
line-height: var(--lh-body);
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
285
frontend/src/components/DevPanel.vue
Normal file
285
frontend/src/components/DevPanel.vue
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { AssistantTurn, EventLogEntry } from '../lib/models'
|
||||
|
||||
const props = defineProps<{
|
||||
latest: AssistantTurn | undefined
|
||||
log: EventLogEntry[]
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; reset: [] }>()
|
||||
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const closeButton = ref<HTMLButtonElement | null>(null)
|
||||
let returnFocus: HTMLElement | null = null
|
||||
|
||||
const rows = computed(() => [
|
||||
{ label: 'request id', value: props.latest?.requestId ?? 'waiting' },
|
||||
{
|
||||
label: 'candidate count',
|
||||
value: props.latest?.candidateCount?.toString() ?? 'waiting',
|
||||
},
|
||||
{
|
||||
label: 'track count',
|
||||
value:
|
||||
props.latest?.completion?.track_count.toString() ??
|
||||
props.latest?.tracks.length.toString() ??
|
||||
'waiting',
|
||||
},
|
||||
{
|
||||
label: 'total ms',
|
||||
value: props.latest?.completion?.total_ms.toString() ?? 'waiting',
|
||||
},
|
||||
])
|
||||
const entries = computed(() =>
|
||||
props.log.length
|
||||
? [...props.log].reverse()
|
||||
: [{ timestamp: '--:--:--', type: 'idle', detail: 'waiting for a request' }],
|
||||
)
|
||||
|
||||
function focusableElements(): HTMLElement[] {
|
||||
if (!panel.value) return []
|
||||
return Array.from(
|
||||
panel.value.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), a[href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
|
||||
const focusable = focusableElements()
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last?.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
closeButton.value?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => returnFocus?.focus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<aside
|
||||
ref="panel"
|
||||
class="panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="dev-panel-title"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div class="bar">
|
||||
<h2 id="dev-panel-title" class="caps">Dev panel</h2>
|
||||
<button
|
||||
ref="closeButton"
|
||||
class="close"
|
||||
type="button"
|
||||
aria-label="Close dev panel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span aria-hidden="true">X</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>
|
||||
<div class="table">
|
||||
<div v-for="row in rows" :key="row.label" class="row">
|
||||
<span class="key">{{ row.label }}</span
|
||||
><span class="value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="caps section-heading">Conversation</h3>
|
||||
<button class="reset" type="button" @click="$emit('reset')">Clear conversation</button>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="caps section-heading">Event stream</h3>
|
||||
<div class="log">
|
||||
<div v-for="(entry, index) in entries" :key="index" class="entry">
|
||||
<span class="time">{{ entry.timestamp }}</span>
|
||||
<span class="type">{{ entry.type }}</span>
|
||||
<span class="detail" :title="entry.detail">{{ entry.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--dev-width);
|
||||
max-width: 100vw;
|
||||
overflow-y: auto;
|
||||
background: var(--c-dev-bg);
|
||||
border-left: 1px solid var(--c-line);
|
||||
box-shadow: var(--shadow-drawer);
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-6) var(--s-7);
|
||||
border-bottom: 1px solid var(--c-line);
|
||||
}
|
||||
|
||||
.caps {
|
||||
margin: 0;
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
font-weight: 400;
|
||||
letter-spacing: var(--ls-caps);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.close {
|
||||
min-width: var(--tap-min);
|
||||
min-height: var(--tap-min);
|
||||
color: var(--c-text-faint);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-body);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-7);
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin: var(--s-1) 0 var(--s-5);
|
||||
color: var(--c-text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
background: var(--c-line);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--s-5);
|
||||
justify-content: space-between;
|
||||
padding: 9px var(--s-5);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-meta);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.key {
|
||||
color: var(--c-text-dim);
|
||||
}
|
||||
|
||||
.value {
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
color: var(--c-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reset {
|
||||
width: 100%;
|
||||
min-height: var(--tap-min);
|
||||
padding: 11px var(--s-5);
|
||||
color: var(--c-text-chip);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.reset:hover {
|
||||
color: var(--c-text);
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--c-text-ghost);
|
||||
}
|
||||
|
||||
.type {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--c-text-dim);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.panel {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
frontend/src/components/EmptyResults.vue
Normal file
28
frontend/src/components/EmptyResults.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div class="empty-results">
|
||||
<strong>No verified tracks matched this request.</strong>
|
||||
<span>Try broadening the moment or removing one constraint.</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-6);
|
||||
color: var(--c-text-muted);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.empty-results strong {
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-results span {
|
||||
font-size: var(--t-small);
|
||||
}
|
||||
</style>
|
||||
72
frontend/src/components/EmptyState.vue
Normal file
72
frontend/src/components/EmptyState.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<script setup lang="ts">
|
||||
import SuggestionChips from './SuggestionChips.vue'
|
||||
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
defineEmits<{ pick: [suggestion: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty">
|
||||
<h1>What should be playing<span class="accent"> right now?</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.
|
||||
</p>
|
||||
<template v-if="suggestions.length">
|
||||
<div class="label">Try one of these</div>
|
||||
<SuggestionChips :suggestions="suggestions" @pick="$emit('pick', $event)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
padding: var(--s-5) 0 var(--s-3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 16ch;
|
||||
margin: 0 0 var(--s-5);
|
||||
font-family: var(--f-display);
|
||||
font-size: var(--t-hero);
|
||||
font-weight: 600;
|
||||
line-height: var(--lh-hero);
|
||||
letter-spacing: var(--ls-hero);
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 52ch;
|
||||
margin: 0 0 var(--s-9);
|
||||
color: var(--c-text-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: var(--s-5);
|
||||
color: var(--c-text-faint);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-caps);
|
||||
letter-spacing: var(--ls-caps);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
h1 {
|
||||
font-size: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.empty {
|
||||
padding-top: var(--s-1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
frontend/src/components/MessageInput.vue
Normal file
139
frontend/src/components/MessageInput.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = defineProps<{ disabled: boolean; turnCounter: string }>()
|
||||
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)
|
||||
|
||||
function updateWidth(event?: MediaQueryListEvent): void {
|
||||
narrow.value = event?.matches ?? mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function submit(event?: KeyboardEvent): void {
|
||||
if (event?.isComposing || props.disabled) return
|
||||
const query = model.value.trim()
|
||||
if (query) emit('send', query)
|
||||
}
|
||||
|
||||
function focus(): void {
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery = window.matchMedia('(max-width: 559px)')
|
||||
updateWidth()
|
||||
mediaQuery.addEventListener('change', updateWidth)
|
||||
})
|
||||
|
||||
onUnmounted(() => mediaQuery?.removeEventListener('change', updateWidth))
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer">
|
||||
<div class="inner">
|
||||
<div class="field">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="model"
|
||||
aria-label="Describe what you want to listen to"
|
||||
:placeholder="narrow ? 'Describe the moment...' : placeholder"
|
||||
@keydown.enter.prevent="submit($event)"
|
||||
/>
|
||||
<button class="send" type="button" :disabled="sendDisabled" @click="submit()">
|
||||
{{ disabled ? 'Working...' : 'Send' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hints">
|
||||
<span class="hint">
|
||||
Enter to send / refine with follow-ups like "more electronic and drop number 3"
|
||||
</span>
|
||||
<span>{{ turnCounter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer {
|
||||
flex: none;
|
||||
padding: var(--s-5) var(--gutter) calc(20px + env(safe-area-inset-bottom));
|
||||
background: var(--c-bg);
|
||||
border-top: 1px solid var(--c-line);
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
align-items: flex-end;
|
||||
padding: var(--s-3) var(--s-3) var(--s-3) var(--s-6);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-lg);
|
||||
transition: border-color var(--dur-fast) ease;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
border-color: var(--c-focus-border);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--s-3) 0;
|
||||
color: var(--c-text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.send {
|
||||
flex: none;
|
||||
min-height: var(--tap-min);
|
||||
padding: var(--s-4) var(--s-7);
|
||||
color: var(--c-on-accent);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--c-accent);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
transition: background var(--dur-fast) ease;
|
||||
}
|
||||
|
||||
.send:hover:not(:disabled) {
|
||||
background: var(--c-accent-hover);
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
justify-content: space-between;
|
||||
margin-top: 9px;
|
||||
color: var(--c-text-ghost);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
frontend/src/components/MessageList.vue
Normal file
130
frontend/src/components/MessageList.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { AssistantTurn, ChatTurn } from '../lib/models'
|
||||
import AssistantMessage from './AssistantMessage.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
import UserMessage from './UserMessage.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
turns: ChatTurn[]
|
||||
suggestions: readonly string[]
|
||||
canSave: boolean
|
||||
}>()
|
||||
defineEmits<{
|
||||
pick: [suggestion: string]
|
||||
save: [turnId: string]
|
||||
retry: [query: string]
|
||||
}>()
|
||||
|
||||
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('|'),
|
||||
)
|
||||
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 announcement = computed(() => {
|
||||
const turn = latestAssistant.value
|
||||
if (!turn) return ''
|
||||
if (turn.status === 'error') return 'The recommendation request failed.'
|
||||
if (turn.status === 'done') {
|
||||
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
|
||||
}
|
||||
if (turn.requestId === null) return 'Reading the request.'
|
||||
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
|
||||
return `${turn.tracks.length} verified tracks received.`
|
||||
})
|
||||
|
||||
function onScroll(): void {
|
||||
const element = list.value
|
||||
if (!element) return
|
||||
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
||||
}
|
||||
|
||||
async function scrollToLatest(): Promise<void> {
|
||||
if (!pinned.value) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
if (list.value) list.value.scrollTop = list.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
watch(fingerprint, scrollToLatest, { flush: 'post' })
|
||||
watch(streaming, (value) => {
|
||||
if (value) pinned.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main ref="list" class="list" aria-label="Conversation" @scroll="onScroll">
|
||||
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
<div class="column">
|
||||
<EmptyState
|
||||
v-if="turns.length === 0"
|
||||
:suggestions="suggestions"
|
||||
@pick="$emit('pick', $event)"
|
||||
/>
|
||||
<div v-for="turn in turns" :key="turn.id" class="turn">
|
||||
<UserMessage v-if="turn.role === 'user'" :text="turn.text" />
|
||||
<AssistantMessage
|
||||
v-else
|
||||
:turn="turn"
|
||||
:can-save="canSave"
|
||||
@save="$emit('save', turn.id)"
|
||||
@retry="$emit('retry', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 32px var(--gutter) 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-9);
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.turn {
|
||||
animation: message-in 0.3s ease both;
|
||||
}
|
||||
|
||||
@keyframes message-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.list {
|
||||
padding-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
frontend/src/components/ModeBanner.vue
Normal file
36
frontend/src/components/ModeBanner.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div class="banner" role="status">
|
||||
<div class="inner">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<span>Demo mode: replaying recorded recommendation sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner {
|
||||
flex: none;
|
||||
padding: 9px var(--gutter);
|
||||
background: var(--c-banner-bg);
|
||||
border-bottom: 1px solid var(--c-banner-line);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
gap: var(--s-5);
|
||||
align-items: center;
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
color: var(--c-accent-soft);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-meta);
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--c-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/components/PlaylistError.vue
Normal file
17
frontend/src/components/PlaylistError.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="error" role="alert">{{ message }}</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error {
|
||||
margin: 0;
|
||||
padding-left: var(--s-5);
|
||||
color: var(--c-error);
|
||||
font-size: var(--t-small);
|
||||
border-left: 2px solid var(--c-error);
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/components/PlaylistSaved.vue
Normal file
64
frontend/src/components/PlaylistSaved.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ name: string; url: string; trackCount: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="saved" role="status">
|
||||
<div>
|
||||
<div class="line">
|
||||
Saved <span class="name">{{ name }}</span>
|
||||
</div>
|
||||
<div class="meta">{{ trackCount }} tracks / private / created just now</div>
|
||||
</div>
|
||||
<a class="link" :href="url" target="_blank" rel="noreferrer">
|
||||
Open in Spotify <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.saved {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-6);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px var(--s-6);
|
||||
background: var(--c-saved-bg);
|
||||
border: 1px solid var(--c-saved-line);
|
||||
border-radius: var(--r-md);
|
||||
animation: save-pop 0.26s ease both;
|
||||
}
|
||||
|
||||
.line {
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.name,
|
||||
.link {
|
||||
color: var(--c-saved);
|
||||
}
|
||||
|
||||
.meta,
|
||||
.link {
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 2px;
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
@keyframes save-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
frontend/src/components/RequestMetadata.vue
Normal file
30
frontend/src/components/RequestMetadata.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
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`)
|
||||
}
|
||||
if (props.turn.completion) {
|
||||
values.push(`${props.turn.completion.track_count} verified`)
|
||||
values.push(`${props.turn.completion.total_ms} ms`)
|
||||
}
|
||||
return values
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="parts.length" class="metadata">{{ parts.join(' / ') }}</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metadata {
|
||||
margin: 0;
|
||||
color: var(--c-text-ghost);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
</style>
|
||||
74
frontend/src/components/ResultActions.vue
Normal file
74
frontend/src/components/ResultActions.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PlaylistState } from '../lib/models'
|
||||
|
||||
const props = defineProps<{
|
||||
state: PlaylistState
|
||||
trackCount: number
|
||||
canSave: boolean
|
||||
}>()
|
||||
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'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="actions">
|
||||
<button
|
||||
v-if="canSave"
|
||||
class="save"
|
||||
type="button"
|
||||
:disabled="state.status === 'saving'"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.save,
|
||||
.connect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--tap-min);
|
||||
padding: 11px var(--s-6);
|
||||
color: var(--c-on-accent);
|
||||
font-size: var(--t-small);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--c-accent);
|
||||
border: 1px solid var(--c-accent);
|
||||
border-radius: var(--r-md);
|
||||
transition: all var(--dur-fast) ease;
|
||||
}
|
||||
|
||||
.save:hover,
|
||||
.connect:hover {
|
||||
color: var(--c-on-accent);
|
||||
text-decoration: none;
|
||||
background: var(--c-accent-hover);
|
||||
border-color: var(--c-accent-hover);
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--c-text-faint);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/components/ResultSet.vue
Normal file
20
frontend/src/components/ResultSet.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
import TrackCard from './TrackCard.vue'
|
||||
|
||||
defineProps<{ tracks: TrackEvent[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="set" aria-label="Recommended tracks">
|
||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.set {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
</style>
|
||||
37
frontend/src/components/StreamError.vue
Normal file
37
frontend/src/components/StreamError.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ code: string; message: string }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="error" role="alert" :data-error-code="code">
|
||||
<p>{{ message }}</p>
|
||||
<button type="button" @click="$emit('retry')">Edit and retry</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error {
|
||||
padding-left: var(--s-5);
|
||||
color: var(--c-error);
|
||||
border-left: 2px solid var(--c-error);
|
||||
}
|
||||
|
||||
.error p {
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.error button {
|
||||
min-height: var(--tap-min);
|
||||
margin-top: var(--s-3);
|
||||
padding: 8px var(--s-4);
|
||||
color: var(--c-error);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
background: transparent;
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: var(--r-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/components/StreamWarning.vue
Normal file
20
frontend/src/components/StreamWarning.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { WarningEvent } from '../lib/types'
|
||||
|
||||
defineProps<{ warning: WarningEvent }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="warning" :data-warning-code="warning.code">{{ warning.message }}</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warning {
|
||||
margin: 0;
|
||||
padding-left: var(--s-5);
|
||||
color: var(--c-accent-soft);
|
||||
font-size: var(--t-small);
|
||||
text-wrap: pretty;
|
||||
border-left: 2px solid var(--c-accent-line);
|
||||
}
|
||||
</style>
|
||||
59
frontend/src/components/SuggestionChips.vue
Normal file
59
frontend/src/components/SuggestionChips.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
defineEmits<{ pick: [suggestion: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="chip"
|
||||
type="button"
|
||||
@click="$emit('pick', suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
padding: var(--s-3) 15px;
|
||||
color: var(--c-text-chip);
|
||||
font-size: var(--t-small);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface-raised);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-pill);
|
||||
transition: all var(--dur-fast) ease;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
color: var(--c-text);
|
||||
border-color: var(--c-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chips {
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 var(--gutter);
|
||||
margin: 0 calc(-1 * var(--gutter));
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
54
frontend/src/components/ThinkingIndicator.vue
Normal file
54
frontend/src/components/ThinkingIndicator.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="thinking" aria-hidden="true">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
<span class="label">{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--c-accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse-dot 1.1s infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: var(--s-1);
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
169
frontend/src/components/TrackCard.vue
Normal file
169
frontend/src/components/TrackCard.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
|
||||
const props = defineProps<{ event: TrackEvent }>()
|
||||
const artworkFailed = ref(false)
|
||||
const artists = computed(() => props.event.track.artists.join(', '))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="card">
|
||||
<div class="rank">{{ event.rank }}</div>
|
||||
<div class="art">
|
||||
<img
|
||||
v-if="event.track.album_art_url && !artworkFailed"
|
||||
:src="event.track.album_art_url"
|
||||
:alt="`${event.track.album_name} album artwork`"
|
||||
@error="artworkFailed = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<div class="head">
|
||||
<span class="title">{{ event.track.title }}</span>
|
||||
<span class="artist">{{ artists }}</span>
|
||||
</div>
|
||||
<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
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: 26px 52px 1fr auto;
|
||||
row-gap: var(--s-4);
|
||||
column-gap: var(--s-6);
|
||||
align-items: center;
|
||||
padding: 11px var(--s-5);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-card-border);
|
||||
border-radius: var(--r-md);
|
||||
transition:
|
||||
border-color var(--dur-fast) ease,
|
||||
background var(--dur-fast) ease;
|
||||
animation: card-in var(--dur-card) var(--ease) both;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--c-surface-hover);
|
||||
border-color: var(--c-line-strong);
|
||||
}
|
||||
|
||||
.rank {
|
||||
color: var(--c-text-faint);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.art {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
background: repeating-linear-gradient(135deg, var(--c-art-a) 0 6px, var(--c-art-b) 6px 12px);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.art img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 9px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--t-title);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.artist,
|
||||
.album,
|
||||
.why {
|
||||
color: var(--c-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.album {
|
||||
margin-top: 1px;
|
||||
color: var(--c-text-faint);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
|
||||
.why {
|
||||
margin-top: 3px;
|
||||
color: var(--c-text-dim);
|
||||
font-size: var(--t-small);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
gap: var(--s-1);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-self: end;
|
||||
padding: 6px var(--s-4);
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-micro);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--c-accent);
|
||||
text-decoration: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.card {
|
||||
grid-template-columns: 26px 52px 1fr;
|
||||
}
|
||||
|
||||
.link {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: stretch;
|
||||
min-height: var(--tap-min);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
26
frontend/src/components/UserMessage.vue
Normal file
26
frontend/src/components/UserMessage.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ text: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="bubble">{{ text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 78%;
|
||||
padding: 11px var(--s-6);
|
||||
color: var(--c-text);
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--c-surface-user);
|
||||
border: 1px solid var(--c-line-user);
|
||||
border-radius: var(--r-md) var(--r-md) var(--r-md) 12px;
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/lib/suggestions.ts
Normal file
9
frontend/src/lib/suggestions.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const DISCOVERY_SUGGESTIONS = [
|
||||
'Focus while coding',
|
||||
'Energy for the gym',
|
||||
'Dutch-language and relaxed',
|
||||
'90s nostalgia',
|
||||
'Surprise me with something new',
|
||||
'Rainy Sunday',
|
||||
'Background for dinner',
|
||||
] as const
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8888',
|
||||
'/callback': 'http://127.0.0.1:8888',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue