feat: [frontend] Update the styles and data on the adventure page, to automatically refresh
Some checks failed
/ test (push) Has been cancelled

This commit is contained in:
wilson 2026-05-08 10:59:24 +01:00
parent 697ddf01fc
commit 941396fc60
9 changed files with 744 additions and 154 deletions

View file

@ -18,13 +18,13 @@ export const load: PageServerLoad = async ({ locals, params }) => {
return error(400, `Error loading adventure`);
}
const { title, entries, current_entry_choices, language } = response.data;
const { title, entries, current_entry_choices, language, status } = response.data;
response.data.entries.forEach((e) => console.log(e.story_text));
return {
title: title,
entries,
choices: current_entry_choices,
language: language
language: language,
status: status
};
};

View file

@ -1,18 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { PageProps } from './$types';
import LatestEntry from './LatestEntry.svelte';
import PreviousEntries from './PreviousEntries.svelte';
import { locale, type Locale } from '$lib/i8n';
import { selectNextStep } from './selectNextStep.remote';
import { getAdventureRemote } from './getAdventure.remote';
import {
adventureState,
type AdventureEntry,
type AdventureStatus,
type GenerationPhase,
type NextStepOption
} from './adventureState';
const { data, params }: PageProps = $props();
const latestEntry = $derived(data.entries[data.entries.length - 1]);
let adventureTitle = $state(data.title as string);
const POLL_INTERVAL_MS = 25_000;
function toNextStepOptions(choices: { id: string; text: string }[]): NextStepOption[] {
return choices.map((choice) => ({
id: choice.id,
label: choice.text
}));
}
function getStatusMessage(phase: GenerationPhase): string {
switch (phase) {
case 'waiting-for-text':
return 'Writing your next scene...';
case 'waiting-for-translation':
return 'Preparing the translation...';
case 'waiting-for-audio':
return 'Generating narration audio...';
case 'error':
return 'Generation failed. Please try another choice.';
case 'ready':
return 'Your next entry is ready.';
default:
return '';
}
}
function getGenerationPhase(entry: AdventureEntry | undefined): GenerationPhase {
if (!entry) {
return 'idle';
}
if (entry.status === 'error') {
return 'error';
}
if (entry.story_text && entry.audio_url) {
return 'ready';
}
if (!entry.story_text) {
return 'waiting-for-text';
}
if (!entry.translation) {
return 'waiting-for-translation';
}
if (!entry.audio_url) {
return 'waiting-for-audio';
}
return 'ready';
}
function isGenerationTerminal(entry: AdventureEntry | undefined): boolean {
if (!entry) {
return false;
}
return entry.status === 'error' || (Boolean(entry.story_text) && Boolean(entry.audio_url));
}
const initialEntries = data.entries as AdventureEntry[];
const initialLatestEntry = initialEntries[initialEntries.length - 1];
const initialPhase = getGenerationPhase(initialLatestEntry);
adventureState.set({
entries: initialEntries,
nextStepsOptions: toNextStepOptions(data.choices),
status: data.status as AdventureStatus,
ui: {
isSelectingNextStep: false,
isPolling: false,
isWaitingForGeneration: !isGenerationTerminal(initialLatestEntry),
generationPhase: initialPhase,
statusMessage: getStatusMessage(initialPhase),
errorMessage: initialPhase === 'error' ? getStatusMessage('error') : null,
expectedMinEntryCount: null
}
});
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let refreshInFlight = false;
const latestEntry = $derived.by(() => {
const entries = $adventureState.entries;
return entries[entries.length - 1];
});
const previousEntries = $derived.by(() => {
const allEntries = data.entries ?? [];
const entries =
$adventureState.status === 'complete'
? $adventureState.entries
: $adventureState.entries.slice(0, -1);
return allEntries.slice(0, -1).map((entry, index) => {
const nextEntry = allEntries[index + 1];
return entries.map((entry, index) => {
const nextEntry = entries[index + 1];
return {
id: entry.id,
@ -26,6 +128,143 @@
});
});
function startPolling() {
if (pollingTimer !== null) {
return;
}
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isPolling: true
}
}));
pollingTimer = setInterval(() => {
void refreshAdventure();
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollingTimer !== null) {
clearInterval(pollingTimer);
pollingTimer = null;
}
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isPolling: false
}
}));
}
async function refreshAdventure() {
if (refreshInFlight) {
return;
}
refreshInFlight = true;
try {
const adventure = await getAdventureRemote({ adventureId: params.id }).run();
if (adventureTitle == 'Untitled adventure') {
adventureTitle = adventure.title;
}
adventureState.update((current) => {
const hasExpectedEntry =
current.ui.expectedMinEntryCount === null ||
adventure.entries.length >= current.ui.expectedMinEntryCount;
const latest = adventure.entries[adventure.entries.length - 1] as
| AdventureEntry
| undefined;
const nextPhase = hasExpectedEntry ? getGenerationPhase(latest) : 'waiting-for-text';
const terminal = hasExpectedEntry && isGenerationTerminal(latest);
return {
entries: adventure.entries as AdventureEntry[],
nextStepsOptions: toNextStepOptions(adventure.current_entry_choices),
status: adventure.status as AdventureStatus,
ui: {
...current.ui,
isWaitingForGeneration: !terminal,
generationPhase: nextPhase,
statusMessage: getStatusMessage(nextPhase),
errorMessage: nextPhase === 'error' ? getStatusMessage('error') : null,
expectedMinEntryCount: hasExpectedEntry ? null : current.ui.expectedMinEntryCount
}
};
});
if (!$adventureState.ui.isWaitingForGeneration) {
stopPolling();
}
} catch (error) {
console.error('Failed to refresh adventure state', error);
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
errorMessage: 'Unable to refresh generation state. Retrying...'
}
}));
} finally {
refreshInFlight = false;
}
}
async function handleNextStepSelect(optionId: string) {
if ($adventureState.ui.isSelectingNextStep) {
return;
}
const expectedMinEntryCount = $adventureState.entries.length + 1;
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isSelectingNextStep: true,
isWaitingForGeneration: true,
generationPhase: 'waiting-for-text',
statusMessage: getStatusMessage('waiting-for-text'),
errorMessage: null,
expectedMinEntryCount
}
}));
try {
await selectNextStep({ adventureId: params.id, possibleChoiceId: optionId });
startPolling();
await refreshAdventure();
} catch (error) {
console.error('Failed to select next step', error);
stopPolling();
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isWaitingForGeneration: false,
generationPhase: 'error',
statusMessage: getStatusMessage('error'),
errorMessage: 'Could not start the next entry. Please try again.',
expectedMinEntryCount: null
}
}));
} finally {
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isSelectingNextStep: false
}
}));
}
}
onMount(() => {
$locale = data.language as Locale;
@ -34,27 +273,40 @@
if (latestStoryElement) {
latestStoryElement.scrollIntoView({ behavior: 'smooth' });
}
if ($adventureState.ui.isWaitingForGeneration) {
startPolling();
void refreshAdventure();
}
});
onDestroy(() => {
stopPolling();
});
</script>
<div class="adventure-page">
<header class="adventure-page__header">
<p class="adventure-page__kicker">Choose your own adventure</p>
<h1 class="adventure-page__title">{data.title}</h1>
<h1 class="adventure-page__title">{adventureTitle}</h1>
</header>
{#if $adventureState.status === 'awaiting_first_entry'}
<p class="adventure-page__awaiting-entry">Waiting for the first entry...</p>
{:else}
<PreviousEntries entries={previousEntries} />
<LatestEntry
sourceText={latestEntry.story_text}
translationText={latestEntry.translation}
audioUrl={latestEntry.audio_url!}
nextStepsOptions={data.choices.map((choice) => ({
label: choice.text,
id: choice.id
}))}
adventureId={params.id}
sourceText={latestEntry?.story_text}
translationText={latestEntry?.translation}
audioUrl={latestEntry?.audio_url}
onSelectNextStep={handleNextStepSelect}
isWaitingForGeneration={$adventureState.ui.isWaitingForGeneration}
generationPhase={$adventureState.ui.generationPhase}
statusMessage={$adventureState.ui.statusMessage}
errorMessage={$adventureState.ui.errorMessage}
/>
{/if}
</div>
<style>

View file

@ -1,16 +1,37 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import NextSteps from './NextSteps.svelte';
import { selectNextStep } from './selectNextStep.remote';
import { adventureState } from './adventureState';
type Props = {
adventureId: string;
sourceText: string | null | undefined;
translationText: string | null | undefined;
audioUrl: string;
nextStepsOptions: { label: string; id: string }[];
audioUrl: string | null | undefined;
onSelectNextStep: (optionId: string) => Promise<void>;
isWaitingForGeneration: boolean;
generationPhase:
| 'idle'
| 'waiting-for-text'
| 'waiting-for-translation'
| 'waiting-for-audio'
| 'ready'
| 'error';
statusMessage: string;
errorMessage: string | null;
};
const { adventureId, sourceText, translationText, audioUrl, nextStepsOptions }: Props = $props();
const {
sourceText,
translationText,
audioUrl,
onSelectNextStep,
isWaitingForGeneration,
generationPhase,
statusMessage,
errorMessage
}: Props = $props();
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText));
const translationParagraphs = $derived.by(() => toParagraphs(translationText));
@ -66,6 +87,10 @@
}
function showTranslation() {
if (!translationText) {
return;
}
translationVisible = true;
if (translationTimer !== null) {
clearTimeout(translationTimer);
@ -81,31 +106,49 @@
}
async function handleNextStepSelect(optionId: string) {
const result = await selectNextStep({ adventureId, possibleChoiceId: optionId });
console.log({ result });
await onSelectNextStep(optionId);
}
onDestroy(() => {
if (translationTimer !== null) {
clearTimeout(translationTimer);
}
});
</script>
{#if $adventureState.status === 'active'}
<section class="latest-story" aria-label="Current story entry" id="latest-story">
<header class="latest-story__header">
<div class="latest-story__title-group">
<p class="latest-story__kicker">Current entry</p>
<h2 class="latest-story__title">Now reading</h2>
{#if isWaitingForGeneration}
<p class="generation-status" data-phase={generationPhase}>{statusMessage}</p>
{:else if errorMessage}
<p class="generation-status" data-phase="error">{errorMessage}</p>
{/if}
</div>
<div class="audio-dock" aria-label="Listening controls">
<p class="audio-dock__label">Listen</p>
{#if audioUrl}
<audio class="audio-dock__player" controls preload="metadata">
<source src={audioUrl} type="audio/wav" />
</audio>
{:else}
<div class="audio-dock__skeleton" aria-hidden="true"></div>
<p class="audio-dock__pending">{statusMessage || 'Narration is being generated...'}</p>
{/if}
</div>
</header>
<div class="latest-entry">
<div class="pane source-pane">
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
{#if sourceParagraphs.length > 0}
{#each sourceParagraphs as paragraph, index (index)}
<p
<button
type="button"
class="paragraph"
class:active={lastClickedParagraphIndex === index}
data-paragraph-index={index}
@ -113,15 +156,23 @@
onclick={() => handleParagraphClicked(index)}
>
{paragraph}
</p>
</button>
{/each}
{:else}
<div class="loading-block" role="status" aria-live="polite">
<p class="loading-block__label">{statusMessage || 'Writing your next entry...'}</p>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
{/if}
</div>
</div>
<div class="pane translation-pane" data-visible={translationVisible}>
<header class="translation-header">
<p class="translation-header__label">Translation</p>
<button class="dict-toggle" onclick={showTranslation}>
<button class="dict-toggle" onclick={showTranslation} disabled={!translationText}>
<span class="dict-toggle-label">Reveal for 20 seconds</span>
</button>
</header>
@ -132,8 +183,10 @@
bind:this={translationPane}
onscroll={handleTranslationScroll}
>
{#if translationParagraphs.length > 0}
{#each translationParagraphs as paragraph, index (index)}
<p
<button
type="button"
class="paragraph"
class:active={lastClickedParagraphIndex === index}
data-paragraph-index={index}
@ -141,15 +194,37 @@
onclick={() => handleParagraphClicked(index)}
>
{paragraph}
</p>
</button>
{/each}
{:else}
<div class="loading-block" role="status" aria-live="polite">
<p class="loading-block__label">
{generationPhase === 'waiting-for-text'
? 'Translation starts after the story text is ready.'
: 'Translation is on the way...'}
</p>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
{/if}
</div>
{:else if !translationText}
<p class="translation-hint" role="status" aria-live="polite">
{generationPhase === 'waiting-for-text'
? 'Translation will appear after the next scene is written.'
: 'Translation is still being prepared.'}
</p>
{/if}
</div>
</div>
</section>
<NextSteps options={nextStepsOptions} onSelect={handleNextStepSelect} />
<NextSteps
onSelect={handleNextStepSelect}
disabled={isWaitingForGeneration}
busyLabel={statusMessage}
/>
{/if}
<style>
.latest-story {
@ -188,6 +263,36 @@
color: var(--color-on-surface);
}
.generation-status {
margin: var(--space-2) 0 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-primary) 76%, var(--color-on-surface));
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.generation-status::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background-color: var(--color-primary);
animation: pulse 1.3s ease-in-out infinite;
}
.generation-status[data-phase='error'] {
color: color-mix(in srgb, #d9534f 86%, var(--color-on-surface));
}
.generation-status[data-phase='error']::before {
background-color: #d9534f;
animation: none;
}
.audio-dock {
padding: var(--space-3);
border-radius: var(--radius-lg);
@ -212,6 +317,25 @@
accent-color: var(--color-primary);
}
.audio-dock__skeleton {
height: 2.5rem;
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-surface-container) 82%, transparent),
color-mix(in srgb, var(--color-primary-container) 35%, transparent),
color-mix(in srgb, var(--color-surface-container) 82%, transparent)
);
background-size: 220% 100%;
animation: shimmer 1.8s linear infinite;
}
.audio-dock__pending {
margin: 0;
font-size: var(--text-label-md);
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.latest-entry {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@ -295,6 +419,12 @@
transform: translateY(0);
}
.dict-toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.dict-toggle-label {
white-space: nowrap;
}
@ -320,6 +450,67 @@
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.loading-block {
display: grid;
gap: var(--space-2);
}
.loading-block__label {
margin: 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.skeleton-line {
height: 1.05rem;
border-radius: var(--radius-full);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-surface-container) 86%, transparent),
color-mix(in srgb, var(--color-primary-container) 35%, transparent),
color-mix(in srgb, var(--color-surface-container) 86%, transparent)
);
background-size: 220% 100%;
animation: shimmer 1.8s linear infinite;
}
.skeleton-line.short {
width: 66%;
}
.translation-hint {
margin: 0;
padding: 0 var(--latest-entry-pane-padding) var(--latest-entry-pane-padding);
font-size: var(--text-label-md);
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.latest-entry__pane-body::-webkit-scrollbar {
width: 0.75rem;
}
@ -336,6 +527,12 @@
}
.paragraph {
display: block;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
font-size: clamp(1.1rem, 1rem + 0.35vw, 1.35rem);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
@ -350,6 +547,11 @@
border-radius: var(--radius-md);
}
.paragraph:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-primary) 45%, transparent);
outline-offset: 2px;
}
.paragraph + .paragraph {
margin-top: var(--space-3);
padding-top: var(--space-3);

View file

@ -1,10 +1,17 @@
<script lang="ts">
import { adventureState } from './adventureState';
type Props = {
options: { label: string; id: string }[];
onSelect: (optionId: string) => void;
disabled?: boolean;
busyLabel?: string;
};
const { options, onSelect }: Props = $props();
const {
onSelect,
disabled = false,
busyLabel = 'Generating your next entry...'
}: Props = $props();
let isSubmitting = $state(false);
@ -22,19 +29,23 @@
};
</script>
{#if $adventureState.status !== 'complete'}
<section class="next-steps" aria-label="Choose what happens next">
<header class="next-steps__header">
<p class="next-steps__kicker">Choose your path</p>
<h2 class="next-steps__title">What happens next?</h2>
{#if disabled}
<p class="next-steps__status" role="status" aria-live="polite">{busyLabel}</p>
{/if}
</header>
<ol class="next-steps__list">
{#each options as option, index (option.id)}
{#each $adventureState.nextStepsOptions as option, index (option.id)}
<li class="next-steps__item">
<button
class="next-steps__button"
onclick={() => handleOptionSelect(option.id)}
disabled={isSubmitting}
disabled={isSubmitting || disabled}
>
<span class="next-steps__index" aria-hidden="true"
>{String(index + 1).padStart(2, '0')}</span
@ -46,6 +57,7 @@
{/each}
</ol>
</section>
{/if}
<style>
.next-steps {
@ -80,6 +92,15 @@
color: var(--color-on-surface);
}
.next-steps__status {
margin: var(--space-2) 0 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.next-steps__list {
list-style: none;
padding: 0;

View file

@ -167,6 +167,7 @@
}
.entry-card__choices {
width: 100%;
max-width: 65ch;
font-size: var(--text-body-md);
}

View file

@ -0,0 +1,56 @@
import { writable } from 'svelte/store';
export type AdventureEntry = {
id: string;
story_text: string | null;
translation: string | null;
audio_url: string | null;
generated_from_choice_id: string | null;
possible_choices: { id: string; text: string }[] | null;
status: string;
};
export type NextStepOption = {
id: string;
label: string;
};
export type GenerationPhase =
| 'idle'
| 'waiting-for-text'
| 'waiting-for-translation'
| 'waiting-for-audio'
| 'ready'
| 'error';
export type AdventureStatus = 'active' | 'complete' | 'awaiting_first_entry';
export type AdventurePageState = {
entries: AdventureEntry[];
nextStepsOptions: NextStepOption[];
status: AdventureStatus;
ui: {
isSelectingNextStep: boolean;
isPolling: boolean;
isWaitingForGeneration: boolean;
generationPhase: GenerationPhase;
statusMessage: string;
errorMessage: string | null;
expectedMinEntryCount: number | null;
};
};
export const adventureState = writable<AdventurePageState>({
entries: [],
nextStepsOptions: [],
status: 'active',
ui: {
isSelectingNextStep: false,
isPolling: false,
isWaitingForGeneration: false,
generationPhase: 'idle',
statusMessage: '',
errorMessage: null,
expectedMinEntryCount: null
}
});

View file

@ -0,0 +1,26 @@
import { getRequestEvent, query } from '$app/server';
import { getAdventureBffAdventureAdventureIdGet } from '@client';
import * as v from 'valibot';
const getAdventureStateSchema = v.object({
adventureId: v.string()
});
export const getAdventureRemote = query(getAdventureStateSchema, async ({ adventureId }) => {
const { locals } = getRequestEvent();
const response = await getAdventureBffAdventureAdventureIdGet({
path: {
adventure_id: adventureId
},
headers: {
Authorization: `Bearer ${locals.authToken}`
}
});
if (response.error) {
console.error('Error fetching adventure state:', response.error);
throw new Error('Failed to fetch adventure state');
}
return response.data;
});

View file

@ -6,63 +6,95 @@ import { randomItemInArray, shuffleArray } from '$lib';
import { formatLanguage } from '$lib/formatters';
const allVibes = [
'Melancholic',
'Gothic',
'Sun-drenched',
'Bleak',
'Whimsical',
'Eerie',
'Cosy',
'Tense',
'Witty',
'Propulsive',
'Mentor and student',
'Unlikely duo',
'Lone wolf',
'Queer-norm',
'Class tensions',
'Chosen family',
'Diaspora',
'Academia',
'Small town',
'The sea',
'Grand house',
'Road trip',
'A single night',
'Heist',
'Mystery box',
'Reluctant hero',
'Redemption',
'Academia',
'Animal companions',
'Gentle',
'Happy ever after',
'Bittersweet',
'Epistolary (letters / diary entries)',
"A big city that isn't the capital",
'Parenthood',
'Sly',
'Slapstick',
'Recovery',
'Political',
'Apocalyptic',
'Australia',
'Bittersweet',
'Bleak',
'Boarding school',
'Bookish',
'Central America',
'Central Asia',
'Chosen family',
'Class tensions',
'Cosy',
'Diaspora',
'East Asia',
'Eastern Africa',
'Eastern Europe',
'Eerie',
'Epistolary (letters / diary entries)',
'Gentle',
'Ghost',
'Gothic',
'Grand house',
'Happy ever after',
'Heist',
'Island',
'Lifelong friendship',
'Lone wolf',
'Mafia',
'Masculine',
'Matriarchal society',
'Mediterranean',
'Melancholic',
'Melodrama',
'Mentor and student',
'Mystery box',
'Nordic',
'North America',
'Northern Africa',
'Paranormal',
'Parenthood',
'Parenthood',
'Plot twist near the end',
'Political',
'Post-apocalyptic',
'Survival',
'War',
'Propulsive',
'Pulp',
'Queer-norm',
'Recovery',
'Redemption',
'Reluctant hero',
'Road trip',
'Slapstick',
'Sly',
'Small town',
'Southeast Asia',
'Southern Africa',
'Spy thriller',
'Time travel'
'Starving artist',
'Sun-drenched',
'Survival',
'Tense',
'The sea',
'Time travel',
'Tropical',
'Unlikely duo',
'Unreliable narrator',
'War',
'West Asia',
'Western Africa',
'Whimsical',
'Witty'
];
const allGenres = [
'Adventure',
'Crime Fiction',
'Crime noir',
'Who-dun-it mystery',
'Paranormal',
'Horror',
'Psychological thriller',
'Romance',
'Family',
'Fantasy',
'Science Fiction'
'Horror',
'Mystery',
'Paranormal',
'Psychological thriller',
'Romance',
'Science Fiction',
'Thriller',
'Who-dun-it mystery'
];
export const load: PageServerLoad = async ({ locals }) => {
let languageCode = 'fr';

View file

@ -4,7 +4,7 @@ import { getJobsApiJobsGet } from '../../../client/sdk.gen.ts';
export const load: PageServerLoad = async ({ locals }) => {
const authToken = locals.authToken;
console.log({ authToken });
client.setConfig({
headers: {
Authorization: `Bearer ${authToken}`