- Add DeepL integration for word translation - Parse article body into sentences and tokens - Highlight active sentence and selected word - Show translation in a sticky panel or mobile drawer - Refactor audio timing and body parsing logic - Enable SvelteKit remote functions and async compiler options - Add dependencies: deepl-node, valibot
366 lines
9.1 KiB
Svelte
366 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from '$app/paths';
|
|
import type { PartsOfSpeechData } from '$lib/spacy/types';
|
|
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';
|
|
|
|
const { data }: PageProps = $props();
|
|
const { article } = data;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Body parsing: split into paragraphs → sentences → tokens
|
|
// -------------------------------------------------------------------------
|
|
|
|
function extractParagraphsAndWordCount(text: PartsOfSpeechData): {
|
|
paragraphs: Paragraph[];
|
|
totalWords: number;
|
|
} {
|
|
const paragraphs: Paragraph[] = [{ index: 0, sentences: [] }];
|
|
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);
|
|
}
|
|
});
|
|
|
|
return { paragraphs, totalWords: wordIdx };
|
|
}
|
|
|
|
const { paragraphs } = extractParagraphsAndWordCount(
|
|
article.target_body_pos as Record<string, any> as PartsOfSpeechData
|
|
);
|
|
|
|
// 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[] {
|
|
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;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const wordTimings = extractWordTimings(
|
|
article.target_body_transcript as unknown as Transcript | null
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Reactive state
|
|
// -------------------------------------------------------------------------
|
|
|
|
let audioEl: HTMLAudioElement | null = $state(null);
|
|
let activeSentenceIdx = $state(-1);
|
|
let selectedSentenceToken: SentenceToken | null = $state(null);
|
|
let selectedSentence: Sentence | 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: SentenceToken, sentence: Sentence) {
|
|
selectedSentenceToken = token;
|
|
activeSentenceIdx = sentence.idx;
|
|
translatedText = null;
|
|
translating = true;
|
|
|
|
try {
|
|
const result = await translateText({
|
|
fromLanguage: article.target_language,
|
|
toLanguage: article.source_language,
|
|
sentenceText: sentence.text,
|
|
text: token.text
|
|
});
|
|
|
|
translatedText = result.text;
|
|
console.log({ result });
|
|
} catch {
|
|
translatedText = null;
|
|
} finally {
|
|
translating = false;
|
|
}
|
|
}
|
|
|
|
function closePanel() {
|
|
selectedSentenceToken = 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={resolve('/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 article.target_audio_url}
|
|
<div class="audio-section">
|
|
<audio
|
|
bind:this={audioEl}
|
|
src={article.target_audio_url}
|
|
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}
|
|
/>
|
|
</div>
|
|
|
|
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} />
|
|
</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}
|
|
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>
|