language-learning-app/frontend/src/routes/app/articles/[article_id]/+page.svelte

579 lines
14 KiB
Svelte
Raw Normal View History

2026-03-29 07:54:27 +00:00
<script lang="ts">
import type { PageProps } from './$types';
const { data }: PageProps = $props();
const { article, audioUrl } = data;
// -------------------------------------------------------------------------
// Body parsing: split into paragraphs → sentences → tokens
// -------------------------------------------------------------------------
type WordToken = { type: 'word'; text: string; wordIdx: number };
type OtherToken = { type: 'other'; text: string };
type Token = WordToken | OtherToken;
type Sentence = {
tokens: Token[];
idx: number; // global sentence index
startWordIdx: number;
endWordIdx: number;
};
type Paragraph = { sentences: Sentence[] };
function parseBody(text: string): { paragraphs: Paragraph[]; totalWords: number } {
const paragraphs: Paragraph[] = [];
let wordIdx = 0;
let sentenceIdx = 0;
for (const paraText of text.split(/\n\n+/)) {
if (!paraText.trim()) continue;
// Split into alternating word / non-word tokens
const rawTokens = paraText.match(/[\p{L}\p{N}\u2019'''-]+|[^\p{L}\p{N}\u2019'''-]+/gu) ?? [];
const sentences: Sentence[] = [];
let currentTokens: Token[] = [];
let startWordIdx = wordIdx;
let hasWord = false;
for (const raw of rawTokens) {
if (/[\p{L}\p{N}]/u.test(raw)) {
currentTokens.push({ type: 'word', text: raw, wordIdx: wordIdx++ });
hasWord = true;
} else {
currentTokens.push({ type: 'other', text: raw });
// Flush sentence on sentence-ending punctuation
if (hasWord && /[.!?]/.test(raw)) {
sentences.push({
tokens: [...currentTokens],
idx: sentenceIdx++,
startWordIdx,
endWordIdx: wordIdx - 1
});
currentTokens = [];
startWordIdx = wordIdx;
hasWord = false;
}
}
}
if (currentTokens.length > 0) {
sentences.push({
tokens: currentTokens,
idx: sentenceIdx++,
startWordIdx,
endWordIdx: wordIdx - 1
});
}
if (sentences.length > 0) {
paragraphs.push({ sentences });
}
}
return { paragraphs, totalWords: wordIdx };
}
const { paragraphs } = parseBody(article.target_body);
// Flat sentence list for O(n) audio-time lookup
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
for (const para of paragraphs) {
for (const s of para.sentences) {
allSentences.push({ idx: s.idx, startWordIdx: s.startWordIdx, endWordIdx: s.endWordIdx });
}
}
// -------------------------------------------------------------------------
// Transcript: extract per-word timings from Deepgram response
// -------------------------------------------------------------------------
type WordTiming = { start: number; end: number };
function extractWordTimings(transcript: Record<string, unknown> | null): WordTiming[] {
if (!transcript) return [];
try {
const words = (transcript as any)?.results?.channels?.[0]?.alternatives?.[0]?.words;
if (!Array.isArray(words)) return [];
return words.map((w: any) => ({ start: Number(w.start), end: Number(w.end) }));
} catch {
return [];
}
}
const wordTimings = extractWordTimings(article.target_body_transcript);
// -------------------------------------------------------------------------
// Reactive state
// -------------------------------------------------------------------------
let audioEl: HTMLAudioElement | null = $state(null);
let activeSentenceIdx = $state(-1);
let selectedWord: WordToken | null = $state(null);
let translatedText: string | null = $state(null);
let translating = $state(false);
// -------------------------------------------------------------------------
// Audio: sentence highlighting
// -------------------------------------------------------------------------
function handleTimeUpdate() {
if (!audioEl || wordTimings.length === 0) return;
const t = audioEl.currentTime;
// Find the word index at current playback time
let wordIdx = -1;
for (let i = 0; i < wordTimings.length; i++) {
if (wordTimings[i].start <= t && t <= wordTimings[i].end) {
wordIdx = i;
break;
}
// Between words: use the most recently started word
if (wordTimings[i].start > t) {
wordIdx = i - 1;
break;
}
}
if (wordIdx < 0) return;
for (const s of allSentences) {
if (s.startWordIdx <= wordIdx && wordIdx <= s.endWordIdx) {
activeSentenceIdx = s.idx;
return;
}
}
}
// -------------------------------------------------------------------------
// Word click: fetch translation
// -------------------------------------------------------------------------
async function handleWordClick(token: WordToken) {
selectedWord = token;
translatedText = null;
translating = true;
try {
const params = new URLSearchParams({
text: token.text,
target_language: article.source_language
});
const res = await fetch(`/app/translate?${params}`);
if (res.ok) {
const body = await res.json();
translatedText = body.translated_text ?? null;
}
} catch {
translatedText = null;
} finally {
translating = false;
}
}
function closePanel() {
selectedWord = null;
translatedText = null;
}
// -------------------------------------------------------------------------
// Display helpers
// -------------------------------------------------------------------------
const languageNames: Record<string, string> = {
en: 'English',
fr: 'French',
es: 'Spanish',
it: 'Italian',
de: 'German',
pt: 'Portuguese',
ja: 'Japanese',
zh: 'Chinese',
ko: 'Korean'
};
const targetLang =
languageNames[article.target_language] ?? article.target_language.toUpperCase();
const publishedDate = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(article.published_at));
</script>
<!-- Reading progress bar (CSS scroll-driven animation) -->
<div class="progress-bar" aria-hidden="true"></div>
<div class="page">
<nav class="breadcrumb">
<a href="/app/articles" class="link">← Articles</a>
</nav>
<header class="article-header">
<p class="article-eyebrow label-md">{targetLang} · {publishedDate}</p>
<h1 class="article-title">{article.target_title}</h1>
</header>
<div class="article-layout">
<!-- Main content: audio + body -->
<div class="article-main">
{#if audioUrl}
<div class="audio-section">
<audio
bind:this={audioEl}
src={audioUrl}
controls
ontimeupdate={handleTimeUpdate}
class="audio-player"
>
Your browser does not support the audio element.
</audio>
</div>
{/if}
<div class="article-body" lang={article.target_language}>
{#each paragraphs as para}
<p class="paragraph">
{#each para.sentences as sentence}<span
class="sentence"
class:sentence--active={activeSentenceIdx === sentence.idx}
>{#each sentence.tokens as token}{#if token.type === 'word'}<button
class="word"
class:word--selected={selectedWord?.wordIdx === token.wordIdx}
onclick={() => handleWordClick(token)}>{token.text}</button
>{:else}{token.text}{/if}{/each}</span
>{/each}
</p>
{/each}
</div>
</div>
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
<aside
class="translation-panel"
class:is-open={selectedWord !== null}
aria-label="Word translation"
>
{#if selectedWord}
<div class="panel-header">
<p class="panel-word">{selectedWord.text}</p>
<button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel">
</button>
</div>
{#if translating}
<div class="panel-loading">
<div class="spinner" aria-hidden="true"></div>
<span>Translating…</span>
</div>
{:else if translatedText}
<p class="panel-translation">{translatedText}</p>
<button class="btn btn-secondary panel-save" disabled aria-disabled="true">
Add to flashcard
</button>
{:else}
<p class="panel-error">Could not load translation.</p>
{/if}
{:else}
<p class="panel-hint">Tap any word for a translation</p>
{/if}
</aside>
</div>
</div>
<!-- Mobile backdrop: closes the drawer when tapped outside -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="drawer-backdrop"
class:is-visible={selectedWord !== null}
onclick={closePanel}
aria-hidden="true"
></div>
<style>
/* --- Reading progress bar (CSS scroll-driven animation) --- */
/* Sits at the bottom edge of the sticky topnav (3.25rem) */
.progress-bar {
position: fixed;
top: 3.25rem;
left: 0;
right: 0;
height: 2px;
background: var(--color-primary);
transform-origin: left;
transform: scaleX(0);
animation: reading-progress linear both;
animation-timeline: scroll(root);
z-index: 99;
pointer-events: none;
}
@keyframes reading-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* --- Page shell --- */
.page {
padding: var(--space-8) var(--space-6);
max-width: 82rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* --- Breadcrumb --- */
.breadcrumb {
font-family: var(--font-label);
font-size: var(--text-label-lg);
}
/* --- Header --- */
.article-header {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-2);
}
.article-eyebrow {
color: var(--color-on-surface-variant);
}
.article-title {
font-family: var(--font-display);
font-size: var(--text-display-md);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
max-width: 38rem;
}
/* --- Two-column layout --- */
.article-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
align-items: start;
}
@media (min-width: 768px) {
.article-layout {
grid-template-columns: 1fr 22rem;
}
}
/* --- Audio --- */
.audio-section {
margin-bottom: var(--space-5);
}
.audio-player {
width: 100%;
accent-color: var(--color-primary);
}
/* --- Article body --- */
.article-body {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.paragraph {
font-family: var(--font-body);
font-size: var(--text-body-xl);
line-height: 2;
color: var(--color-on-surface);
}
/* Sentence: highlighted when audio is at that point */
.sentence {
border-radius: var(--radius-xs);
transition: background-color var(--duration-normal) var(--ease-standard);
}
.sentence--active {
background-color: var(--color-primary-container);
}
/* --- Word buttons --- */
.word {
display: inline;
background: none;
border: none;
padding: 0 0.05em;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
border-radius: var(--radius-xs);
transition:
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.word:hover {
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.word--selected {
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
color: var(--color-primary);
font-weight: var(--weight-medium);
}
/* --- Translation panel: Desktop (sticky sidebar) --- */
@media (min-width: 768px) {
.translation-panel {
position: sticky;
top: var(--space-6);
background-color: var(--color-surface-container-lowest);
border-radius: var(--radius-xl);
padding: var(--space-5);
min-height: 16rem;
box-shadow: var(--shadow-tonal-sm);
}
.drawer-backdrop {
display: none;
}
}
/* --- Translation panel: Mobile (bottom drawer) --- */
@media (max-width: 767px) {
.translation-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 300;
background-color: var(--color-surface-container-lowest);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom));
max-height: 55vh;
overflow-y: auto;
transform: translateY(100%);
transition: transform var(--duration-slow) var(--ease-standard);
box-shadow: 0 -8px 32px color-mix(in srgb, var(--color-on-surface) 8%, transparent);
}
.translation-panel.is-open {
transform: translateY(0);
}
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: color-mix(in srgb, var(--color-on-surface) 20%, transparent);
opacity: 0;
pointer-events: none;
transition: opacity var(--duration-slow) var(--ease-standard);
}
.drawer-backdrop.is-visible {
opacity: 1;
pointer-events: auto;
}
}
/* --- Panel internals --- */
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.panel-word {
font-family: var(--font-display);
font-size: var(--text-headline-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--color-on-surface);
}
.panel-close {
flex-shrink: 0;
color: var(--color-on-surface-variant);
font-size: var(--text-body-lg);
line-height: 1;
}
.panel-translation {
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
color: var(--color-on-surface-variant);
font-style: italic;
margin-bottom: var(--space-4);
}
.panel-save {
width: 100%;
padding-block: var(--space-2);
opacity: 0.6;
}
.panel-hint {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
text-align: center;
padding: var(--space-4) 0;
}
.panel-error {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
.panel-loading {
display: flex;
align-items: center;
gap: var(--space-3);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
.spinner {
flex-shrink: 0;
width: 1rem;
height: 1rem;
border: 2px solid var(--color-outline-variant);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* --- Responsive --- */
@media (max-width: 640px) {
.page {
padding: var(--space-6) var(--space-4);
}
.article-title {
font-size: var(--text-headline-lg);
}
}
</style>