feat: [frontend] Build the /adventures page; add i18n to the adventure page
This commit is contained in:
parent
fac5d26220
commit
7df2542d1e
10 changed files with 378 additions and 50 deletions
|
|
@ -117,14 +117,18 @@
|
|||
--text-headline-sm: 1.25rem;
|
||||
--text-title-lg: 1.125rem;
|
||||
--text-title-md: 1rem;
|
||||
--text-body-xl: 1.25rem; /* long-form reading standard */
|
||||
--text-body-lg: 1rem;
|
||||
--text-body-md: 0.9375rem;
|
||||
--text-body-sm: 0.875rem;
|
||||
--text-body-xl: clamp(1.56rem, 1vi + 1.31rem, 2.11rem);
|
||||
--text-body-lg: clamp(1.25rem, 0.61vi + 1.1rem, 1.58rem);
|
||||
--text-body-md: clamp(1rem, 0.34vi + 0.91rem, 1.19rem);
|
||||
--text-body-sm: clamp(0.8rem, 0.17vi + 0.76rem, 0.89rem);
|
||||
--text-label-lg: 0.875rem;
|
||||
--text-label-md: 0.75rem; /* metadata, all-caps */
|
||||
--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 --- */
|
||||
--weight-light: 300;
|
||||
--weight-regular: 400;
|
||||
|
|
@ -138,6 +142,7 @@
|
|||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
|
||||
--leading-loose: 1.8;
|
||||
--leading-xloose: 2.25;
|
||||
|
||||
/* --- Typography: Letter Spacing --- */;
|
||||
--tracking-tight: -0.025em;
|
||||
|
|
|
|||
29
frontend/src/lib/i8n/index.ts
Normal file
29
frontend/src/lib/i8n/index.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,32 +1,88 @@
|
|||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { resolve } from '$app/paths';
|
||||
import AdventuresList from './AdventuresList.svelte';
|
||||
|
||||
import { getAdventures } from './getAdventures.remote';
|
||||
|
||||
let adventures = $derived(await getAdventures(''));
|
||||
let adventures = $derived((await getAdventures('')) ?? []);
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
{#if data.successMessage !== null}
|
||||
<div class="alert success">
|
||||
<div class="adventures-success" role="status" aria-live="polite">
|
||||
{data.successMessage}
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
{#each adventures as adventure (adventure.id)}
|
||||
<div class="adventure-card">
|
||||
<a href={resolve('/app/adventures/[id]', { id: adventure.id })}>
|
||||
<h2>{adventure.title}</h2>
|
||||
<p>{adventure.description}</p>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<AdventuresList {adventures} />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.adventures-page {
|
||||
max-width: 92rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-16) clamp(var(--space-3), 8vw, 6.5rem) var(--space-10)
|
||||
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>
|
||||
|
|
|
|||
99
frontend/src/routes/app/adventures/AdventuresList.svelte
Normal file
99
frontend/src/routes/app/adventures/AdventuresList.svelte
Normal 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: Date;
|
||||
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>
|
||||
82
frontend/src/routes/app/adventures/AdventuresListItem.svelte
Normal file
82
frontend/src/routes/app/adventures/AdventuresListItem.svelte
Normal 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>
|
||||
|
|
@ -18,12 +18,13 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||
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));
|
||||
return {
|
||||
title: title,
|
||||
entries,
|
||||
choices: current_entry_choices
|
||||
choices: current_entry_choices,
|
||||
language: language
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import LatestEntry from './LatestEntry.svelte';
|
||||
import PreviousEntries from './PreviousEntries.svelte';
|
||||
import { locale, type Locale } from '$lib/i8n';
|
||||
|
||||
const { data, params }: PageProps = $props();
|
||||
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>
|
||||
|
||||
<div class="adventure-page">
|
||||
|
|
@ -33,18 +45,16 @@
|
|||
|
||||
<PreviousEntries entries={previousEntries} />
|
||||
|
||||
{#if latestEntry}
|
||||
<LatestEntry
|
||||
sourceText={latestEntry.story_text}
|
||||
translationText={latestEntry.translation}
|
||||
audioUrl={latestEntry.audio_url!}
|
||||
nextStepsOptions={data.choices.map((choice) => ({
|
||||
label: choice.text,
|
||||
id: choice.id
|
||||
}))}
|
||||
adventureId={params.id}
|
||||
/>
|
||||
{/if}
|
||||
<LatestEntry
|
||||
sourceText={latestEntry.story_text}
|
||||
translationText={latestEntry.translation}
|
||||
audioUrl={latestEntry.audio_url!}
|
||||
nextStepsOptions={data.choices.map((choice) => ({
|
||||
label: choice.text,
|
||||
id: choice.id
|
||||
}))}
|
||||
adventureId={params.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
}
|
||||
</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">
|
||||
<div class="latest-story__title-group">
|
||||
<p class="latest-story__kicker">Current entry</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { locale, makeTranslate } from '$lib/i8n';
|
||||
import translations from '../translations';
|
||||
|
||||
type Props = {
|
||||
entries: {
|
||||
id: string;
|
||||
|
|
@ -11,6 +14,8 @@
|
|||
}[];
|
||||
};
|
||||
|
||||
const t = $derived(makeTranslate(translations, $locale));
|
||||
|
||||
const { entries }: Props = $props();
|
||||
|
||||
function toParagraphs(text: string): string[] {
|
||||
|
|
@ -24,15 +29,24 @@
|
|||
{#if entries.length > 0}
|
||||
<section class="previous-entries" aria-label="Previous story entries">
|
||||
<header class="previous-entries__header">
|
||||
<h2 class="previous-entries__title">Previous entries</h2>
|
||||
<h2 class="previous-entries__title">{t('previousEntries.title')}</h2>
|
||||
</header>
|
||||
|
||||
<ol class="previous-entries__list">
|
||||
<ol class="list">
|
||||
{#each entries as entry, index (entry.id)}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<div class="entry-card__body">
|
||||
|
|
@ -43,16 +57,19 @@
|
|||
|
||||
{#if entry.possibleChoices.length > 0}
|
||||
<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">
|
||||
{#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"
|
||||
>{String(choiceIndex + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="entry-card__choice-text">{choice.text}</span>
|
||||
{#if choice.isSelected}
|
||||
<span class="entry-card__choice-state">Selected</span>
|
||||
<span class="entry-card__choice-state">{t('options.selected')}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -86,19 +103,24 @@
|
|||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.previous-entries__list {
|
||||
.previous-entries .list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
padding: clamp(0.9rem, 0.8rem + 0.7vw, 1.4rem);
|
||||
border-radius: var(--radius-lg);
|
||||
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 {
|
||||
|
|
@ -132,10 +154,11 @@
|
|||
|
||||
.entry-card__paragraph {
|
||||
font-family: var(--font-body);
|
||||
font-size: clamp(1rem, 0.97rem + 0.2vw, 1.12rem);
|
||||
line-height: var(--leading-loose);
|
||||
font-size: var(--text-body-md);
|
||||
line-height: var(--leading-xloose);
|
||||
color: var(--color-on-surface);
|
||||
text-wrap: pretty;
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
.entry-card__paragraph + .entry-card__paragraph {
|
||||
|
|
@ -144,8 +167,8 @@
|
|||
}
|
||||
|
||||
.entry-card__choices {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
max-width: 65ch;
|
||||
font-size: var(--text-body-md);
|
||||
}
|
||||
|
||||
.entry-card__choices-label {
|
||||
|
|
@ -169,7 +192,7 @@
|
|||
.entry-card__choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
place-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
|
|
@ -185,10 +208,11 @@
|
|||
|
||||
.entry-card__choice-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-body-lg);
|
||||
font-size: var(--text-body-md);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: color-mix(in srgb, var(--color-on-surface) 84%, transparent);
|
||||
text-wrap: pretty;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entry-card__choice-state {
|
||||
|
|
|
|||
22
frontend/src/routes/app/adventures/translations.ts
Normal file
22
frontend/src/routes/app/adventures/translations.ts
Normal 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é'
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue