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 { 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> </script>
{#if data.successMessage !== null}
<div class="alert success">
{data.successMessage}
</div>
{/if}
<div class="app-page"> <div class="app-page">
<header class="page-header"> <header class="page-header">
<h1 class="page-title">Adventures</h1> <h1 class="page-title">Adventures</h1>

View file

@ -1,12 +1,92 @@
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 type { PageServerLoad } from './$types';
import { createAdventureApiAdventuresPost } from '@client'; import { createAdventureApiAdventuresPost, getUserProfileBffUserProfileGet } from '@client';
import * as v from 'valibot'; import * as v from 'valibot';
import { shuffleArray } from '$lib'; import { randomItemInArray, shuffleArray } from '$lib';
import { formatLanguage } from '$lib/formatters';
export const load: PageServerLoad = async () => { 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',
'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',
'Post-apocalyptic',
'Survival',
'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 { return {
lengths: ['100-200 Words', '200-350 Words', '350-500 Words', '500-750 Words'], 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'], competencies: ['A1', 'A2', 'B1', 'B2', 'C1'],
languages: [ languages: [
{ code: 'fr', name: 'French' }, { code: 'fr', name: 'French' },
@ -15,18 +95,8 @@ export const load: PageServerLoad = async () => {
{ code: 'es', name: 'Spanish' }, { code: 'es', name: 'Spanish' },
{ code: 'pt', name: 'Portuguese' } { code: 'pt', name: 'Portuguese' }
], ],
genres: shuffleArray([ genres: allGenres.sort((a, b) => a.localeCompare(b)),
'Crime Fiction', selectedGenre: randomItemInArray(allGenres),
'Crime noir',
'Who-dun-it mystery',
'Paranormal',
'Horror',
'Psychological thriller',
'Romance',
'Family',
'Fantasy',
'Science Fiction'
]),
eras: [ eras: [
'Ancient/Classical', 'Ancient/Classical',
'Medieval', 'Medieval',
@ -51,52 +121,10 @@ export const load: PageServerLoad = async () => {
'Space', 'Space',
'Another planet' 'Another planet'
], ],
vibes: [ allVibes: allVibes,
'Melancholic', selectedVibes: shuffleArray(allVibes)
'Gothic', .slice(0, 6)
'Sun-drenched', .sort((a, b) => a.localeCompare(b))
'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',
'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',
'Post-apocalyptic',
'Survival',
'War',
'Spy thriller',
'Time travel'
]
}; };
}; };
@ -129,8 +157,9 @@ export const actions = {
}); });
if (data.success == false) { if (data.success == false) {
console.error({ formError: data.issues });
throw error(400, { 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; } satisfies Actions;

View file

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