This commit is contained in:
2026-09-15 14:02:34 +00:00
parent 62c35565d7
commit c53944a469
107 changed files with 3074 additions and 0 deletions
+831
View File
@@ -0,0 +1,831 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Word {
pub id: &'static str,
pub spelling: &'static str,
pub ipa_us: &'static str,
pub russian_hint: &'static str,
pub audio_path: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Token {
Word {
word_id: &'static str,
surface: &'static str,
},
Punctuation(&'static str),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ReadingText {
pub id: &'static str,
pub title: &'static str,
pub tokens: &'static [Token],
}
macro_rules! entry {
($id:literal, $ipa:literal, $hint:literal) => {
Word {
id: $id,
spelling: $id,
ipa_us: $ipa,
russian_hint: $hint,
audio_path: concat!("/audio/", $id, ".mp3"),
}
};
}
macro_rules! word {
($id:literal) => {
Token::Word {
word_id: $id,
surface: $id,
}
};
($id:literal, $surface:literal) => {
Token::Word {
word_id: $id,
surface: $surface,
}
};
}
macro_rules! punct {
($value:literal) => {
Token::Punctuation($value)
};
}
pub const DICTIONARY: &[Word] = &[
entry!("sam", "sæm", "сэм"),
entry!("and", "ænd", "энд"),
entry!("ben", "bɛn", "бэн"),
entry!("sit", "sɪt", "сит"),
entry!("at", "æt", "эт"),
entry!("camp", "kæmp", "кэмп"),
entry!("plan", "plæn", "плэн"),
entry!("next", "nɛkst", "нэкст"),
entry!("trip", "trɪp", "трип"),
entry!("tim", "tɪm", "тим"),
entry!("gus", "ɡʌs", "гас"),
entry!("pack", "pæk", "пэк"),
entry!("red", "rɛd", "рэд"),
entry!("bags", "bæɡz", "бэгз"),
entry!("fresh", "frɛʃ", "фреш"),
entry!("fish", "fɪʃ", "фиш"),
entry!("chips", "tʃɪps", "чипс"),
entry!("has", "hæz", "хэз"),
entry!("his", "hɪz", "хиз"),
entry!("map", "mæp", "мэп"),
entry!("big", "bɪɡ", "биг"),
entry!("maps", "mæps", "мэпс"),
entry!("black", "blæk", "блэк"),
entry!("in", "ɪn", "ин"),
entry!("sits", "sɪts", "ситс"),
entry!("past", "pæst", "пэст"),
entry!("hills", "hɪlz", "хилз"),
entry!("wide", "waɪd", "уайд"),
entry!("grass", "ɡræs", "грэс"),
entry!("trails", "treɪlz", "трейлз"),
entry!("rain", "reɪn", "рейн"),
entry!("hits", "hɪts", "хитс"),
entry!("runs", "rʌnz", "ранз"),
entry!("rocks", "rɑks", "ракс"),
entry!("wait", "weɪt", "уэйт"),
entry!("tents", "tɛnts", "тентс"),
entry!("chat", "tʃæt", "чэт"),
entry!("can", "kæn", "кэн"),
entry!("see", "siː", "си"),
entry!("drip", "drɪp", "дрип"),
entry!("on", "ɑn", "ан"),
entry!("fast", "fæst", "фэст"),
entry!("swim", "swɪm", "свим"),
entry!("pond", "pɑnd", "панд"),
entry!("grab", "ɡræb", "грэб"),
entry!("rush", "rʌʃ", "раш"),
entry!("wet", "wɛt", "уэт"),
entry!("make", "meɪk", "мейк"),
entry!("bright", "braɪt", "брайт"),
entry!("light", "laɪt", "лайт"),
entry!("dry", "draɪ", "драй"),
entry!("sticks", "stɪks", "стикс"),
entry!("help", "hɛlp", "хэлп"),
entry!("chip", "tʃɪp", "чип"),
entry!("dish", "dɪʃ", "диш"),
entry!("ends", "ɛndz", "эндз"),
entry!("sun", "sʌn", "сан"),
entry!("shine", "ʃaɪn", "шайн"),
entry!("hike", "haɪk", "хайк"),
entry!("ride", "raɪd", "райд"),
entry!("bikes", "baɪks", "байкс"),
entry!("pick", "pɪk", "пик"),
entry!("coins", "kɔɪnz", "койнз"),
entry!("sand", "sænd", "сэнд"),
entry!("by", "baɪ", "бай"),
entry!("road", "roʊd", "роуд"),
entry!("shells", "ʃɛlz", "шелз"),
entry!("wash", "wɑʃ", "уош"),
entry!("chop", "tʃɑp", "чап"),
entry!("fix", "fɪks", "фикс"),
entry!("bike", "baɪk", "байк"),
entry!("tape", "teɪp", "тейп"),
entry!("night", "naɪt", "найт"),
entry!("fills", "fɪlz", "филз"),
entry!("sky", "skaɪ", "скай"),
entry!("dims", "dɪmz", "димз"),
entry!("sing", "sɪŋ", "синг"),
entry!("grin", "ɡrɪn", "грин"),
entry!("may", "meɪ", "мей"),
entry!("hit", "hɪt", "хит"),
entry!("but", "bʌt", "бат"),
entry!("lamps", "læmps", "лэмпс"),
entry!("keep", "kiːp", "кип"),
entry!("tent", "tɛnt", "тент"),
entry!("safe", "seɪf", "сейф"),
entry!("bag", "bæɡ", "бэг"),
entry!("day", "deɪ", "дей"),
entry!("stops", "stɑps", "стапс"),
entry!("spot", "spɑt", "спат"),
entry!("hot", "hɑt", "хат"),
entry!("last", "læst", "лэст"),
entry!("up", "ʌp", "ап"),
entry!("home", "hoʊm", "хоум"),
entry!("dusk", "dʌsk", "даск"),
entry!("end", "ɛnd", "энд"),
entry!("lights", "laɪts", "лайтс"),
entry!("wave", "weɪv", "уэйв"),
];
const BASIC_RULES_TOKENS: &[Token] = &[
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("sit"),
word!("at"),
word!("camp"),
word!("and"),
word!("plan"),
word!("next"),
word!("trip"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("pack"),
word!("red"),
word!("bags"),
word!("fresh"),
word!("fish"),
word!("and"),
word!("chips"),
punct!("."),
word!("sam", "Sam"),
word!("has"),
word!("his"),
word!("map"),
word!("and"),
word!("ben", "Ben"),
word!("has"),
word!("his"),
word!("big"),
word!("map"),
punct!("."),
word!("tim", "Tim"),
word!("has"),
word!("red"),
word!("maps"),
punct!(";"),
word!("gus", "Gus"),
word!("has"),
word!("black"),
word!("maps"),
word!("in"),
word!("camp"),
punct!("."),
word!("camp", "Camp"),
word!("sits"),
word!("past"),
word!("big"),
word!("hills"),
word!("and"),
word!("wide"),
word!("camp"),
word!("grass"),
word!("trails"),
punct!("."),
word!("rain", "Rain"),
word!("hits"),
word!("grass"),
word!("and"),
word!("runs"),
word!("past"),
word!("black"),
word!("rocks"),
word!("in"),
word!("camp"),
punct!("."),
word!("sam", "Sam"),
punct!(","),
word!("ben", "Ben"),
punct!(","),
word!("tim", "Tim"),
punct!(","),
word!("and"),
word!("gus", "Gus"),
word!("wait"),
word!("in"),
word!("tents"),
word!("and"),
word!("chat"),
punct!("."),
word!("gus", "Gus"),
word!("can"),
word!("see"),
word!("rain"),
word!("drip"),
word!("on"),
word!("red"),
word!("camp"),
word!("tents"),
word!("fast"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("see"),
word!("fish"),
word!("swim"),
word!("in"),
word!("big"),
word!("camp"),
word!("pond"),
word!("fast"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("grab"),
word!("bags"),
word!("and"),
word!("rush"),
word!("past"),
word!("wet"),
word!("grass"),
punct!("."),
word!("sam", "Sam"),
word!("can"),
word!("make"),
word!("bright"),
word!("camp"),
word!("light"),
punct!(";"),
word!("dry"),
word!("sticks"),
word!("help"),
word!("sam", "Sam"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("make"),
word!("fresh"),
word!("fish"),
word!("chip"),
word!("dish"),
word!("at"),
word!("camp"),
word!("tent"),
punct!("."),
word!("rain", "Rain"),
word!("ends"),
word!("and"),
word!("sun"),
word!("can"),
word!("shine"),
word!("on"),
word!("hills"),
word!("at"),
word!("camp"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("hike"),
word!("past"),
word!("big"),
word!("rocks"),
word!("in"),
word!("rain"),
word!("fast"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("ride"),
word!("bikes"),
word!("on"),
word!("wide"),
word!("camp"),
word!("grass"),
word!("trails"),
punct!("."),
word!("sam", "Sam"),
word!("can"),
word!("pick"),
word!("coins"),
word!("in"),
word!("sand"),
word!("by"),
word!("wide"),
word!("camp"),
word!("road"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("pick"),
word!("bright"),
word!("shells"),
word!("in"),
word!("sand"),
word!("by"),
word!("wide"),
word!("road"),
punct!("."),
word!("tim", "Tim"),
word!("can"),
word!("wash"),
word!("fish"),
word!("and"),
word!("chop"),
word!("red"),
word!("chips"),
word!("at"),
word!("camp"),
punct!("."),
word!("gus", "Gus"),
word!("can"),
word!("fix"),
word!("his"),
word!("bike"),
punct!(";"),
word!("black"),
word!("tape"),
word!("can"),
word!("help"),
word!("gus", "Gus"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("help"),
word!("gus", "Gus"),
word!("fix"),
word!("his"),
word!("bike"),
word!("at"),
word!("camp"),
punct!("."),
word!("night", "Night"),
word!("fills"),
word!("camp"),
word!("and"),
word!("black"),
word!("sky"),
word!("dims"),
word!("camp"),
word!("trails"),
word!("fast"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("sit"),
word!("by"),
word!("bright"),
word!("camp"),
word!("light"),
word!("and"),
word!("sing"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("sit"),
word!("by"),
word!("bright"),
word!("camp"),
word!("light"),
word!("and"),
word!("grin"),
punct!("."),
word!("rain", "Rain"),
word!("may"),
word!("hit"),
word!("tents"),
word!("but"),
word!("camp"),
word!("lamps"),
word!("make"),
word!("bright"),
word!("light"),
punct!("."),
word!("sam", "Sam"),
word!("can"),
word!("keep"),
word!("dry"),
word!("bags"),
word!("in"),
word!("his"),
word!("tent"),
word!("at"),
word!("camp"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("keep"),
word!("his"),
word!("map"),
word!("safe"),
word!("in"),
word!("tent"),
word!("at"),
word!("camp"),
punct!("."),
word!("tim", "Tim"),
word!("can"),
word!("keep"),
word!("fish"),
word!("fresh"),
word!("in"),
word!("his"),
word!("bag"),
word!("at"),
word!("camp"),
punct!("."),
word!("gus", "Gus"),
word!("can"),
word!("keep"),
word!("red"),
word!("chips"),
word!("in"),
word!("his"),
word!("bag"),
word!("at"),
word!("camp"),
punct!("."),
word!("day", "Day"),
word!("ends"),
punct!(";"),
word!("rain"),
word!("stops"),
punct!("."),
word!("sun", "Sun"),
word!("can"),
word!("shine"),
word!("at"),
word!("red"),
word!("camp"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("plan"),
word!("next"),
word!("hike"),
word!("by"),
word!("wide"),
word!("camp"),
word!("road"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("plan"),
word!("next"),
word!("bike"),
word!("ride"),
word!("by"),
word!("camp"),
word!("road"),
punct!("."),
word!("sam", "Sam"),
word!("can"),
word!("help"),
word!("ben", "Ben"),
word!("past"),
word!("rocks"),
word!("and"),
word!("wet"),
word!("camp"),
word!("grass"),
punct!("."),
word!("tim", "Tim"),
word!("can"),
word!("help"),
word!("gus", "Gus"),
word!("past"),
word!("hills"),
word!("and"),
word!("red"),
word!("camp"),
word!("tents"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("ride"),
word!("bike"),
word!("past"),
word!("camp"),
word!("pond"),
word!("and"),
word!("rocks"),
word!("fast"),
punct!("."),
word!("gus", "Gus"),
word!("can"),
word!("ride"),
word!("bike"),
word!("past"),
word!("wet"),
word!("grass"),
word!("and"),
word!("tents"),
word!("fast"),
punct!("."),
word!("sam", "Sam"),
word!("can"),
word!("see"),
word!("bright"),
word!("light"),
word!("on"),
word!("camp"),
word!("road"),
word!("at"),
word!("night"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("see"),
word!("bright"),
word!("lights"),
word!("at"),
word!("night"),
word!("in"),
word!("his"),
word!("camp"),
punct!("."),
word!("tim", "Tim"),
word!("can"),
word!("spot"),
word!("red"),
word!("fish"),
word!("in"),
word!("pond"),
word!("at"),
word!("night"),
word!("fast"),
punct!("."),
word!("gus", "Gus"),
word!("can"),
word!("spot"),
word!("big"),
word!("fish"),
word!("in"),
word!("pond"),
word!("at"),
word!("night"),
word!("fast"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("make"),
word!("hot"),
word!("fish"),
word!("dish"),
word!("in"),
word!("camp"),
word!("tent"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("make"),
word!("fresh"),
word!("chip"),
word!("dish"),
word!("in"),
word!("camp"),
word!("tent"),
punct!("."),
word!("rain", "Rain"),
word!("ends"),
word!("and"),
word!("camp"),
word!("trails"),
word!("dry"),
word!("in"),
word!("sun"),
word!("at"),
word!("last"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("pick"),
word!("wet"),
word!("tents"),
word!("up"),
word!("and"),
word!("pack"),
word!("fast"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("pack"),
word!("fish"),
word!("chips"),
word!("maps"),
word!("and"),
word!("bags"),
word!("fast"),
punct!("."),
word!("sam", "Sam"),
word!("and"),
word!("ben", "Ben"),
word!("hike"),
word!("home"),
word!("by"),
word!("wide"),
word!("road"),
word!("at"),
word!("dusk"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("ride"),
word!("home"),
word!("by"),
word!("wide"),
word!("road"),
word!("at"),
word!("dusk"),
punct!("."),
word!("sam", "Sam"),
word!("can"),
word!("see"),
word!("rain"),
word!("end"),
word!("and"),
word!("sun"),
word!("shine"),
word!("at"),
word!("last"),
punct!("."),
word!("ben", "Ben"),
word!("can"),
word!("see"),
word!("night"),
word!("sky"),
word!("and"),
word!("bright"),
word!("red"),
word!("camp"),
word!("lights"),
punct!("."),
word!("tim", "Tim"),
word!("and"),
word!("gus", "Gus"),
word!("grin"),
word!("and"),
word!("wave"),
word!("at"),
word!("camp"),
word!("at"),
word!("last"),
punct!("."),
word!("sam", "Sam"),
punct!(","),
word!("ben", "Ben"),
punct!(","),
word!("tim", "Tim"),
punct!(","),
word!("and"),
word!("gus", "Gus"),
word!("plan"),
word!("next"),
word!("big"),
word!("camp"),
word!("trip"),
punct!("."),
];
pub const READING_TEXTS: &[ReadingText] = &[ReadingText {
id: "basic-rules-01",
title: "Текст для чтения",
tokens: BASIC_RULES_TOKENS,
}];
pub fn word_by_id(id: &str) -> Option<&'static Word> {
DICTIONARY.iter().find(|word| word.id == id)
}
#[cfg(test)]
pub fn tokens_to_text(tokens: &[Token]) -> String {
let mut output = String::new();
let mut needs_space = false;
for token in tokens {
match token {
Token::Word { surface, .. } => {
if needs_space {
output.push(' ');
}
output.push_str(surface);
needs_space = true;
}
Token::Punctuation(value) => {
output.push_str(value);
needs_space = !matches!(*value, "…" | "—" | "(");
}
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn source_text() -> &'static str {
let source =
include_str!("../../english/ex/theme-13-reading-rules/reading-text-01-basic-rules.md");
source
.split_once("<!-- text-start -->")
.unwrap()
.1
.split_once("<!-- text-end -->")
.unwrap()
.0
.trim()
}
#[test]
fn tokens_render_exact_source_text() {
let text = &READING_TEXTS[0];
assert_eq!(tokens_to_text(text.tokens), source_text());
assert_eq!(
text.tokens
.iter()
.filter(|token| matches!(token, Token::Word { .. }))
.count(),
500
);
}
#[test]
fn repeated_words_reuse_stable_ids() {
let sam_ids: Vec<_> = READING_TEXTS[0]
.tokens
.iter()
.filter_map(|token| match token {
Token::Word { word_id, surface } if *surface == "Sam" => Some(*word_id),
_ => None,
})
.collect();
assert!(sam_ids.len() > 1);
assert!(sam_ids.iter().all(|id| *id == "sam"));
}
#[test]
fn dictionary_ids_are_unique_and_audio_files_exist() {
assert_eq!(DICTIONARY.len(), 97);
assert_eq!(word_by_id("camp").unwrap().ipa_us, "kæmp");
assert!(word_by_id("missing").is_none());
for (index, word) in DICTIONARY.iter().enumerate() {
assert!(
!DICTIONARY[..index]
.iter()
.any(|previous| previous.id == word.id),
"duplicate dictionary id: {}",
word.id
);
}
for word in DICTIONARY {
let asset = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("public")
.join(word.audio_path.trim_start_matches('/'));
assert!(asset.is_file(), "missing audio: {}", asset.display());
}
}
#[test]
fn every_text_token_references_exactly_one_dictionary_word() {
for text in READING_TEXTS {
for token in text.tokens {
if let Token::Word { word_id, .. } = token {
let matches = DICTIONARY.iter().filter(|word| word.id == *word_id).count();
assert_eq!(matches, 1, "{} references {word_id}", text.id);
}
}
}
}
}
+247
View File
@@ -0,0 +1,247 @@
mod data;
use data::{word_by_id, ReadingText, Token, Word, DICTIONARY, READING_TEXTS};
use leptos::ev::KeyboardEvent;
use leptos::prelude::*;
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::HtmlInputElement;
#[derive(Clone, Copy, PartialEq, Eq)]
enum AudioState {
Ready,
Playing,
PlaybackBlocked,
Unavailable,
}
fn main() {
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
let reading_text = &READING_TEXTS[0];
let word_count = reading_text
.tokens
.iter()
.filter(|token| matches!(token, Token::Word { .. }))
.count();
let selected_id = RwSignal::new(None::<&'static str>);
let query = RwSignal::new(String::new());
let audio_ref = NodeRef::<leptos::html::Audio>::new();
let audio_state = RwSignal::new(AudioState::Ready);
let select_word = move |id: &'static str| {
if let Some(audio) = audio_ref.get() {
let _ = audio.pause();
}
audio_state.set(AudioState::Ready);
selected_id.set(Some(id));
};
let activate_key = move |event: KeyboardEvent, id: &'static str| {
if event.key() == "Enter" || event.key() == " " {
event.prevent_default();
select_word(id);
}
};
let toggle_audio = move |_| {
if let Some(audio) = audio_ref.get() {
if audio.paused() {
match audio.play() {
Ok(promise) => {
let audio_state = audio_state;
spawn_local(async move {
if JsFuture::from(promise).await.is_err() {
audio_state.set(AudioState::PlaybackBlocked);
}
});
}
Err(_) => audio_state.set(AudioState::PlaybackBlocked),
}
} else {
let _ = audio.pause();
}
}
};
view! {
<main class="lesson-shell">
<header class="masthead">
<p class="eyebrow">"Лаборатория чтения · Урок 01"</p>
<h1>"Читаем" <em>"по правилам"</em></h1>
<p class="lede">{format!("{word_count} слов для спокойной тренировки. Нажмите на слово, чтобы увидеть его чтение.")}</p>
</header>
<section class="reading-card" aria-labelledby="reading-heading">
<div class="section-heading">
<p class="section-number">"01"</p>
<div>
<h2 id="reading-heading">{reading_text.title}</h2>
<p>"Каждое слово — отдельная карточка для слуха и произношения."</p>
</div>
<p class="word-count">{format!("{word_count} слов")}</p>
</div>
<p class="reading-text">
{render_reading_text(reading_text, selected_id, select_word, activate_key)}
</p>
</section>
<section class="detail-panel" aria-live="polite" aria-labelledby="detail-heading">
{move || selected_id.get().and_then(word_by_id).map(|word| detail_view(word, audio_ref, audio_state, toggle_audio)).unwrap_or_else(|| view! {
<div class="detail-empty">
<p class="detail-kicker">"Словарная карточка"</p>
<h2 id="detail-heading">"Выберите слово в тексте"</h2>
<p>"Карточка покажет написание, американское произношение и русскую подсказку."</p>
</div>
}.into_any())}
</section>
<section class="dictionary-section" aria-labelledby="dictionary-heading">
<div class="dictionary-heading">
<p class="section-number">"02"</p>
<div>
<h2 id="dictionary-heading">"Внутренний словарь"</h2>
<p>"Все слова урока; поиск по написанию, IPA или русской подсказке."</p>
</div>
</div>
<label class="search-label" for="dictionary-search">"Найти слово"</label>
<input
id="dictionary-search"
type="search"
placeholder="например, camp или кэмп"
on:input=move |event| query.set(event_target::<HtmlInputElement>(&event).value())
/>
<div class="table-wrap">
<table>
<thead><tr><th>"Слово"</th><th>"US IPA"</th><th>"Подсказка"</th><th>"Аудио"</th></tr></thead>
<tbody>
{move || {
let needle = query.get().to_lowercase();
DICTIONARY.iter().filter(|word| needle.is_empty() || word.spelling.contains(&needle) || word.ipa_us.contains(&needle) || word.russian_hint.to_lowercase().contains(&needle)).map(|word| {
let id = word.id;
view! {
<tr>
<td><button type="button" class="table-word" on:click=move |_| select_word(id)>{word.spelling}</button></td>
<td>{word.ipa_us}</td>
<td>{word.russian_hint}</td>
<td>
<audio controls=true preload="none" src=word.audio_path>
"Ваш браузер не поддерживает аудио."
</audio>
</td>
</tr>
}
}).collect_view()
}}
</tbody>
</table>
</div>
</section>
</main>
}
}
fn render_reading_text(
reading_text: &'static ReadingText,
selected_id: RwSignal<Option<&'static str>>,
select_word: impl Fn(&'static str) + Copy + 'static,
activate_key: impl Fn(KeyboardEvent, &'static str) + Copy + 'static,
) -> AnyView {
reading_text
.tokens
.iter()
.enumerate()
.map(|(index, token)| match token {
Token::Word { word_id, surface } => {
let id = *word_id;
let leading_space = if index == 0 { "" } else { " " };
view! {
<span class="word-token">
{leading_space}
<button
class="reading-word"
class:active=move || selected_id.get() == Some(id)
type="button"
aria-label=format!("Открыть слово {surface}")
on:click=move |_| select_word(id)
on:keydown=move |event| activate_key(event, id)
>{*surface}</button>
</span>
}
.into_any()
}
Token::Punctuation(value) => {
view! { <span class="punctuation">{*value}</span> }.into_any()
}
})
.collect_view()
.into_any()
}
fn detail_view(
word: &'static Word,
audio_ref: NodeRef<leptos::html::Audio>,
audio_state: RwSignal<AudioState>,
toggle_audio: impl Fn(leptos::ev::MouseEvent) + Copy + 'static,
) -> AnyView {
view! {
<div class="detail-content">
<p class="detail-kicker">"Словарная карточка"</p>
<h2 id="detail-heading">{word.spelling}</h2>
<dl class="pronunciation-grid">
<div><dt>"Американский IPA"</dt><dd>{word.ipa_us}</dd></div>
<div><dt>"Русская подсказка"</dt><dd>{word.russian_hint}</dd></div>
</dl>
<audio
node_ref=audio_ref
src=word.audio_path
preload="none"
on:play=move |_| audio_state.set(AudioState::Playing)
on:pause=move |_| if audio_state.get() != AudioState::Unavailable { audio_state.set(AudioState::Ready) }
on:ended=move |_| audio_state.set(AudioState::Ready)
on:error=move |_| audio_state.set(AudioState::Unavailable)
/>
<button
class="audio-button"
type="button"
disabled=move || audio_state.get() == AudioState::Unavailable
aria-pressed=move || audio_state.get() == AudioState::Playing
aria-label=move || audio_button_label(audio_state.get(), word.spelling)
on:click=toggle_audio
>
{move || audio_button_text(audio_state.get())}
</button>
<p class="audio-note" aria-live="polite">{move || audio_status_text(audio_state.get())}</p>
</div>
}.into_any()
}
fn audio_button_label(state: AudioState, spelling: &str) -> String {
match state {
AudioState::Ready => format!("Слушать: {spelling}"),
AudioState::Playing => format!("Пауза: {spelling}"),
AudioState::PlaybackBlocked => format!("Повторить воспроизведение: {spelling}"),
AudioState::Unavailable => format!("Аудио недоступно: {spelling}"),
}
}
fn audio_button_text(state: AudioState) -> &'static str {
match state {
AudioState::Ready => "Слушать",
AudioState::Playing => "Пауза",
AudioState::PlaybackBlocked => "Повторить",
AudioState::Unavailable => "Аудио недоступно",
}
}
fn audio_status_text(state: AudioState) -> &'static str {
match state {
AudioState::Ready => "Запись: американский английский · MP3",
AudioState::Playing => "Воспроизводится: американский английский · MP3",
AudioState::PlaybackBlocked => "Браузер не разрешил начать запись. Нажмите «Повторить».",
AudioState::Unavailable => {
"Не удалось открыть локальный MP3. Проверьте файл и повторите попытку."
}
}
}