feat: [frontend] Create the parallel scroll for the latest entry
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
17dc49482c
commit
b91f6f81f8
5 changed files with 233 additions and 4 deletions
|
|
@ -5,7 +5,6 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
if (url.searchParams.get('created')) {
|
if (url.searchParams.get('created')) {
|
||||||
successMessage = `Adventure created, check back in a few minutes`;
|
successMessage = `Adventure created, check back in a few minutes`;
|
||||||
url.searchParams.delete('created');
|
url.searchParams.delete('created');
|
||||||
url.searchParams.
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
successMessage
|
successMessage
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,10 @@
|
||||||
|
|
||||||
{#each adventures as adventure (adventure.id)}
|
{#each adventures as adventure (adventure.id)}
|
||||||
<div class="adventure-card">
|
<div class="adventure-card">
|
||||||
|
<a href={resolve('/app/adventures/[id]', { id: adventure.id })}>
|
||||||
<h2>{adventure.title}</h2>
|
<h2>{adventure.title}</h2>
|
||||||
<p>{adventure.description}</p>
|
<p>{adventure.description}</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
29
frontend/src/routes/app/adventures/[id]/+page.server.ts
Normal file
29
frontend/src/routes/app/adventures/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { getAdventureBffAdventureAdventureIdGet } from '@client';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
|
const response = await getAdventureBffAdventureAdventureIdGet({
|
||||||
|
path: {
|
||||||
|
adventure_id: params.id
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${locals.authToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.error(`Error fetching an adventure:`);
|
||||||
|
console.error({ error: response.error });
|
||||||
|
return error(400, `Error loading adventure`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, entries, current_entry_choices } = response.data;
|
||||||
|
|
||||||
|
response.data.entries.forEach((e) => console.log(e.story_text));
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
entries,
|
||||||
|
choices: current_entry_choices
|
||||||
|
};
|
||||||
|
};
|
||||||
18
frontend/src/routes/app/adventures/[id]/+page.svelte
Normal file
18
frontend/src/routes/app/adventures/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import LatestEntry from './LatestEntry.svelte';
|
||||||
|
|
||||||
|
const { data }: PageProps = $props();
|
||||||
|
const latestEntry = $derived(data.entries[data.entries.length - 1]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app-page">
|
||||||
|
<h1 class="page-title">{data.title}</h1>
|
||||||
|
|
||||||
|
{#if latestEntry}
|
||||||
|
<LatestEntry sourceText={latestEntry.story_text} translationText={latestEntry.translation} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
181
frontend/src/routes/app/adventures/[id]/LatestEntry.svelte
Normal file
181
frontend/src/routes/app/adventures/[id]/LatestEntry.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
sourceText: string | null | undefined;
|
||||||
|
translationText: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sourceText, translationText }: 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;
|
||||||
|
|
||||||
|
function toParagraphs(text: string | null | undefined): string[] {
|
||||||
|
return (text ?? '')
|
||||||
|
.split(/\n\s*\n/g)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScrollPosition(
|
||||||
|
origin: HTMLDivElement | undefined,
|
||||||
|
target: HTMLDivElement | undefined
|
||||||
|
) {
|
||||||
|
if (!origin || !target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originRange = origin.scrollHeight - origin.clientHeight;
|
||||||
|
const targetRange = target.scrollHeight - target.clientHeight;
|
||||||
|
const progress = originRange <= 0 ? 0 : origin.scrollTop / originRange;
|
||||||
|
|
||||||
|
target.scrollTop = targetRange <= 0 ? 0 : progress * targetRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSourceScroll() {
|
||||||
|
if (suppressSourceScroll) {
|
||||||
|
suppressSourceScroll = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressTranslationScroll = true;
|
||||||
|
syncScrollPosition(sourcePane, translationPane);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTranslationScroll() {
|
||||||
|
if (suppressTranslationScroll) {
|
||||||
|
suppressTranslationScroll = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressSourceScroll = true;
|
||||||
|
syncScrollPosition(translationPane, sourcePane);
|
||||||
|
}
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pane translation">
|
||||||
|
<header class="translation-header">
|
||||||
|
<p class="eyebrow">Translation</p>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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-entry-pane-padding: var(--space-3);
|
||||||
|
|
||||||
|
--latest-entry-pane-shadow: var(--shadow-tonal-sm);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: var(--latest-entry-pane-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--latest-entry-pane-padding);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-header .eyebrow {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--latest-entry-pane-padding);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body::-webkit-scrollbar {
|
||||||
|
width: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-entry__pane-body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 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);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph + .paragraph {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane .paragraph {
|
||||||
|
font-size: clamp(1.2rem, 1rem + 0.35vw, 1.45rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue