- 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
161 lines
3.8 KiB
Svelte
161 lines
3.8 KiB
Svelte
<script lang="ts">
|
|
import type { SentenceToken } from './Transcript';
|
|
|
|
let {
|
|
selectedSentenceToken = null,
|
|
translating = false,
|
|
translatedText = '',
|
|
closePanel
|
|
}: {
|
|
selectedSentenceToken?: SentenceToken | null;
|
|
translating?: boolean;
|
|
translatedText: string | null;
|
|
closePanel: () => void;
|
|
} = $props();
|
|
</script>
|
|
|
|
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
|
|
<aside
|
|
class="translation-panel"
|
|
class:is-open={selectedSentenceToken !== null}
|
|
aria-label="Word translation"
|
|
>
|
|
{#if selectedSentenceToken}
|
|
<div class="panel-header">
|
|
<p class="panel-word">{selectedSentenceToken.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>
|
|
|
|
<style>
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
/* --- 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);
|
|
}
|
|
}
|
|
|
|
/* --- 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);
|
|
}
|
|
}
|
|
</style>
|