feat: [frontend] Update the styles and data on the adventure page, to automatically refresh
Some checks failed
/ test (push) Has been cancelled
Some checks failed
/ test (push) Has been cancelled
This commit is contained in:
parent
697ddf01fc
commit
941396fc60
9 changed files with 744 additions and 154 deletions
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<PreviousEntries entries={previousEntries} />
|
||||
{#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}
|
||||
/>
|
||||
<LatestEntry
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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,75 +106,125 @@
|
|||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{#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>
|
||||
<audio class="audio-dock__player" controls preload="metadata">
|
||||
<source src={audioUrl} type="audio/wav" />
|
||||
</audio>
|
||||
</div>
|
||||
</header>
|
||||
<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}>
|
||||
{#each sourceParagraphs as paragraph, index (index)}
|
||||
<p
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
data-paragraph-index={index}
|
||||
data-language="source"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
<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)}
|
||||
<button
|
||||
type="button"
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
data-paragraph-index={index}
|
||||
data-language="source"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</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} disabled={!translationText}>
|
||||
<span class="dict-toggle-label">Reveal for 20 seconds</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if translationVisible}
|
||||
<div
|
||||
class="latest-entry__pane-body"
|
||||
bind:this={translationPane}
|
||||
onscroll={handleTranslationScroll}
|
||||
>
|
||||
{paragraph}
|
||||
{#if translationParagraphs.length > 0}
|
||||
{#each translationParagraphs as paragraph, index (index)}
|
||||
<button
|
||||
type="button"
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
data-paragraph-index={index}
|
||||
data-language="translation"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</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>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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}>
|
||||
<span class="dict-toggle-label">Reveal for 20 seconds</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if translationVisible}
|
||||
<div
|
||||
class="latest-entry__pane-body"
|
||||
bind:this={translationPane}
|
||||
onscroll={handleTranslationScroll}
|
||||
>
|
||||
{#each translationParagraphs as paragraph, index (index)}
|
||||
<p
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
data-paragraph-index={index}
|
||||
data-language="translation"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/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);
|
||||
|
|
|
|||
|
|
@ -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,30 +29,35 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</header>
|
||||
{#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)}
|
||||
<li class="next-steps__item">
|
||||
<button
|
||||
class="next-steps__button"
|
||||
onclick={() => handleOptionSelect(option.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span class="next-steps__index" aria-hidden="true"
|
||||
>{String(index + 1).padStart(2, '0')}</span
|
||||
<ol class="next-steps__list">
|
||||
{#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}
|
||||
>
|
||||
<span class="next-steps__label">{option.label}</span>
|
||||
<span class="next-steps__meta" aria-hidden="true">Choose</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
<span class="next-steps__index" aria-hidden="true"
|
||||
>{String(index + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="next-steps__label">{option.label}</span>
|
||||
<span class="next-steps__meta" aria-hidden="true">Choose</span>
|
||||
</button>
|
||||
</li>
|
||||
{/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;
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@
|
|||
}
|
||||
|
||||
.entry-card__choices {
|
||||
width: 100%;
|
||||
max-width: 65ch;
|
||||
font-size: var(--text-body-md);
|
||||
}
|
||||
|
|
|
|||
56
frontend/src/routes/app/adventures/[id]/adventureState.ts
Normal file
56
frontend/src/routes/app/adventures/[id]/adventureState.ts
Normal 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
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
Loading…
Reference in a new issue