Compare commits

...

2 commits

Author SHA1 Message Date
01e09680c8 fix: [frontend] type of Date to string for incoming request
Some checks are pending
/ test (push) Waiting to run
2026-05-07 21:43:44 +01:00
7df2542d1e feat: [frontend] Build the /adventures page; add i18n to the adventure page 2026-05-07 21:43:02 +01:00
10 changed files with 378 additions and 50 deletions

View file

@ -117,14 +117,18 @@
--text-headline-sm: 1.25rem; --text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem; --text-title-lg: 1.125rem;
--text-title-md: 1rem; --text-title-md: 1rem;
--text-body-xl: 1.25rem; /* long-form reading standard */ --text-body-xl: clamp(1.56rem, 1vi + 1.31rem, 2.11rem);
--text-body-lg: 1rem; --text-body-lg: clamp(1.25rem, 0.61vi + 1.1rem, 1.58rem);
--text-body-md: 0.9375rem; --text-body-md: clamp(1rem, 0.34vi + 0.91rem, 1.19rem);
--text-body-sm: 0.875rem; --text-body-sm: clamp(0.8rem, 0.17vi + 0.76rem, 0.89rem);
--text-label-lg: 0.875rem; --text-label-lg: 0.875rem;
--text-label-md: 0.75rem; /* metadata, all-caps */ --text-label-md: 0.75rem; /* metadata, all-caps */
--text-label-sm: 0.6875rem; --text-label-sm: 0.6875rem;
--fs-xl: clamp(1.95rem, 1.56vi + 1.56rem, 2.81rem);
--fs-xxl: clamp(2.44rem, 2.38vi + 1.85rem, 3.75rem);
--fs-xxxl: clamp(3.05rem, 3.54vi + 2.17rem, 5rem);
/* --- Typography: Weights --- */ /* --- Typography: Weights --- */
--weight-light: 300; --weight-light: 300;
--weight-regular: 400; --weight-regular: 400;
@ -138,6 +142,7 @@
--leading-normal: 1.5; --leading-normal: 1.5;
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */ --leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
--leading-loose: 1.8; --leading-loose: 1.8;
--leading-xloose: 2.25;
/* --- Typography: Letter Spacing --- */; /* --- Typography: Letter Spacing --- */;
--tracking-tight: -0.025em; --tracking-tight: -0.025em;

View file

@ -0,0 +1,29 @@
import { derived, writable, type Writable } from 'svelte/store';
export type Locale = 'en' | 'fr';
export const locale: Writable<Locale> = writable('en');
export function makeTranslate<T extends Record<string, any>>(translations: T, locale: Locale) {
return function (key: string, vars: Record<string, string> = {}): string {
// Keys can be e.g. 'cards.title', so we split by ., and have to access
// nested props
let translation = '';
let localeText = translations[locale];
for (const part of key.split('.')) {
translation = localeText[part];
if (!translation) break;
localeText = localeText[part];
}
if (!translation) throw new Error(`no translation found for ${locale}.${key}`);
// Replace any passed in variables in the translation string.
// Variables are denoted by {{variableName}} in the translation string.
Object.keys(vars).map((k) => {
const regex = new RegExp(`{{${k}}}`, 'g');
translation = translation.replace(regex, vars[k]);
});
return translation;
};
}

View file

@ -1,32 +1,88 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AdventuresList from './AdventuresList.svelte';
import { getAdventures } from './getAdventures.remote'; import { getAdventures } from './getAdventures.remote';
let adventures = $derived(await getAdventures('')); let adventures = $derived((await getAdventures('')) ?? []);
const { data }: PageProps = $props(); const { data }: PageProps = $props();
</script> </script>
{#if data.successMessage !== null} {#if data.successMessage !== null}
<div class="alert success"> <div class="adventures-success" role="status" aria-live="polite">
{data.successMessage} {data.successMessage}
</div> </div>
{/if} {/if}
<div class="app-page">
<header class="page-header">
<h1 class="page-title">Adventures</h1>
<a href={resolve('/app/adventures/new')} class="btn">Create</a> <section class="adventures-page">
<header class="adventures-page__header">
<p class="adventures-page__kicker">Library</p>
<h1 class="adventures-page__title">Adventures</h1>
<a href={resolve('/app/adventures/new')} class="btn btn-primary">Create</a>
</header> </header>
{#each adventures as adventure (adventure.id)} <AdventuresList {adventures} />
<div class="adventure-card"> </section>
<a href={resolve('/app/adventures/[id]', { id: adventure.id })}>
<h2>{adventure.title}</h2> <style>
<p>{adventure.description}</p> .adventures-page {
</a> max-width: 92rem;
</div> margin: 0 auto;
{/each} padding: var(--space-16) clamp(var(--space-3), 8vw, 6.5rem) var(--space-10)
</div> clamp(var(--space-3), 5vw, 4rem);
display: grid;
gap: var(--space-6);
}
.adventures-page__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: end;
gap: var(--space-3);
}
.adventures-page__kicker {
margin: 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 70%, transparent);
}
.adventures-page__title {
margin: var(--space-1) 0 0;
font-family: var(--font-display);
font-size: clamp(2rem, 1.8rem + 1.6vw, 3.2rem);
font-weight: var(--weight-semibold);
line-height: 1.04;
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
}
.adventures-success {
max-width: 92rem;
margin: 0 auto;
padding: var(--space-3) clamp(var(--space-3), 5vw, 4rem);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: color-mix(in srgb, var(--colour-green-700) 82%, var(--color-on-surface));
background-color: color-mix(in srgb, var(--colour-green-100) 38%, var(--color-surface));
}
@media (max-width: 56rem) {
.adventures-page {
padding: var(--space-8) var(--space-3);
gap: var(--space-5);
}
.adventures-page__header {
align-items: start;
}
}
</style>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import AdventuresListItem from './AdventuresListItem.svelte';
import { resolve } from '$app/paths';
type Adventure = {
id: string;
title: string;
description: string | null;
created_at: string;
genres: string[];
vibes: string[];
};
type Props = {
adventures: Adventure[];
};
const { adventures }: Props = $props();
const makeEyebrowText = (adventure: Adventure): string => {
const genresText = adventure.genres.length > 0 ? adventure.genres.join(', ') : 'No genres';
const vibesText = adventure.vibes.length > 0 ? adventure.vibes.join(', ') : 'No vibes';
return `${genresText} | ${vibesText}`;
};
</script>
{#if adventures.length === 0}
<section class="adventures-empty" aria-label="No adventures yet">
<p class="adventures-empty__label">No adventures yet</p>
<p class="adventures-empty__copy">Create one to begin your next story.</p>
</section>
{:else}
<ol class="adventures-list" aria-label="Available adventures">
{#each adventures as adventure, index (adventure.id)}
<li class="adventures-list__item">
<AdventuresListItem
href={resolve('/app/adventures/[id]', { id: adventure.id })}
title={adventure.title}
description={adventure.description}
eyebrowText={makeEyebrowText(adventure)}
createdAt={new Date(adventure.created_at)}
/>
</li>
{/each}
</ol>
{/if}
<style>
.adventures-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-3);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-xl);
padding: var(--space-3);
}
.adventures-list__item {
margin: 0;
}
.adventures-empty {
padding: var(--space-6) var(--space-4);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-xl);
}
.adventures-empty__label {
margin: 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 70%, transparent);
}
.adventures-empty__copy {
margin: var(--space-2) 0 0;
font-family: var(--font-body);
font-size: var(--text-body-xl);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
}
@media (min-width: 60rem) {
.adventures-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 56rem) {
.adventures-list {
padding: var(--space-2);
}
}
</style>

View file

@ -0,0 +1,82 @@
<script lang="ts">
type Props = {
href: string;
title: string;
description: string | null;
eyebrowText: string;
createdAt: Date;
};
const { href, title, description, eyebrowText, createdAt }: Props = $props();
const dateFormatter = Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
});
</script>
<a class="adventure-item" {href}>
<p class="eyebrow">{eyebrowText}</p>
<h2 class="title">{title}</h2>
{#if description}
<p class="description">{description}</p>
{/if}
<p class="date">{dateFormatter.format(createdAt)}</p>
</a>
<style>
.adventure-item {
display: grid;
gap: var(--space-2);
padding: clamp(1rem, 0.85rem + 0.7vw, 1.5rem);
background-color: var(--color-surface-container-lowest);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition:
transform var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard);
}
.adventure-item:hover,
.adventure-item:focus-visible {
transform: translateY(-1px);
background-color: var(--color-surface);
}
.eyebrow {
margin: 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.title {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.25rem, 1.15rem + 0.5vw, 1.65rem);
font-weight: var(--weight-semibold);
line-height: 1.15;
color: var(--color-on-surface);
text-wrap: balance;
}
.description {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
color: color-mix(in srgb, var(--color-on-surface) 90%, transparent);
text-wrap: pretty;
}
.date {
margin: 0;
font-family: var(--font-label);
font-size: var(--text-label-sm);
color: color-mix(in srgb, var(--color-on-surface) 70%, transparent);
}
</style>

View file

@ -18,12 +18,13 @@ export const load: PageServerLoad = async ({ locals, params }) => {
return error(400, `Error loading adventure`); return error(400, `Error loading adventure`);
} }
const { title, entries, current_entry_choices } = response.data; const { title, entries, current_entry_choices, language } = response.data;
response.data.entries.forEach((e) => console.log(e.story_text)); response.data.entries.forEach((e) => console.log(e.story_text));
return { return {
title: title, title: title,
entries, entries,
choices: current_entry_choices choices: current_entry_choices,
language: language
}; };
}; };

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import LatestEntry from './LatestEntry.svelte'; import LatestEntry from './LatestEntry.svelte';
import PreviousEntries from './PreviousEntries.svelte'; import PreviousEntries from './PreviousEntries.svelte';
import { locale, type Locale } from '$lib/i8n';
const { data, params }: PageProps = $props(); const { data, params }: PageProps = $props();
const latestEntry = $derived(data.entries[data.entries.length - 1]); const latestEntry = $derived(data.entries[data.entries.length - 1]);
@ -23,6 +25,16 @@
}; };
}); });
}); });
onMount(() => {
$locale = data.language as Locale;
// Scroll down to the #latest-story entry, in a nice way
const latestStoryElement = document.getElementById('latest-story');
if (latestStoryElement) {
latestStoryElement.scrollIntoView({ behavior: 'smooth' });
}
});
</script> </script>
<div class="adventure-page"> <div class="adventure-page">
@ -33,7 +45,6 @@
<PreviousEntries entries={previousEntries} /> <PreviousEntries entries={previousEntries} />
{#if latestEntry}
<LatestEntry <LatestEntry
sourceText={latestEntry.story_text} sourceText={latestEntry.story_text}
translationText={latestEntry.translation} translationText={latestEntry.translation}
@ -44,7 +55,6 @@
}))} }))}
adventureId={params.id} adventureId={params.id}
/> />
{/if}
</div> </div>
<style> <style>

View file

@ -86,7 +86,7 @@
} }
</script> </script>
<section class="latest-story" aria-label="Current story entry"> <section class="latest-story" aria-label="Current story entry" id="latest-story">
<header class="latest-story__header"> <header class="latest-story__header">
<div class="latest-story__title-group"> <div class="latest-story__title-group">
<p class="latest-story__kicker">Current entry</p> <p class="latest-story__kicker">Current entry</p>

View file

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { locale, makeTranslate } from '$lib/i8n';
import translations from '../translations';
type Props = { type Props = {
entries: { entries: {
id: string; id: string;
@ -11,6 +14,8 @@
}[]; }[];
}; };
const t = $derived(makeTranslate(translations, $locale));
const { entries }: Props = $props(); const { entries }: Props = $props();
function toParagraphs(text: string): string[] { function toParagraphs(text: string): string[] {
@ -24,15 +29,24 @@
{#if entries.length > 0} {#if entries.length > 0}
<section class="previous-entries" aria-label="Previous story entries"> <section class="previous-entries" aria-label="Previous story entries">
<header class="previous-entries__header"> <header class="previous-entries__header">
<h2 class="previous-entries__title">Previous entries</h2> <h2 class="previous-entries__title">{t('previousEntries.title')}</h2>
</header> </header>
<ol class="previous-entries__list"> <ol class="list">
{#each entries as entry, index (entry.id)} {#each entries as entry, index (entry.id)}
<li class="previous-entries__item"> <li class="previous-entries__item">
<article class="entry-card" aria-label={`Entry ${index + 1}`}> <article
class="entry-card"
aria-label={t('previousEntries.entryTitle', {
entryNumber: String(index + 1).padStart(2, '0')
})}
>
<header class="entry-card__header"> <header class="entry-card__header">
<p class="entry-card__index">Entry {String(index + 1).padStart(2, '0')}</p> <p class="entry-card__index">
{t('previousEntries.entryTitle', {
entryNumber: String(index + 1).padStart(2, '0')
})}
</p>
</header> </header>
<div class="entry-card__body"> <div class="entry-card__body">
@ -43,16 +57,19 @@
{#if entry.possibleChoices.length > 0} {#if entry.possibleChoices.length > 0}
<footer class="entry-card__choices"> <footer class="entry-card__choices">
<p class="entry-card__choices-label">Possible choices</p> <p class="entry-card__choices-label">{t('previousEntries.possibleChoices')}</p>
<ul class="entry-card__choices-list" aria-label="Choices for this entry"> <ul class="entry-card__choices-list" aria-label="Choices for this entry">
{#each entry.possibleChoices as choice, choiceIndex (choice.id)} {#each entry.possibleChoices as choice, choiceIndex (choice.id)}
<li class="entry-card__choice" class:entry-card__choice--selected={choice.isSelected}> <li
class="entry-card__choice"
class:entry-card__choice--selected={choice.isSelected}
>
<span class="entry-card__choice-index" aria-hidden="true" <span class="entry-card__choice-index" aria-hidden="true"
>{String(choiceIndex + 1).padStart(2, '0')}</span >{String(choiceIndex + 1).padStart(2, '0')}</span
> >
<span class="entry-card__choice-text">{choice.text}</span> <span class="entry-card__choice-text">{choice.text}</span>
{#if choice.isSelected} {#if choice.isSelected}
<span class="entry-card__choice-state">Selected</span> <span class="entry-card__choice-state">{t('options.selected')}</span>
{/if} {/if}
</li> </li>
{/each} {/each}
@ -86,19 +103,24 @@
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.previous-entries__list { .previous-entries .list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-5);
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.entry-card { .entry-card {
padding: clamp(0.9rem, 0.8rem + 0.7vw, 1.4rem);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background-color: var(--previous-surface-elevated); background-color: var(--previous-surface-elevated);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
place-items: center;
gap: var(--space-5);
padding: var(--space-4) var(--space-1);
} }
.entry-card__header { .entry-card__header {
@ -132,10 +154,11 @@
.entry-card__paragraph { .entry-card__paragraph {
font-family: var(--font-body); font-family: var(--font-body);
font-size: clamp(1rem, 0.97rem + 0.2vw, 1.12rem); font-size: var(--text-body-md);
line-height: var(--leading-loose); line-height: var(--leading-xloose);
color: var(--color-on-surface); color: var(--color-on-surface);
text-wrap: pretty; text-wrap: pretty;
max-width: 65ch;
} }
.entry-card__paragraph + .entry-card__paragraph { .entry-card__paragraph + .entry-card__paragraph {
@ -144,8 +167,8 @@
} }
.entry-card__choices { .entry-card__choices {
margin-top: var(--space-3); max-width: 65ch;
padding-top: var(--space-2); font-size: var(--text-body-md);
} }
.entry-card__choices-label { .entry-card__choices-label {
@ -169,7 +192,7 @@
.entry-card__choice { .entry-card__choice {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
align-items: start; place-items: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-2); padding: var(--space-2);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@ -185,10 +208,11 @@
.entry-card__choice-text { .entry-card__choice-text {
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-md);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
color: color-mix(in srgb, var(--color-on-surface) 84%, transparent); color: color-mix(in srgb, var(--color-on-surface) 84%, transparent);
text-wrap: pretty; text-wrap: pretty;
width: 100%;
} }
.entry-card__choice-state { .entry-card__choice-state {

View file

@ -0,0 +1,22 @@
export default {
en: {
previousEntries: {
title: 'Previous entries',
possibleChoices: 'Possible choices',
entryTitle: `Entry {{entryNumber}}`
},
options: {
selected: 'Selected'
}
},
fr: {
previousEntries: {
title: 'Entrées précédentes',
possibleChoices: 'Choix possibles',
entryTitle: `Entrée {{entryNumber}}`
},
options: {
selected: 'Sélectionné'
}
}
};