feat: [frontend] First implementation of the "Create new adventure"
Some checks are pending
/ test (push) Waiting to run

form.
This commit is contained in:
wilson 2026-05-03 22:39:03 +01:00
parent ac73bd1a04
commit a8cd8d8060
8 changed files with 339 additions and 12 deletions

View file

@ -1 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
export { shuffleArray } from './shuffleArray';

View file

@ -0,0 +1,3 @@
export function shuffleArray<T>(data: Array<T>): Array<T> {
return data.sort(() => Math.random() - 0.5);
}

View file

@ -0,0 +1,28 @@
<script>
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([]);
</script>
<div class="app-page">
<header class="page-header">
<h1 class="page-title">Adventures</h1>
<a href={resolve('/app/adventures/new')} class="btn">Create</a>
</header>
{#each adventures as adventure (adventure.id)}
<div class="adventure-card">
<h2>{adventure.title}</h2>
<p>{adventure.description}</p>
</div>
{/each}
</div>

View file

@ -0,0 +1,12 @@
import { getRequestEvent, query } from '$app/server';
import { listAdventuresApiAdventuresGet } from '@client';
export const getAdventures = query('unchecked', async () => {
const request = getRequestEvent();
const { data } = await listAdventuresApiAdventuresGet({
headers: {
Authorization: `Bearer ${request.locals.authToken}`
}
});
return data;
});

View file

@ -0,0 +1,167 @@
import { error, type Action, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createAdventureApiAdventuresPost } from '@client';
import * as v from 'valibot';
import { shuffleArray } from '$lib';
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: [
'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 CreateFormSchema = v.object({
vibes: v.array(v.string()),
genre: v.string(),
setting: v.string(),
competency: v.pipe(v.string(), v.picklist(['A1', 'A2', 'B1', 'B2', 'C1'])),
language: v.pipe(v.string(), v.picklist(['fr', 'it', 'de', 'it', 'es'])),
length: v.string(),
protagonist_gender: v.string(),
protagonist_age: v.string()
});
export const actions = {
default: async ({ locals, request }) => {
const { authToken } = locals;
const formData = await request.formData();
const data = v.safeParse(CreateFormSchema, {
vibes: formData.getAll('vibes') as string[],
genre: formData.get('genre') as string,
setting: formData.get('setting') as string,
competency: formData.get('competency') as string,
language: formData.get('language') as string,
length: formData.get('length') as string,
protagonist_gender: formData.get('protagonist_gender') as string,
protagonist_age: formData.get('protagonist_age') as string
});
if (data.success == false) {
throw error(400, {
message: data.issues.map((e) => e.message).join(', ')
});
}
const {
competency,
language,
length,
genre,
setting,
protagonist_gender,
protagonist_age,
vibes
} = data.output;
const { data: apiData, error: apiError } = await createAdventureApiAdventuresPost({
headers: {
Authorization: `Bearer ${authToken}`
},
body: {
competencies: [competency],
language: language,
genres: [genre],
setting: [setting],
vibes: vibes,
protagonist: [protagonist_gender, protagonist_age],
source_language: 'en',
entry_word_count_range: length,
max_entry_count: 6
}
});
console.log({ apiData, apiError });
}
} satisfies Actions;

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { shuffleArray } from '$lib';
import type { ChangeEventHandler } from 'svelte/elements';
import type { PageServerData } from './$types';
const { data }: { data: PageServerData } = $props();
let theVibes = $state(shuffleArray(data.vibes).slice(0, 7));
const handleVibeSelected: ChangeEventHandler<HTMLInputElement> = (e) => {
const { checked, value } = e.currentTarget;
if (checked) {
selectedVibes = [...selectedVibes, value as string];
} else {
selectedVibes = selectedVibes.filter((v) => v !== value);
}
};
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>
<form class="form" method="POST">
<div class="field">
<label for="genre" class="label">Genre</label>
<select name="genre" id="genre">
{#each data.genres as genre (genre)}
<option value={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)}
<option value={setting}>{setting}</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} />
<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}`}
<label for={theId} class="label">
<input
type="checkbox"
disabled={selectedVibes.length >= 2 && !selectedVibes.includes(vibe)}
id={theId}
value={vibe}
onchange={handleVibeSelected}
/>
{vibe}
</label>
{/each}
</fieldset>
</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>
</div>
<div class="field">
<label for="competency" class="label">Competency</label>
<select name="competency" id="competency">
{#each data.competencies as competency (competency)}
<option value={competency}>{competency}</option>
{/each}
</select>
</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} />
<label for={inputId}>{gender}</label>
</div>
{/each}
</fieldset>
</div>
<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>
{/each}
</select>
</div>
<input type="submit" value="Create Adventure" class="submit-button" />
</form>
</section>

View file

@ -25,7 +25,7 @@
}).format(new Date(iso));
</script>
<div class="page">
<div class="app-page">
<header class="page-header">
<p class="form-eyebrow">Reading</p>
<h1 class="form-title">Articles</h1>
@ -44,7 +44,9 @@
</div>
<h2 class="article-title">{article.target_title}</h2>
<p class="article-source">{article.source_title}</p>
<time class="article-date label-md" datetime={article.published_at}>{fmt(article.published_at)}</time>
<time class="article-date label-md" datetime={article.published_at}
>{fmt(article.published_at)}</time
>
</a>
</li>
{/each}
@ -58,15 +60,6 @@
</div>
<style>
.page {
max-width: 52rem;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* --- Article list --- */
.article-list {

View file

@ -8,7 +8,7 @@ const config = {
remoteFunctions: true
},
alias: {
'@client': 'src/client/client.gen.ts'
'@client': 'src/client/sdk.gen'
}
},
compilerOptions: {