feat: [frontend] Build form and UI for creating a Choose Your Own
Some checks failed
/ test (push) Has been cancelled

Adventure
This commit is contained in:
wilson 2026-05-04 08:02:47 +01:00
parent 568b907013
commit 48bbcac9a6
4 changed files with 180 additions and 114 deletions

View file

@ -0,0 +1,13 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
let successMessage: null | string = null;
if (url.searchParams.get('created')) {
successMessage = `Adventure created, check back in a few minutes`;
url.searchParams.delete('created');
url.searchParams.
}
return {
successMessage
};
};

View file

@ -1,17 +1,19 @@
<script>
<script lang="ts">
import type { PageProps } from './$types';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { getAdventures } from './getAdventures.remote';
onMount(async () => {
const _adventures = await getAdventures('');
if (_adventures) {
adventures = _adventures;
}
});
let adventures = $state([]);
import { getAdventures } from './getAdventures.remote';
let adventures = $derived(await getAdventures(''));
const { data }: PageProps = $props();
</script>
{#if data.successMessage !== null}
<div class="alert success">
{data.successMessage}
</div>
{/if}
<div class="app-page">
<header class="page-header">
<h1 class="page-title">Adventures</h1>

View file

@ -1,57 +1,11 @@
import { error, type Action, type Actions } from '@sveltejs/kit';
import { error, redirect, type Action, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createAdventureApiAdventuresPost } from '@client';
import { createAdventureApiAdventuresPost, getUserProfileBffUserProfileGet } from '@client';
import * as v from 'valibot';
import { shuffleArray } from '$lib';
import { randomItemInArray, shuffleArray } from '$lib';
import { formatLanguage } from '$lib/formatters';
export const load: PageServerLoad = async () => {
return {
lengths: ['100-200 Words', '200-350 Words', '350-500 Words', '500-750 Words'],
competencies: ['A1', 'A2', 'B1', 'B2', 'C1'],
languages: [
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'es', name: 'Spanish' },
{ code: 'pt', name: 'Portuguese' }
],
genres: shuffleArray([
'Crime Fiction',
'Crime noir',
'Who-dun-it mystery',
'Paranormal',
'Horror',
'Psychological thriller',
'Romance',
'Family',
'Fantasy',
'Science Fiction'
]),
eras: [
'Ancient/Classical',
'Medieval',
'1500-1800',
'Renaissance',
'19th century',
'Early 20th century',
'Mid-century',
'Contemporary',
'Near future',
'Far future'
],
settings: [
'Capital city',
'Large city (not the capital)',
'Urban',
'The suburbs',
'Rural',
'Pastoral',
'Small town',
'Wilderness',
'Space',
'Another planet'
],
vibes: [
const allVibes = [
'Melancholic',
'Gothic',
'Sun-drenched',
@ -96,7 +50,81 @@ export const load: PageServerLoad = async () => {
'War',
'Spy thriller',
'Time travel'
]
];
const allGenres = [
'Crime Fiction',
'Crime noir',
'Who-dun-it mystery',
'Paranormal',
'Horror',
'Psychological thriller',
'Romance',
'Family',
'Fantasy',
'Science Fiction'
];
export const load: PageServerLoad = async ({ locals }) => {
let languageCode = 'fr';
let competency = 'A1';
const profile = await getUserProfileBffUserProfileGet({
headers: {
Authorization: `Bearer ${locals.authToken}`
}
});
if (profile.error) {
console.error({ error: profile.error });
} else {
languageCode = profile.data?.learnable_languages[0].target_language ?? 'fr';
competency = profile.data?.learnable_languages[0].proficiencies[0] ?? competency;
}
return {
lengths: ['100-200 Words', '200-350 Words', '350-500 Words', '500-750 Words'],
competency: competency,
language: {
code: languageCode,
label: formatLanguage(languageCode)
},
competencies: ['A1', 'A2', 'B1', 'B2', 'C1'],
languages: [
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'es', name: 'Spanish' },
{ code: 'pt', name: 'Portuguese' }
],
genres: allGenres.sort((a, b) => a.localeCompare(b)),
selectedGenre: randomItemInArray(allGenres),
eras: [
'Ancient/Classical',
'Medieval',
'1500-1800',
'Renaissance',
'19th century',
'Early 20th century',
'Mid-century',
'Contemporary',
'Near future',
'Far future'
],
settings: [
'Capital city',
'Large city (not the capital)',
'Urban',
'The suburbs',
'Rural',
'Pastoral',
'Small town',
'Wilderness',
'Space',
'Another planet'
],
allVibes: allVibes,
selectedVibes: shuffleArray(allVibes)
.slice(0, 6)
.sort((a, b) => a.localeCompare(b))
};
};
@ -129,8 +157,9 @@ export const actions = {
});
if (data.success == false) {
console.error({ formError: data.issues });
throw error(400, {
message: data.issues.map((e) => e.message).join(', ')
message: data.issues.map((e) => `${e.path?.[0].key}: ${e.message}`).join(', ')
});
}
@ -162,6 +191,12 @@ export const actions = {
}
});
console.log({ apiData, apiError });
if (apiError) {
return {
error: `Error creating new adventure, try again`
};
}
redirect(303, '/app/adventures?created=true');
}
} satisfies Actions;

View file

@ -1,13 +1,11 @@
<script lang="ts">
import { shuffleArray } from '$lib';
import type { ChangeEventHandler } from 'svelte/elements';
import type { PageServerData } from './$types';
import { resolve } from '$app/paths';
const { data }: { data: PageServerData } = $props();
let theVibes = $state(shuffleArray(data.vibes).slice(0, 7));
const handleVibeSelected: ChangeEventHandler<HTMLInputElement> = (e) => {
const { checked, value } = e.currentTarget;
@ -18,29 +16,31 @@
}
};
let selectedVibes = $state([]);
let vibeShuffleCount = $state(0);
let shuffleTheVibes = () => (theVibes = shuffleArray(data.vibes).slice(0, 5));
</script>
<section class="app-page">
<div class="page-header">
<div class="page-title">Create new Adventure</div>
</div>
<svelte:head>
<title>Create a new Adventure</title>
</svelte:head>
<form class="form" method="POST">
<section class="app-page">
<form class="form outline" method="POST">
<div class="form-eyebrow">
<a href={resolve('/app')}>Back home</a>
</div>
<h1 class="form-title">Create your own adventure</h1>
<div class="field">
<label for="genre" class="label">Genre</label>
<select name="genre" id="genre">
<select name="genre" id="genre" class="field-select">
{#each data.genres as genre (genre)}
<option value={genre}>{genre}</option>
<option value={genre} selected={data.selectedGenre === genre}>{genre}</option>
{/each}
</select>
</div>
<div class="field">
<label for="setting" class="label">Setting</label>
<select name="setting" id="setting">
{#each ['Urban', 'Rural', 'Coastal', 'Mountain', 'Forest', 'Desert'] as setting (setting)}
<select name="setting" id="setting" class="field-select">
{#each data.settings as setting (setting)}
<option value={setting}>{setting}</option>
{/each}
</select>
@ -48,25 +48,13 @@
<div class="field">
<fieldset class="field-fieldset">
<legend>Entry length</legend>
{#each data.lengths as length (length)}
{@const inputId = `length-${length}`}
<div class="radio-option">
<input type="radio" name="length" value={length} id={inputId} />
<label for={inputId}>{length}</label>
</div>
{/each}
</fieldset>
</div>
<div class="field">
<fieldset class="field-fieldset">
<legend>Vibes (pick two)</legend>
{#each theVibes as vibe (vibe)}
<legend>Vibes (pick up to two)</legend>
{#each data.selectedVibes as vibe (vibe)}
{@const theId = `vibe-${vibe}`}
<label for={theId} class="label">
<input
type="checkbox"
name="vibes"
disabled={selectedVibes.length >= 2 && !selectedVibes.includes(vibe)}
id={theId}
value={vibe}
@ -78,31 +66,59 @@
</fieldset>
</div>
<div class="form-divider">
<div class="form-divider-text">Additional details</div>
<div class="form-divider-line"></div>
</div>
<div class="field">
<label for="language" class="label">Language</label>
<select name="language" id="language">
{#each data.languages as language (language.code)}
<option value={language.code}>{language.name}</option>
{/each}
<select name="language" id="language" class="field-select">
<option value={data.language.code}>{data.language.label}</option>
</select>
</div>
<div class="field">
<label for="competency" class="label">Competency</label>
<select name="competency" id="competency">
<select name="competency" id="competency" class="field-select">
{#each data.competencies as competency (competency)}
<option value={competency}>{competency}</option>
<option value={competency} selected={data.competency === competency}>{competency}</option>
{/each}
</select>
</div>
<div class="field">
<fieldset class="field-fieldset">
<legend>Entry length</legend>
{#each data.lengths as length (length)}
{@const inputId = `length-${length}`}
<div class="radio-option">
<input
type="radio"
name="length"
value={length}
id={inputId}
checked={length === '350-500 Words'}
/>
<label for={inputId}>{length}</label>
</div>
{/each}
</fieldset>
</div>
<div class="field">
<fieldset class="field-fieldset">
<legend>Protagonist gender</legend>
{#each ['male', 'female', 'non-binary', 'any'] as gender (gender)}
{@const inputId = `gender-${gender}`}
<div class="radio-option">
<input type="radio" name="protagonist_gender" value={gender} id={inputId} />
<input
type="radio"
name="protagonist_gender"
value={gender}
id={inputId}
checked={gender === 'any'}
/>
<label for={inputId}>{gender}</label>
</div>
{/each}
@ -111,13 +127,13 @@
<div class="field">
<label for="protagonist_age" class="label">Protagonist age</label>
<select name="protagonist_age" id="protagonist_age">
{#each ['Adult', 'Child', 'Teen', 'Young Adult', 'Older Adult', 'Middle-aged', 'Older'] as age (age)}
<option value={age}>{age}</option>
<select name="protagonist_age" id="protagonist_age" class="field-select">
{#each ['Adult', 'Teen', 'Young Adult', 'Older Adult', 'Middle-aged', 'Older'] as age (age)}
<option value={age} selected={age === 'Adult'}>{age}</option>
{/each}
</select>
</div>
<input type="submit" value="Create Adventure" class="submit-button" />
<input type="submit" value="Create Adventure" class="btn-submit" />
</form>
</section>