Compare commits

...

8 commits

Author SHA1 Message Date
48bbcac9a6 feat: [frontend] Build form and UI for creating a Choose Your Own
Some checks failed
/ test (push) Has been cancelled
Adventure
2026-05-04 08:02:47 +01:00
568b907013 refactor: [frontend] color -> colour 2026-05-04 08:00:59 +01:00
bcc12e3fad Update hooks.server.ts 2026-05-04 08:00:06 +01:00
4c60a3ca91 feat: [frontend] Add randomItemInArray helper 2026-05-04 07:59:59 +01:00
e075f2dc39 Update types.gen.ts 2026-05-04 07:59:34 +01:00
ae709ac8f2 styles: [frontend] Add various form and btn components 2026-05-04 07:59:27 +01:00
d1243a1997 Update app.d.ts 2026-05-04 07:59:03 +01:00
149c821959 Update openapi.json 2026-05-04 07:58:59 +01:00
16 changed files with 315 additions and 143 deletions

File diff suppressed because one or more lines are too long

View file

@ -56,13 +56,39 @@
--colour-grey-950: #030712;
--colour-grey-1000: #000000;
/** Colour: Red palette, based on Material You's red palette */
--colour-red-50: #ffebee;
--colour-red-100: #ffcdd2;
--colour-red-200: #ef9a9a;
--colour-red-300: #e57373;
--colour-red-400: #ef5350;
--colour-red-500: #f44336;
--colour-red-600: #e53935;
--colour-red-700: #d32f2f;
--colour-red-800: #c62828;
--colour-red-900: #b71c1c;
--colour-red-950: #7f0000;
/** Colour: Green palette, based on Material You's green palette */
--colour-green-50: #e8f5e9;
--colour-green-100: #c8e6c9;
--colour-green-200: #a5d6a7;
--colour-green-300: #81c784;
--colour-green-400: #66bb6a;
--colour-green-500: #4caf50;
--colour-green-600: #43a047;
--colour-green-700: #388e3c;
--colour-green-800: #2e7d32;
--colour-green-900: #1b5e20;
--colour-green-950: #0b2f10;
/* --- Color: On-Surface --- */
--color-on-surface: #2f342e; /* replaces pure black */
--color-on-surface-variant: #5c605b;
/* --- Color: Outline --- */
--color-outline: #8c908b;
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
--colour-outline: #8c908b;
--colour-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
/* --- Typography: Font Families --- */
--font-display: 'archivo', sans-serif;
@ -218,6 +244,12 @@ body {
gap: var(--space-4);
}
.form.outline {
padding: var(--space-4);
border: 1px solid var(--colour-outline);
border-radius: var(--radius-md);
}
.form-header {
margin-bottom: var(--space-6);
}
@ -293,6 +325,23 @@ body {
padding-inline: var(--space-2);
}
.btn-submit {
background-color: var(--color-primary);
color: var(--color-on-primary);
padding: var(--space-1) var(--space-6);
font-weight: var(--weight-semi-bold);
font-size: var(--text-body-md);
border: 1px solid var(--color-primary);
}
.btn-submit:hover {
opacity: 0.88;
}
.btn-submit:active {
opacity: 0.75;
}
/* --------------------------------------------------------------------------
Component: Input
-------------------------------------------------------------------------- */
@ -322,7 +371,7 @@ body {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--colour-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
@ -340,11 +389,11 @@ body {
padding: var(--space-2) var(--space-3);
background-color: var(--color-surface-container);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--colour-outline-variant) 20%, transparent);
}
.field-input::placeholder {
color: var(--color-outline-variant);
color: var(--colour-outline-variant);
}
.field-input:focus {
@ -355,7 +404,7 @@ body {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--colour-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
font-family: var(--font-body);
@ -375,11 +424,16 @@ body {
border-bottom: 2px solid var(--color-primary);
}
.field-select:disabled {
cursor: not-allowed;
opacity: 0.75;
}
.field-textarea {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--colour-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
@ -393,7 +447,7 @@ body {
}
.field-textarea::placeholder {
color: var(--color-outline-variant);
color: var(--colour-outline-variant);
}
.field-textarea:focus {
@ -407,6 +461,29 @@ body {
margin-top: var(--space-1);
}
/* --------------------------------------------------------------------------
Component: Form divider
-------------------------------------------------------------------------- */
.form-divider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-3) 0;
}
.form-divider-text {
font-family: var(--font-label);
color: var(--color-on-surface);
white-space: nowrap;
}
.form-divider-line {
flex: 1;
height: 1px;
background-color: color-mix(in srgb, var(--colour-outline-variant) 40%, transparent);
}
/* --------------------------------------------------------------------------
Component: Button (modifiers)
-------------------------------------------------------------------------- */
@ -518,9 +595,15 @@ body {
}
.alert-error {
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
color: #b3261e;
border-left: 3px solid #b3261e;
background-color: color-mix(in srgb, var(--colour-red-700) 10%, var(--color-surface));
color: var(--colour-red-700);
border-left: 3px solid var(--colour-red-700);
}
.alert.success {
background-color: color-mix(in srgb, var(--colour-green-700) 10%, var(--color-surface));
color: var(--colour-green-700);
border-left: 3px solid var(--colour-green-700);
}
/*

View file

@ -9,6 +9,10 @@ declare global {
apiClient?: ApiClient;
authToken: string | null;
isAdmin: boolean;
flash?: {
type: 'success';
message: string;
};
}
// interface PageData {}
// interface PageState {}

View file

@ -416,6 +416,10 @@ export type CreateAdventureRequest = {
* Protagonist
*/
protagonist: Array<string>;
/**
* Entry Word Count Range
*/
entry_word_count_range: string;
/**
* Max Entry Count
*/

View file

@ -24,7 +24,6 @@ export const handle: Handle = async ({ event, resolve }) => {
const rawToken = event.cookies.get(COOKIE_NAME_AUTH_TOKEN);
const isValid = rawToken ? await verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
console.log({ isValid });
event.locals.authToken = isValid ? rawToken! : null;
if (isValid && rawToken) {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
interface Props {
@ -12,7 +13,7 @@
<header class="topnav" role="banner">
<div class="topnav-inner">
<a href="/app" class="wordmark" aria-label="Home">
<a href={resolve('/app')} class="wordmark" aria-label="Home">
<span class="wordmark-text">Language Learning App</span>
</a>
@ -20,7 +21,17 @@
<ul class="nav-links" role="list">
<li>
<a
href="/app/articles"
href={resolve('/app/adventures')}
class="nav-link"
class:is-active={isActive('/app/adventures')}
aria-current={isActive('/app/adventures') ? 'page' : undefined}
>
Adventures
</a>
</li>
<li>
<a
href={resolve('/app/articles')}
class="nav-link"
class:is-active={isActive('/app/articles')}
aria-current={isActive('/app/articles') ? 'page' : undefined}
@ -73,7 +84,7 @@
background-color: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
box-shadow: 0 1px 0 color-mix(in srgb, var(--color-outline-variant) 35%, transparent);
box-shadow: 0 1px 0 color-mix(in srgb, var(--colour-outline-variant) 35%, transparent);
}
.topnav-inner {

View file

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

View file

@ -0,0 +1,4 @@
export function randomItemInArray<T>(arr: Array<T>): T {
const index = Math.floor(Math.random() * arr.length);
return arr[index];
}

View file

@ -130,7 +130,7 @@
background-color: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
box-shadow: 0 1px 0 color-mix(in srgb, var(--color-outline-variant) 35%, transparent);
box-shadow: 0 1px 0 color-mix(in srgb, var(--colour-outline-variant) 35%, transparent);
}
.sitenav-inner {
@ -187,7 +187,7 @@
/* ---------- Left margin ---------- */
.left-margin {
border-right: 1px solid var(--color-outline-variant);
border-right: 1px solid var(--colour-outline-variant);
padding: var(--space-12) var(--space-5) var(--space-8);
display: flex;
flex-direction: column;
@ -288,7 +288,7 @@
.divider {
border: none;
border-top: 1px solid var(--color-outline-variant);
border-top: 1px solid var(--colour-outline-variant);
margin: 0;
}
@ -325,7 +325,7 @@
/* ---------- Right rail ---------- */
.right-rail {
border-left: 1px dashed var(--color-outline-variant);
border-left: 1px dashed var(--colour-outline-variant);
padding: var(--space-12) var(--space-4);
display: flex;
align-items: flex-start;
@ -338,7 +338,7 @@
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-outline);
color: var(--colour-outline);
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
@ -364,7 +364,7 @@
.left-margin {
border-right: none;
border-bottom: 1px solid var(--color-outline-variant);
border-bottom: 1px solid var(--colour-outline-variant);
padding: var(--space-6) var(--space-6) var(--space-5);
flex-direction: row;
flex-wrap: wrap;

View file

@ -87,7 +87,7 @@
/* ---------- Left margin ---------- */
.left-margin {
border-right: 1px solid var(--color-outline-variant);
border-right: 1px solid var(--colour-outline-variant);
padding: var(--space-12) var(--space-5) var(--space-8);
display: flex;
flex-direction: column;
@ -188,7 +188,7 @@
.divider {
border: none;
border-top: 1px solid var(--color-outline-variant);
border-top: 1px solid var(--colour-outline-variant);
margin: 0;
}
@ -249,7 +249,7 @@
/* ---------- Right rail ---------- */
.right-rail {
border-left: 1px dashed var(--color-outline-variant);
border-left: 1px dashed var(--colour-outline-variant);
padding: var(--space-12) var(--space-4);
display: flex;
align-items: flex-start;
@ -262,7 +262,7 @@
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-outline);
color: var(--colour-outline);
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
@ -288,7 +288,7 @@
.left-margin {
border-right: none;
border-bottom: 1px solid var(--color-outline-variant);
border-bottom: 1px solid var(--colour-outline-variant);
padding: var(--space-6) var(--space-6) var(--space-5);
flex-direction: row;
flex-wrap: wrap;

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,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 { 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 () => {
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 {
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' },
@ -15,18 +95,8 @@ export const load: PageServerLoad = async () => {
{ 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'
]),
genres: allGenres.sort((a, b) => a.localeCompare(b)),
selectedGenre: randomItemInArray(allGenres),
eras: [
'Ancient/Classical',
'Medieval',
@ -51,52 +121,10 @@ export const load: PageServerLoad = async () => {
'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'
]
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>

View file

@ -171,7 +171,7 @@
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--color-outline-variant);
border: 2px solid var(--colour-outline-variant);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;

View file

@ -59,7 +59,7 @@
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
border-top: 1px solid color-mix(in srgb, var(--colour-outline-variant) 30%, transparent);
}
.section-title {