feat: [frontend] First implementation of the "Create new adventure"
Some checks are pending
/ test (push) Waiting to run
Some checks are pending
/ test (push) Waiting to run
form.
This commit is contained in:
parent
ac73bd1a04
commit
a8cd8d8060
8 changed files with 339 additions and 12 deletions
|
|
@ -1 +1,2 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
export { shuffleArray } from './shuffleArray';
|
||||
|
|
|
|||
3
frontend/src/lib/shuffleArray.ts
Normal file
3
frontend/src/lib/shuffleArray.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function shuffleArray<T>(data: Array<T>): Array<T> {
|
||||
return data.sort(() => Math.random() - 0.5);
|
||||
}
|
||||
28
frontend/src/routes/app/adventures/+page.svelte
Normal file
28
frontend/src/routes/app/adventures/+page.svelte
Normal 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>
|
||||
12
frontend/src/routes/app/adventures/getAdventures.remote.ts
Normal file
12
frontend/src/routes/app/adventures/getAdventures.remote.ts
Normal 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;
|
||||
});
|
||||
167
frontend/src/routes/app/adventures/new/+page.server.ts
Normal file
167
frontend/src/routes/app/adventures/new/+page.server.ts
Normal 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;
|
||||
123
frontend/src/routes/app/adventures/new/+page.svelte
Normal file
123
frontend/src/routes/app/adventures/new/+page.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const config = {
|
|||
remoteFunctions: true
|
||||
},
|
||||
alias: {
|
||||
'@client': 'src/client/client.gen.ts'
|
||||
'@client': 'src/client/sdk.gen'
|
||||
}
|
||||
},
|
||||
compilerOptions: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue