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

367 lines
9.1 KiB
Svelte
Raw Normal View History

2026-03-29 07:54:27 +00:00
<script lang="ts">
import { resolve } from '$app/paths';
import type { PartsOfSpeechData } from '$lib/spacy/types';
2026-03-29 07:54:27 +00:00
import type { PageProps } from './$types';
import TargetLanguageBody from './TargetLanguageBody.svelte';
import type { Paragraph, Sentence, SentenceToken, Transcript } from './Transcript';
import TranslationPanel from './TranslationPanel.svelte';
import { translateText } from './translate.remote';
2026-03-29 07:54:27 +00:00
const { data }: PageProps = $props();
const { article } = data;
2026-03-29 07:54:27 +00:00
// -------------------------------------------------------------------------
// Body parsing: split into paragraphs → sentences → tokens
// -------------------------------------------------------------------------
function extractParagraphsAndWordCount(text: PartsOfSpeechData): {
paragraphs: Paragraph[];
totalWords: number;
} {
const paragraphs: Paragraph[] = [{ index: 0, sentences: [] }];
2026-03-29 07:54:27 +00:00
let wordIdx = 0;
let sentenceIdx = 0;
text.sentences.forEach((s) => {
const sentence: Sentence = {
idx: sentenceIdx++,
text: s.text,
startWordIdx: wordIdx,
endWordIdx: wordIdx + s.tokens.length - 1,
tokens: s.tokens.map((t) => ({
...t,
idx: wordIdx++
})) as SentenceToken[]
};
const sentenceEndsWithNewLine = s.text.endsWith('\n');
if (sentenceEndsWithNewLine) {
paragraphs.push({ index: paragraphs.length, sentences: [] });
} else {
paragraphs[paragraphs.length - 1].sentences.push(sentence);
2026-03-29 07:54:27 +00:00
}
});
2026-03-29 07:54:27 +00:00
return { paragraphs, totalWords: wordIdx };
}
const { paragraphs } = extractParagraphsAndWordCount(
article.target_body_pos as Record<string, any> as PartsOfSpeechData
);
2026-03-29 07:54:27 +00:00
// 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: Transcript | null): WordTiming[] {
2026-03-29 07:54:27 +00:00
if (!transcript) return [];
try {
const timings: WordTiming[] = [];
for (const utterance of transcript.utterances) {
for (const word of utterance.words) {
timings.push({ start: word.start, end: word.end });
}
}
return timings;
2026-03-29 07:54:27 +00:00
} catch {
return [];
}
}
const wordTimings = extractWordTimings(
article.target_body_transcript as unknown as Transcript | null
);
2026-03-29 07:54:27 +00:00
// -------------------------------------------------------------------------
// Reactive state
// -------------------------------------------------------------------------
let audioEl: HTMLAudioElement | null = $state(null);
let activeSentenceIdx = $state(-1);
let selectedSentenceToken: SentenceToken | null = $state(null);
let selectedSentence: Sentence | null = $state(null);
2026-03-29 07:54:27 +00:00
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: SentenceToken, sentence: Sentence) {
selectedSentenceToken = token;
activeSentenceIdx = sentence.idx;
2026-03-29 07:54:27 +00:00
translatedText = null;
translating = true;
try {
const result = await translateText({
fromLanguage: article.target_language,
toLanguage: article.source_language,
sentenceText: sentence.text,
text: token.text
2026-03-29 07:54:27 +00:00
});
translatedText = result.text;
console.log({ result });
2026-03-29 07:54:27 +00:00
} catch {
translatedText = null;
} finally {
translating = false;
}
}
function closePanel() {
selectedSentenceToken = null;
2026-03-29 07:54:27 +00:00
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={resolve('/app/articles')} class="link">← Articles</a>
2026-03-29 07:54:27 +00:00
</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 article.target_audio_url}
2026-03-29 07:54:27 +00:00
<div class="audio-section">
<audio
bind:this={audioEl}
src={article.target_audio_url}
2026-03-29 07:54:27 +00:00
controls
ontimeupdate={handleTimeUpdate}
class="audio-player"
>
Your browser does not support the audio element.
</audio>
</div>
{/if}
<TargetLanguageBody
lang={article.source_language}
{paragraphs}
{activeSentenceIdx}
onWordClick={handleWordClick}
{selectedSentenceToken}
/>
2026-03-29 07:54:27 +00:00
</div>
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} />
2026-03-29 07:54:27 +00:00
</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={selectedSentenceToken !== null}
2026-03-29 07:54:27 +00:00
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);
}
@media (min-width: 768px) {
.drawer-backdrop {
display: none;
}
}
/* --- Translation panel: Mobile (bottom drawer) --- */
@media (max-width: 767px) {
.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;
}
}
@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>