feat: [frontend] Update the adventure page, show past entries and future ones
Some checks are pending
/ test (push) Waiting to run
Some checks are pending
/ test (push) Waiting to run
This commit is contained in:
parent
1b54536647
commit
fac5d26220
8 changed files with 792 additions and 61 deletions
|
|
@ -82,6 +82,19 @@
|
|||
--colour-green-900: #1b5e20;
|
||||
--colour-green-950: #0b2f10;
|
||||
|
||||
/** Colour: Yellow palette, from Tailwind's yellow palette */
|
||||
--colour-yellow-50: #fffbeb;
|
||||
--colour-yellow-100: #fef3c7;
|
||||
--colour-yellow-200: #fde68a;
|
||||
--colour-yellow-300: #fcd34d;
|
||||
--colour-yellow-400: #fbbf24;
|
||||
--colour-yellow-500: #f59e0b;
|
||||
--colour-yellow-600: #d97706;
|
||||
--colour-yellow-700: #b45309;
|
||||
--colour-yellow-800: #92400e;
|
||||
--colour-yellow-900: #78350f;
|
||||
--colour-yellow-950: #451a03;
|
||||
|
||||
/* --- Color: On-Surface --- */
|
||||
--color-on-surface: #2f342e; /* replaces pure black */
|
||||
--color-on-surface-variant: #5c605b;
|
||||
|
|
@ -126,7 +139,7 @@
|
|||
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
|
||||
--leading-loose: 1.8;
|
||||
|
||||
/* --- Typography: Letter Spacing --- */
|
||||
/* --- Typography: Letter Spacing --- */;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0em;
|
||||
--tracking-wide: 0.05rem; /* label-md metadata */
|
||||
|
|
@ -305,7 +318,7 @@ body {
|
|||
background-color var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.btn-primary, .btn.primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
|
@ -626,3 +639,8 @@ LAYOUT: APP PAGE
|
|||
color: var(--color-on-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.app-page.full-bleed {
|
||||
max-width: none;
|
||||
padding: var(--space-0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
} else {
|
||||
event.locals.isAdmin = false;
|
||||
console.log(`Not valid and no token`);
|
||||
event.cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
|
||||
}
|
||||
|
||||
const { pathname } = event.url;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,92 @@
|
|||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import LatestEntry from './LatestEntry.svelte';
|
||||
import PreviousEntries from './PreviousEntries.svelte';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
const { data, params }: PageProps = $props();
|
||||
const latestEntry = $derived(data.entries[data.entries.length - 1]);
|
||||
|
||||
const previousEntries = $derived.by(() => {
|
||||
const allEntries = data.entries ?? [];
|
||||
|
||||
return allEntries.slice(0, -1).map((entry, index) => {
|
||||
const nextEntry = allEntries[index + 1];
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
text: entry.story_text!,
|
||||
possibleChoices: (entry.possible_choices ?? []).map((choice) => ({
|
||||
id: choice.id,
|
||||
text: choice.text,
|
||||
isSelected: nextEntry?.generated_from_choice_id === choice.id
|
||||
}))
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-page">
|
||||
<h1 class="page-title">{data.title}</h1>
|
||||
<div class="adventure-page">
|
||||
<header class="adventure-page__header">
|
||||
<p class="adventure-page__kicker">Choose your own adventure</p>
|
||||
<h1 class="adventure-page__title">{data.title}</h1>
|
||||
</header>
|
||||
|
||||
<PreviousEntries entries={previousEntries} />
|
||||
|
||||
{#if latestEntry}
|
||||
<LatestEntry sourceText={latestEntry.story_text} translationText={latestEntry.translation} />
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.adventure-page {
|
||||
max-width: 96rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-16) clamp(var(--space-3), 8vw, 7rem) var(--space-10)
|
||||
clamp(var(--space-3), 5vw, 4.5rem);
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.adventure-page__header {
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.adventure-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) 72%, transparent);
|
||||
}
|
||||
|
||||
.adventure-page__title {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 1.75rem + 1.9vw, var(--text-display-lg));
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1.04;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--color-on-surface);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@media (max-width: 56rem) {
|
||||
.adventure-page {
|
||||
padding: var(--space-8) var(--space-3) var(--space-8);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
<script lang="ts">
|
||||
import NextSteps from './NextSteps.svelte';
|
||||
import { selectNextStep } from './selectNextStep.remote';
|
||||
|
||||
type Props = {
|
||||
adventureId: string;
|
||||
sourceText: string | null | undefined;
|
||||
translationText: string | null | undefined;
|
||||
audioUrl: string;
|
||||
nextStepsOptions: { label: string; id: string }[];
|
||||
};
|
||||
|
||||
const { sourceText, translationText }: Props = $props();
|
||||
const { adventureId, sourceText, translationText, audioUrl, nextStepsOptions }: Props = $props();
|
||||
|
||||
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText));
|
||||
const translationParagraphs = $derived.by(() => toParagraphs(translationText));
|
||||
|
||||
let sourcePane: HTMLDivElement | undefined;
|
||||
let translationPane: HTMLDivElement | undefined;
|
||||
let suppressSourceScroll = false;
|
||||
let suppressTranslationScroll = false;
|
||||
let lastClickedParagraphIndex: number | null = $state(null);
|
||||
let sourcePane = $state<HTMLDivElement | undefined>();
|
||||
let translationPane = $state<HTMLDivElement | undefined>();
|
||||
let suppressSourceScroll = $state(false);
|
||||
let suppressTranslationScroll = $state(false);
|
||||
let translationVisible = $state(false);
|
||||
let translationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function toParagraphs(text: string | null | undefined): string[] {
|
||||
return (text ?? '')
|
||||
|
|
@ -55,50 +64,158 @@
|
|||
suppressSourceScroll = true;
|
||||
syncScrollPosition(translationPane, sourcePane);
|
||||
}
|
||||
|
||||
function showTranslation() {
|
||||
translationVisible = true;
|
||||
if (translationTimer !== null) {
|
||||
clearTimeout(translationTimer);
|
||||
}
|
||||
translationTimer = setTimeout(() => {
|
||||
translationVisible = false;
|
||||
translationTimer = null;
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
function handleParagraphClicked(paragraphIndex: number) {
|
||||
lastClickedParagraphIndex = paragraphIndex;
|
||||
}
|
||||
|
||||
async function handleNextStepSelect(optionId: string) {
|
||||
const result = await selectNextStep({ adventureId, possibleChoiceId: optionId });
|
||||
console.log({ result });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="latest-entry">
|
||||
<div class="pane">
|
||||
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
|
||||
{#each sourceParagraphs as paragraph, index (index)}
|
||||
<p class="paragraph" data-paragraph-index={index} data-language="target">
|
||||
{paragraph}
|
||||
</p>
|
||||
{/each}
|
||||
<section class="latest-story" aria-label="Current story entry">
|
||||
<header class="latest-story__header">
|
||||
<div class="latest-story__title-group">
|
||||
<p class="latest-story__kicker">Current entry</p>
|
||||
<h2 class="latest-story__title">Now reading</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane translation">
|
||||
<header class="translation-header">
|
||||
<p class="eyebrow">Translation</p>
|
||||
</header>
|
||||
<div class="audio-dock" aria-label="Listening controls">
|
||||
<p class="audio-dock__label">Listen</p>
|
||||
<audio class="audio-dock__player" controls preload="metadata">
|
||||
<source src={audioUrl} type="audio/wav" />
|
||||
</audio>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="latest-entry__pane-body"
|
||||
bind:this={translationPane}
|
||||
onscroll={handleTranslationScroll}
|
||||
>
|
||||
{#each translationParagraphs as paragraph, index (index)}
|
||||
<p class="paragraph" data-paragraph-index={index} data-language="source">
|
||||
{paragraph}
|
||||
</p>
|
||||
{/each}
|
||||
<div class="latest-entry">
|
||||
<div class="pane source-pane">
|
||||
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
|
||||
{#each sourceParagraphs as paragraph, index (index)}
|
||||
<p
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
data-paragraph-index={index}
|
||||
data-language="source"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane translation-pane" data-visible={translationVisible}>
|
||||
<header class="translation-header">
|
||||
<p class="translation-header__label">Translation</p>
|
||||
<button class="dict-toggle" onclick={showTranslation}>
|
||||
<span class="dict-toggle-label">Reveal for 20 seconds</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if translationVisible}
|
||||
<div
|
||||
class="latest-entry__pane-body"
|
||||
bind:this={translationPane}
|
||||
onscroll={handleTranslationScroll}
|
||||
>
|
||||
{#each translationParagraphs as paragraph, index (index)}
|
||||
<p
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
data-paragraph-index={index}
|
||||
data-language="translation"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NextSteps options={nextStepsOptions} onSelect={handleNextStepSelect} />
|
||||
|
||||
<style>
|
||||
.latest-entry {
|
||||
--latest-entry-border: color-mix(in srgb, var(--colour-outline-variant) 32%, transparent);
|
||||
--latest-entry-divider: color-mix(in srgb, var(--colour-outline-variant) 18%, transparent);
|
||||
--latest-entry-pane-height: clamp(20rem, 90vh, 50rem);
|
||||
.latest-story {
|
||||
--latest-entry-pane-height: clamp(20rem, 84vh, 48rem);
|
||||
--latest-entry-pane-padding: var(--space-3);
|
||||
|
||||
--latest-entry-pane-shadow: var(--shadow-tonal-sm);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: var(--color-surface-container-low);
|
||||
}
|
||||
|
||||
.latest-story__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 24rem);
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-1) var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.latest-story__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) 72%, transparent);
|
||||
}
|
||||
|
||||
.latest-story__title {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 1.32rem + 1vw, 2.2rem);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.audio-dock {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-surface-container-lowest);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.audio-dock__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) 72%, transparent);
|
||||
}
|
||||
|
||||
.audio-dock__player {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.latest-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
|
|
@ -108,35 +225,78 @@
|
|||
min-height: 0;
|
||||
height: var(--latest-entry-pane-height);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.pane.translation {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-primary-container) 22%, transparent),
|
||||
transparent 24%
|
||||
),
|
||||
var(--latest-entry-pane-surface);
|
||||
border: 1px solid var(--latest-entry-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--latest-entry-pane-shadow);
|
||||
.source-pane {
|
||||
background-color: var(--color-surface-container-lowest);
|
||||
}
|
||||
|
||||
.translation-pane {
|
||||
transition: height var(--duration-normal) var(--ease-standard);
|
||||
background-color: var(--color-surface-container);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.translation-pane[data-visible='true'] {
|
||||
height: var(--latest-entry-pane-height);
|
||||
}
|
||||
|
||||
.translation-pane[data-visible='false'] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.translation-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--latest-entry-pane-padding);
|
||||
padding-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.translation-header .eyebrow {
|
||||
.translation-header__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);
|
||||
}
|
||||
|
||||
.dict-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-label);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.dict-toggle:hover {
|
||||
opacity: 0.88;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dict-toggle:active {
|
||||
opacity: 0.75;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dict-toggle-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.latest-entry__pane-body {
|
||||
|
|
@ -146,6 +306,18 @@
|
|||
padding: var(--latest-entry-pane-padding);
|
||||
scrollbar-gutter: stable;
|
||||
scroll-behavior: auto;
|
||||
animation: slideIn var(--duration-normal) var(--ease-standard) forwards;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.latest-entry__pane-body::-webkit-scrollbar {
|
||||
|
|
@ -157,17 +329,25 @@
|
|||
}
|
||||
|
||||
.latest-entry__pane-body::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 24%, transparent);
|
||||
background-color: color-mix(in srgb, var(--colour-yellow-300) 24%, transparent);
|
||||
border: 0.1875rem solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
font-size: var(--text-body-xl);
|
||||
line-height: var(--leading-loose);
|
||||
font-size: clamp(1.1rem, 1rem + 0.35vw, 1.35rem);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-on-surface);
|
||||
text-wrap: pretty;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.paragraph.active {
|
||||
background-color: color-mix(in srgb, var(--color-primary-container) 56%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.paragraph + .paragraph {
|
||||
|
|
@ -175,7 +355,33 @@
|
|||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.pane .paragraph {
|
||||
font-size: clamp(1.2rem, 1rem + 0.35vw, 1.45rem);
|
||||
@media (max-width: 64rem) {
|
||||
.latest-story__header {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.audio-dock {
|
||||
max-width: 26rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 56rem) {
|
||||
.latest-story {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.latest-entry {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pane {
|
||||
height: clamp(16rem, 62vh, 30rem);
|
||||
}
|
||||
|
||||
.translation-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
173
frontend/src/routes/app/adventures/[id]/NextSteps.svelte
Normal file
173
frontend/src/routes/app/adventures/[id]/NextSteps.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts">
|
||||
type Props = {
|
||||
options: { label: string; id: string }[];
|
||||
onSelect: (optionId: string) => void;
|
||||
};
|
||||
|
||||
const { options, onSelect }: Props = $props();
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const handleOptionSelect = async (optionId: string) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await onSelect(optionId);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="next-steps" aria-label="Choose what happens next">
|
||||
<header class="next-steps__header">
|
||||
<p class="next-steps__kicker">Choose your path</p>
|
||||
<h2 class="next-steps__title">What happens next?</h2>
|
||||
</header>
|
||||
|
||||
<ol class="next-steps__list">
|
||||
{#each options as option, index (option.id)}
|
||||
<li class="next-steps__item">
|
||||
<button
|
||||
class="next-steps__button"
|
||||
onclick={() => handleOptionSelect(option.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span class="next-steps__index" aria-hidden="true"
|
||||
>{String(index + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="next-steps__label">{option.label}</span>
|
||||
<span class="next-steps__meta" aria-hidden="true">Choose</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.next-steps {
|
||||
--next-steps-surface: var(--color-surface-container-low);
|
||||
--next-steps-surface-hover: var(--color-surface-container-lowest);
|
||||
|
||||
max-width: 72ch;
|
||||
margin: var(--space-6) auto 0;
|
||||
padding: clamp(1rem, 0.9rem + 0.9vw, 1.8rem);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: var(--next-steps-surface);
|
||||
}
|
||||
|
||||
.next-steps__header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.next-steps__kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-label);
|
||||
font-size: var(--text-label-md);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--color-on-surface) 70%, transparent);
|
||||
}
|
||||
|
||||
.next-steps__title {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.35rem, 1.2rem + 0.9vw, 2rem);
|
||||
line-height: 1.15;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.next-steps__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.next-steps__item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.next-steps__button {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: clamp(0.9rem, 0.8rem + 0.5vw, 1.2rem);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-container);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--duration-fast) var(--ease-standard),
|
||||
background-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.next-steps__index {
|
||||
font-family: var(--font-label);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.08em;
|
||||
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||
}
|
||||
|
||||
.next-steps__label {
|
||||
font-family: var(--font-body);
|
||||
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.18rem);
|
||||
line-height: 1.35;
|
||||
color: var(--color-on-surface);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.next-steps__meta {
|
||||
font-family: var(--font-label);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-on-surface));
|
||||
}
|
||||
|
||||
.next-steps__button:hover:enabled {
|
||||
background: var(--next-steps-surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.next-steps__button:hover:enabled .next-steps__meta,
|
||||
.next-steps__button:focus-visible .next-steps__meta {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.next-steps__button:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--colour-outline-variant) 20%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.next-steps__button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.next-steps {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.next-steps__button {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.next-steps__meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
220
frontend/src/routes/app/adventures/[id]/PreviousEntries.svelte
Normal file
220
frontend/src/routes/app/adventures/[id]/PreviousEntries.svelte
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<script lang="ts">
|
||||
type Props = {
|
||||
entries: {
|
||||
id: string;
|
||||
text: string;
|
||||
possibleChoices: {
|
||||
id: string;
|
||||
text: string;
|
||||
isSelected: boolean;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const { entries }: Props = $props();
|
||||
|
||||
function toParagraphs(text: string): string[] {
|
||||
return text
|
||||
.split(/\n\s*\n/g)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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>
|
||||
</header>
|
||||
|
||||
<ol class="previous-entries__list">
|
||||
{#each entries as entry, index (entry.id)}
|
||||
<li class="previous-entries__item">
|
||||
<article class="entry-card" aria-label={`Entry ${index + 1}`}>
|
||||
<header class="entry-card__header">
|
||||
<p class="entry-card__index">Entry {String(index + 1).padStart(2, '0')}</p>
|
||||
</header>
|
||||
|
||||
<div class="entry-card__body">
|
||||
{#each toParagraphs(entry.text) as paragraph, paragraphIndex (paragraphIndex)}
|
||||
<p class="entry-card__paragraph">{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if entry.possibleChoices.length > 0}
|
||||
<footer class="entry-card__choices">
|
||||
<p class="entry-card__choices-label">Possible choices</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}>
|
||||
<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>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.previous-entries {
|
||||
--previous-surface: var(--color-surface-container-low);
|
||||
--previous-surface-elevated: var(--color-surface-container-lowest);
|
||||
max-width: 88rem;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.previous-entries__header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.previous-entries__title {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.3rem, 1.12rem + 0.85vw, 1.9rem);
|
||||
line-height: 1.15;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.previous-entries__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
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);
|
||||
}
|
||||
|
||||
.entry-card__header {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.entry-card__index {
|
||||
margin: 0;
|
||||
font-family: var(--font-label);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
|
||||
}
|
||||
|
||||
.entry-card__body::-webkit-scrollbar {
|
||||
width: 0.65rem;
|
||||
}
|
||||
|
||||
.entry-card__body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.entry-card__body::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(in srgb, var(--colour-yellow-300) 26%, transparent);
|
||||
border: 0.16rem solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.entry-card__paragraph {
|
||||
font-family: var(--font-body);
|
||||
font-size: clamp(1rem, 0.97rem + 0.2vw, 1.12rem);
|
||||
line-height: var(--leading-loose);
|
||||
color: var(--color-on-surface);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.entry-card__paragraph + .entry-card__paragraph {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.entry-card__choices {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.entry-card__choices-label {
|
||||
margin: 0 0 var(--space-2);
|
||||
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);
|
||||
}
|
||||
|
||||
.entry-card__choices-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.entry-card__choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.entry-card__choice-index {
|
||||
font-family: var(--font-label);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: color-mix(in srgb, var(--color-on-surface) 62%, transparent);
|
||||
}
|
||||
|
||||
.entry-card__choice-text {
|
||||
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) 84%, transparent);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.entry-card__choice-state {
|
||||
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: var(--color-primary);
|
||||
}
|
||||
|
||||
.entry-card__choice--selected {
|
||||
background-color: color-mix(in srgb, var(--color-primary-container) 56%, transparent);
|
||||
}
|
||||
|
||||
.entry-card__choice--selected .entry-card__choice-text {
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.previous-entries {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { command, getRequestEvent } from '$app/server';
|
||||
import { recordDecisionApiAdventuresAdventureIdDecisionsPost } from '@client';
|
||||
import * as v from 'valibot';
|
||||
|
||||
const selectNextStepSchema = v.object({
|
||||
adventureId: v.string(),
|
||||
possibleChoiceId: v.string()
|
||||
});
|
||||
|
||||
export const selectNextStep = command(
|
||||
selectNextStepSchema,
|
||||
async ({ adventureId, possibleChoiceId }) => {
|
||||
const { locals } = getRequestEvent();
|
||||
const { error, response, data } = await recordDecisionApiAdventuresAdventureIdDecisionsPost({
|
||||
headers: {
|
||||
Authorization: `Bearer ${locals.authToken}`
|
||||
},
|
||||
path: {
|
||||
adventure_id: adventureId
|
||||
},
|
||||
body: {
|
||||
choice_id: possibleChoiceId
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error recording decision:', error);
|
||||
throw new Error('Failed to record decision');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
);
|
||||
|
|
@ -18,7 +18,7 @@ export const actions = {
|
|||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
const { response, data } = await loginApiAuthLoginPost({
|
||||
const { response, data, error } = await loginApiAuthLoginPost({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: locals.authToken ? `Bearer ${locals.authToken}` : ''
|
||||
|
|
@ -26,6 +26,12 @@ export const actions = {
|
|||
body: { email, password }
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Error logging in:`, { error });
|
||||
cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
if (response.status === 200 && data) {
|
||||
cookies.set(COOKIE_NAME_AUTH_TOKEN, data.access_token, {
|
||||
path: '/',
|
||||
|
|
|
|||
Loading…
Reference in a new issue