From dd05208d0bce856174f1f1f5432ba46c59cd9160 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:24:49 +0200 Subject: [PATCH 1/3] Add title-aware module grouping and CI fixes Implement BoundaryDetector::group_by_titles that detects labeled and dotted numbering (e.g. "Module 2", "1.5.1") and falls back to batch grouping when the signal is weak. Update ingest use cases to use title-based grouping. Add a GitHub Actions step to free disk space on Linux runners. Tune Cargo profiles: reduce debug info for dev/test, use Thin LTO, increase codegen-units and enable stripping to reduce link-time resource usage. --- .github/workflows/release.yml | 10 + Cargo.toml | 15 +- src/application/use_cases/ingest_local.rs | 3 +- src/application/use_cases/ingest_playlist.rs | 5 +- src/domain/services/boundary_detector.rs | 262 +++++++++++++++++-- src/main.rs | 2 +- 6 files changed, 266 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42789be..d0cc31d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,16 @@ jobs: with: fetch-depth: 0 + - name: Free Disk Space (Linux) + if: matrix.platform == 'linux' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + shell: bash + - name: Configure Cargo git CLI run: | mkdir -p .cargo diff --git a/Cargo.toml b/Cargo.toml index e397ecc..dca7b01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,11 +87,18 @@ keyring = { version = "3", features = ["windows-native"] } [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3", features = ["linux-native"] } +[profile.dev] +debug = 1 # Reduce debug info to save disk space and prevent Bus Error on CI + +[profile.test] +debug = 1 # Reduce debug info for tests as well + [profile.release] -opt-level = "z" # Optimize for size -lto = true # Enable Link Time Optimization -codegen-units = 1 # Allow for maximum cross-module optimization -panic = "abort" # Remove unwinding code for smaller binaries +opt-level = "z" # Optimize for size +lto = "thin" # Use Thin LTO to reduce memory/disk pressure during linking +codegen-units = 16 # Increase units to parallelize and reduce resource usage +panic = "abort" # Remove unwinding code for smaller binaries +strip = true # Strip symbols to further reduce binary size [patch.crates-io] dioxus = { git = "https://github.com/k5602/dioxus", branch = "navigation_policy_hook", package = "dioxus" } diff --git a/src/application/use_cases/ingest_local.rs b/src/application/use_cases/ingest_local.rs index 4c4d4f9..a255d33 100644 --- a/src/application/use_cases/ingest_local.rs +++ b/src/application/use_cases/ingest_local.rs @@ -245,7 +245,8 @@ fn split_root_group_if_needed( } let detector = BoundaryDetector::new(); - let groups = detector.group_into_modules(items.len()); + let raw_titles: Vec<&str> = items.iter().map(|item| item.title.as_str()).collect(); + let groups = detector.group_by_titles(&raw_titles); if groups.len() <= 1 { return grouped.clone(); } diff --git a/src/application/use_cases/ingest_playlist.rs b/src/application/use_cases/ingest_playlist.rs index d6a827e..3a11b87 100644 --- a/src/application/use_cases/ingest_playlist.rs +++ b/src/application/use_cases/ingest_playlist.rs @@ -105,9 +105,10 @@ where // 3. Sanitize titles let sanitized_titles: Vec = raw_videos.iter().map(|v| self.sanitizer.sanitize(&v.title)).collect(); + let raw_titles: Vec<&str> = raw_videos.iter().map(|v| v.title.as_str()).collect(); - // 4. Group videos into modules (simple batch grouping) - let module_groups = self.boundary_detector.group_into_modules(raw_videos.len()); + // 4. Group videos into modules (title-aware with batch fallback) + let module_groups = self.boundary_detector.group_by_titles(&raw_titles); // 5. Create course let course_name = input diff --git a/src/domain/services/boundary_detector.rs b/src/domain/services/boundary_detector.rs index 6e66c80..e5e87e4 100644 --- a/src/domain/services/boundary_detector.rs +++ b/src/domain/services/boundary_detector.rs @@ -1,14 +1,18 @@ //! Boundary Detector - Groups videos into modules. -/// Groups videos into modules using a simple batch size approach. -/// Each module contains up to `batch_size` videos, keeping related content together. +use std::collections::BTreeSet; + +/// Groups videos into modules using title-aware patterns with a batch-size fallback. +/// - Detects hierarchical numbering: `1.5`, `1.5.1` +/// - Detects labeled patterns: `Module 2`, `Chapter 3.1`, `Week 4` +/// - Handles hybrid mixes (labels + dotted numbers + plain leading numbers) #[derive(Debug)] pub struct BoundaryDetector { batch_size: usize, } impl BoundaryDetector { - /// Creates a boundary detector with default batch size (8 videos per module). + /// Creates a boundary detector with default batch size (5 videos per module). pub fn new() -> Self { Self { batch_size: 5 } } @@ -31,6 +35,59 @@ impl BoundaryDetector { .map(|chunk| chunk.to_vec()) .collect() } + + /// Groups videos into modules using title-aware boundary detection. + /// Falls back to `group_into_modules` if signal is weak or ambiguous. + pub fn group_by_titles>(&self, titles: &[T]) -> Vec> { + if titles.is_empty() { + return vec![]; + } + + let keys: Vec> = + titles.iter().map(|t| boundary_key(t.as_ref())).collect(); + + let matched = keys.iter().filter(|k| k.is_some()).count(); + let total = titles.len(); + let matched_ratio = matched as f32 / total as f32; + + let distinct_majors: BTreeSet = + keys.iter().filter_map(|k| k.as_ref().map(|key| key.major)).collect(); + + // Weak signal: fallback to batch grouping. + if matched < 2 || matched_ratio < 0.5 { + return self.group_into_modules(total); + } + + // Only one major detected: fallback to batch grouping. + if distinct_majors.len() <= 1 { + return self.group_into_modules(total); + } + + // Split on major changes in observed order. + let mut groups: Vec> = Vec::new(); + let mut current_group: Vec = Vec::new(); + let mut current_major: Option = None; + + for (idx, key) in keys.iter().enumerate() { + if let Some(key) = key { + if let Some(active) = current_major { + if key.major != active && !current_group.is_empty() { + groups.push(current_group); + current_group = Vec::new(); + } + } + current_major = Some(key.major); + } + + current_group.push(idx); + } + + if !current_group.is_empty() { + groups.push(current_group); + } + + groups + } } impl Default for BoundaryDetector { @@ -39,41 +96,200 @@ impl Default for BoundaryDetector { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct BoundaryKey { + major: u32, + full: Vec, +} + +fn boundary_key(title: &str) -> Option { + let title_trimmed = title.trim(); + if title_trimmed.is_empty() { + return None; + } + + if let Some(nums) = parse_labeled_sequence(title_trimmed) { + return Some(BoundaryKey { major: nums[0], full: nums }); + } + + if let Some(nums) = parse_leading_sequence(title_trimmed) { + // Avoid over-splitting on simple leading numbers like "1 Intro". + if nums.len() == 1 { + return None; + } + return Some(BoundaryKey { major: nums[0], full: nums }); + } + + None +} + +fn parse_labeled_sequence(title: &str) -> Option> { + let lower = title.to_lowercase(); + let labels = [ + "module", "chapter", "section", "part", "lesson", "lecture", "unit", "week", "day", + "topic", "track", "stage", + ]; + + for label in labels { + if let Some(pos) = find_word(&lower, label) { + let start = pos + label.len(); + let mut idx = start; + let bytes = lower.as_bytes(); + + // Skip separators and whitespace after label. + while idx < bytes.len() { + let c = bytes[idx] as char; + if c.is_whitespace() || matches!(c, ':' | '-' | '#' | '.' | '(' | '[') { + idx += 1; + } else { + break; + } + } + + if let Some(nums) = parse_number_sequence(&lower, idx) { + if !nums.is_empty() { + return Some(nums); + } + } + } + } + + None +} + +fn parse_leading_sequence(title: &str) -> Option> { + let mut idx = 0; + let bytes = title.as_bytes(); + + while idx < bytes.len() { + let c = bytes[idx] as char; + if c.is_whitespace() || matches!(c, '(' | '[' | '{' | '-' | '#') { + idx += 1; + } else { + break; + } + } + + parse_number_sequence(title, idx) +} + +fn parse_number_sequence(text: &str, start: usize) -> Option> { + let bytes = text.as_bytes(); + let mut idx = start; + let mut numbers: Vec = Vec::new(); + let mut current: Option = None; + let mut saw_digit = false; + + while idx < bytes.len() { + let c = bytes[idx] as char; + if c.is_ascii_digit() { + saw_digit = true; + let digit = (c as u8 - b'0') as u32; + current = Some(current.unwrap_or(0).saturating_mul(10).saturating_add(digit)); + idx += 1; + continue; + } + + let is_sep = matches!(c, '.' | '-' | '_' | 'ยท'); + if is_sep { + if let Some(num) = current.take() { + numbers.push(num); + } else if saw_digit { + // e.g., "1..2" -> stop on malformed sequence + break; + } + idx += 1; + continue; + } + + // Stop on non-separator, non-digit once a sequence has started + if saw_digit { + break; + } + idx += 1; + } + + if let Some(num) = current.take() { + numbers.push(num); + } + + if numbers.is_empty() { None } else { Some(numbers) } +} + +fn find_word(haystack: &str, needle: &str) -> Option { + let mut start = 0; + while let Some(pos) = haystack[start..].find(needle) { + let abs = start + pos; + let before = abs.saturating_sub(1); + let after = abs + needle.len(); + + let before_ok = abs == 0 || !haystack.as_bytes()[before].is_ascii_alphabetic(); + let after_ok = after >= haystack.len() || !haystack.as_bytes()[after].is_ascii_alphabetic(); + + if before_ok && after_ok { + return Some(abs); + } + start = abs + needle.len(); + } + None +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_empty_videos() { + fn groups_by_major_from_dotted_numbers() { + let titles = + vec!["1.1 Intro", "1.2 Basics", "1.3 Ownership", "2.1 Lifetimes", "2.2 Borrowing"]; let detector = BoundaryDetector::new(); - let modules = detector.group_into_modules(0); - assert!(modules.is_empty()); + let groups = detector.group_by_titles(&titles); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0], vec![0, 1, 2]); + assert_eq!(groups[1], vec![3, 4]); + } + + #[test] + fn groups_by_labeled_numbers() { + let titles = vec![ + "Module 1 - Setup", + "Lesson 1.1 Installing", + "Module 2 - Basics", + "Lesson 2.1 Variables", + ]; + let detector = BoundaryDetector::new(); + let groups = detector.group_by_titles(&titles); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0], vec![0, 1]); + assert_eq!(groups[1], vec![2, 3]); } #[test] - fn test_single_module() { + fn keeps_single_major_as_one_group() { + let titles = vec!["1.1 Intro", "1.2 Basics", "1.3 Ownership"]; let detector = BoundaryDetector::new(); - let modules = detector.group_into_modules(5); - assert_eq!(modules.len(), 1); - assert_eq!(modules[0], vec![0, 1, 2, 3, 4]); + let groups = detector.group_by_titles(&titles); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0], vec![0, 1, 2]); } #[test] - fn test_multiple_modules() { - let detector = BoundaryDetector::with_batch_size(3); - let modules = detector.group_into_modules(7); - assert_eq!(modules.len(), 3); - assert_eq!(modules[0], vec![0, 1, 2]); - assert_eq!(modules[1], vec![3, 4, 5]); - assert_eq!(modules[2], vec![6]); + fn falls_back_on_weak_signal() { + let titles = vec!["Intro", "Deep Dive", "Advanced Topics"]; + let detector = BoundaryDetector::with_batch_size(2); + let groups = detector.group_by_titles(&titles); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0], vec![0, 1]); + assert_eq!(groups[1], vec![2]); } #[test] - fn test_exact_batch_size() { - let detector = BoundaryDetector::with_batch_size(4); - let modules = detector.group_into_modules(8); - assert_eq!(modules.len(), 2); - assert_eq!(modules[0], vec![0, 1, 2, 3]); - assert_eq!(modules[1], vec![4, 5, 6, 7]); + fn parses_hybrid_separators() { + assert_eq!(parse_number_sequence("1-5-1 Intro", 0), Some(vec![1, 5, 1])); + assert_eq!(parse_number_sequence("1_5 Intro", 0), Some(vec![1, 5])); } } diff --git a/src/main.rs b/src/main.rs index bc7a244..3a1bb56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use course_pilot::ui::App; use dioxus_desktop::{Config, WindowBuilder}; fn main() { - // Install rustls crypto provider (required for TLS) + // Install rustls crypto provider rustls::crypto::ring::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); From a8e9f1ee4b3506e2607fb428889fcb2809707862 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:41:11 +0200 Subject: [PATCH 2/3] Improve subtitle cleaner, SQLite repos, and hooks - SubtitleCleaner: detect more VTT headers (KIND, LANGUAGE, STYLE, NOTE), strip speaker labels (">>", "[NAME]:", "NAME:"), normalize whitespace, remove duplicate consecutive lines, simplify timestamp/timecode checks, strip inline tags, and expand unit tests. - Persistence: reorganize imports, use model row_to_* mappers, add ON CONFLICT DO UPDATE for courses and modules, and import VideoSource/YouTubeVideoId. - UI hooks: add backend_key and use_keyed_effect to prevent redundant effects; replace several use_effect usages with keyed effects keyed by backend pointer and resource ids. --- src/domain/services/subtitle_cleaner.rs | 156 ++++---- .../persistence/repositories.rs | 369 +++++++++--------- src/ui/hooks.rs | 137 ++++--- 3 files changed, 353 insertions(+), 309 deletions(-) diff --git a/src/domain/services/subtitle_cleaner.rs b/src/domain/services/subtitle_cleaner.rs index 5d0210d..05113ec 100644 --- a/src/domain/services/subtitle_cleaner.rs +++ b/src/domain/services/subtitle_cleaner.rs @@ -13,9 +13,10 @@ use std::borrow::Cow; /// - WebVTT /// /// The cleaner: -/// - Strips BOM and format headers +/// - Strips BOM and format headers (WEBVTT, KIND, etc.) /// - Removes timestamp lines and cue indices -/// - Drops inline formatting tags +/// - Drops inline formatting tags (e.g., , , ) +/// - Removes speaker labels (e.g., [John]:, SPEAKER:, >>) /// - Collapses whitespace /// - Removes duplicate consecutive lines #[derive(Debug, Default, Clone)] @@ -39,25 +40,25 @@ impl SubtitleCleaner { continue; } - if is_vtt_header(line) { + // Skip format metadata + if is_vtt_header(line) || is_cue_index(line) || is_timestamp_line(line) { continue; } - if is_cue_index(line) { - continue; - } + // Strip inline tags like ... + let cleaned = strip_inline_tags(line); - if is_timestamp_line(line) { - continue; - } + // Strip speaker indicators like "[Speaker]:" or ">>" + let cleaned = strip_speaker_labels(&cleaned); - let cleaned = strip_inline_tags(line); + // Normalize whitespace (internal and surrounding) let cleaned = normalize_whitespace(&cleaned); if cleaned.is_empty() { continue; } + // Deduplicate consecutive identical lines if let Some(prev) = prev_line.as_ref() { if prev == &cleaned { continue; @@ -79,17 +80,23 @@ fn strip_bom(input: &str) -> &str { fn is_vtt_header(line: &str) -> bool { let upper = line.to_ascii_uppercase(); upper.starts_with("WEBVTT") + || upper.starts_with("KIND:") + || upper.starts_with("LANGUAGE:") + || upper.starts_with("STYLE") + || upper.starts_with("NOTE") } fn is_cue_index(line: &str) -> bool { + // Cue indices in SRT are just numbers on their own line line.chars().all(|ch| ch.is_ascii_digit()) } fn is_timestamp_line(line: &str) -> bool { - // Matches patterns like: - // 00:00:01.000 --> 00:00:03.000 - // 00:00:01,000 --> 00:00:03,000 - // 00:01.000 --> 00:02.000 + // Matches patterns containing the separator "-->" + if !line.contains("-->") { + return false; + } + let mut parts = line.split("-->"); let start = parts.next().map(str::trim); let end = parts.next().map(str::trim); @@ -101,63 +108,23 @@ fn is_timestamp_line(line: &str) -> bool { } fn is_timecode(value: &str) -> bool { - // Accept hh:mm:ss.mmm or mm:ss.mmm or hh:mm:ss,mmm - let mut v = value.split_whitespace().next().unwrap_or(value); - - // Remove trailing cues like "position:0%" in VTT - if let Some((time, _rest)) = v.split_once(' ') { - v = time; - } - - let v = v.trim(); - - // Normalize separators - let v = v.replace(',', "."); - let parts: Vec<&str> = v.split(':').collect(); - if parts.len() < 2 || parts.len() > 3 { - return false; - } - - let (sec_part, min_part, hour_part) = match parts.len() { - 2 => (parts[1], parts[0], None), - 3 => (parts[2], parts[1], Some(parts[0])), - _ => return false, - }; - - if let Some(hour) = hour_part { - if !is_number(hour) { - return false; - } - } - - if !is_number(min_part) { + // Basic timecode check: should contain ':' and digits + // Accepts hh:mm:ss.mmm, mm:ss.mmm, or variants with commas + let v = value.split_whitespace().next().unwrap_or(value).trim(); + if v.is_empty() { return false; } - let (sec, millis) = match sec_part.split_once('.') { - Some((s, ms)) => (s, Some(ms)), - None => (sec_part, None), - }; - - if !is_number(sec) { + let parts: Vec<&str> = v.split(':').collect(); + if parts.len() < 2 { return false; } - if let Some(ms) = millis { - if !is_number(ms) { - return false; - } - } - - true -} - -fn is_number(value: &str) -> bool { - !value.is_empty() && value.chars().all(|c| c.is_ascii_digit()) + // Every part should contain at least one digit + parts.iter().all(|p| p.chars().any(|c| c.is_ascii_digit())) } fn strip_inline_tags(line: &str) -> Cow<'_, str> { - // Remove simple tags like , , , , , , and VTT cues like if !line.contains('<') { return Cow::Borrowed(line); } @@ -176,6 +143,38 @@ fn strip_inline_tags(line: &str) -> Cow<'_, str> { Cow::Owned(out) } +/// Removes speaker labels like "[John]:", "SPEAKER:", or ">>" +fn strip_speaker_labels(line: &str) -> Cow<'_, str> { + let trimmed = line.trim(); + + // Remove ">>" prefix (common in news/multi-speaker transcripts) + if trimmed.starts_with(">>") { + let after = trimmed.trim_start_matches('>').trim_start_matches(' '); + return Cow::Owned(after.to_string()); + } + + // Detect labels like "NAME:" or "[NAME]:" + if let Some(colon_pos) = trimmed.find(':') { + let prefix = trimmed[..colon_pos].trim(); + + if prefix.is_empty() { + return Cow::Borrowed(line); + } + + let is_bracketed = prefix.starts_with('[') && prefix.ends_with(']'); + let is_all_caps = prefix.chars().all(|c| !c.is_alphabetic() || c.is_uppercase()); + + // Speakers are typically short, all caps, or bracketed + // We limit to 25 chars to avoid catching long sentences that happen to have a colon + if (is_bracketed || is_all_caps) && prefix.len() < 25 { + let after = &trimmed[colon_pos + 1..].trim(); + return Cow::Owned(after.to_string()); + } + } + + Cow::Borrowed(line) +} + fn normalize_whitespace(line: &str) -> String { let mut out = String::with_capacity(line.len()); let mut prev_space = false; @@ -200,24 +199,41 @@ mod tests { use super::*; #[test] - fn cleans_srt() { - let input = "\u{feff}1\n00:00:01,000 --> 00:00:02,000\nHello\n\n2\n00:00:02,500 --> 00:00:03,000\nWorld\n"; + fn cleans_srt_standard() { + let input = + "1\n00:00:01,000 --> 00:00:02,000\nHello\n\n2\n00:00:02,500 --> 00:00:03,000\nWorld\n"; let cleaned = SubtitleCleaner::new().clean(input); assert_eq!(cleaned, "Hello World"); } #[test] - fn cleans_vtt_with_tags() { - let input = "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nHello\n00:00:01.000 --> 00:00:02.000\nWorld"; + fn cleans_vtt_with_headers_and_tags() { + let input = "WEBVTT\nKIND: captions\n\n00:00:00.000 --> 00:00:01.000\nHello\n00:00:01.000 --> 00:00:02.000\nWorld"; let cleaned = SubtitleCleaner::new().clean(input); assert_eq!(cleaned, "Hello World"); } #[test] - fn removes_duplicate_lines() { - let input = - "1\n00:00:01,000 --> 00:00:02,000\nHello\n\n2\n00:00:02,000 --> 00:00:03,000\nHello\n"; + fn handles_speaker_labels() { + let cleaner = SubtitleCleaner::new(); + + assert_eq!(cleaner.clean(">> This is a new speaker"), "This is a new speaker"); + assert_eq!(cleaner.clean("[JOHN]: Good morning"), "Good morning"); + assert_eq!(cleaner.clean("SPEAKER 1: Test message"), "Test message"); + assert_eq!(cleaner.clean("Normal sentence: with a colon"), "Normal sentence: with a colon"); + } + + #[test] + fn removes_duplicate_consecutive_lines() { + let input = "1\n00:01:00,000 --> 00:01:02,000\nRepeat this\n\n2\n00:01:02,000 --> 00:01:04,000\nRepeat this\n"; + let cleaned = SubtitleCleaner::new().clean(input); + assert_eq!(cleaned, "Repeat this"); + } + + #[test] + fn strips_bom_correctly() { + let input = "\u{feff}WEBVTT\n\n00:01.000 --> 00:02.000\nBOM Test"; let cleaned = SubtitleCleaner::new().clean(input); - assert_eq!(cleaned, "Hello"); + assert_eq!(cleaned, "BOM Test"); } } diff --git a/src/infrastructure/persistence/repositories.rs b/src/infrastructure/persistence/repositories.rs index 48267a3..ac51a9b 100644 --- a/src/infrastructure/persistence/repositories.rs +++ b/src/infrastructure/persistence/repositories.rs @@ -1,24 +1,22 @@ -//! Repository implementations using Diesel. +//! implementation of domain repositories using Diesel. -use diesel::prelude::*; -use std::str::FromStr; use std::sync::Arc; use chrono::{DateTime, NaiveDateTime, Utc}; +use diesel::prelude::*; -use super::connection::DbPool; -use super::models::{ - CourseRow, ExamRow, ModuleRow, NewCourse, NewExam, NewModule, NewNote, NewVideo, NoteRow, - VideoRow, -}; use crate::domain::{ entities::{Course, Exam, Module, Note, NoteId, Video}, ports::{ CourseRepository, ExamRepository, ModuleRepository, NoteRepository, RepositoryError, VideoRepository, }, - value_objects::{CourseId, ExamId, ModuleId, PlaylistUrl, VideoId}, + value_objects::{ + CourseId, ExamId, ModuleId, PlaylistUrl, VideoId, VideoSource, YouTubeVideoId, + }, }; +use crate::infrastructure::persistence::connection::DbPool; +use crate::infrastructure::persistence::models::*; use crate::schema::{courses, exams, modules, notes, videos}; /// SQLite-backed course repository. @@ -46,6 +44,12 @@ impl CourseRepository for SqliteCourseRepository { diesel::insert_into(courses::table) .values(&new_course) + .on_conflict(courses::id) + .do_update() + .set(( + courses::name.eq(new_course.name), + courses::description.eq(new_course.description), + )) .execute(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; @@ -55,35 +59,14 @@ impl CourseRepository for SqliteCourseRepository { fn find_by_id(&self, id: &CourseId) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let result = courses::table + let row: Option = courses::table .find(id.as_uuid().to_string()) - .first::(&mut conn) + .first(&mut conn) .optional() .map_err(|e| RepositoryError::Database(e.to_string()))?; - match result { - Some(row) => { - let course_id = CourseId::from_uuid( - uuid::Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let playlist_url = PlaylistUrl::new(&row.source_url) - .map_err(|e| RepositoryError::Database(e.to_string()))?; - - let created_at = - NaiveDateTime::parse_from_str(&row.created_at, "%Y-%m-%d %H:%M:%S") - .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) - .map_err(|e| RepositoryError::Database(e.to_string()))?; - - Ok(Some(Course::new_with_created_at( - course_id, - row.name, - playlist_url, - row.playlist_id, - row.description, - created_at, - ))) - }, + match row { + Some(r) => Ok(Some(row_to_course(r)?)), None => Ok(None), } } @@ -94,30 +77,7 @@ impl CourseRepository for SqliteCourseRepository { let rows: Vec = courses::table.load(&mut conn).map_err(|e| RepositoryError::Database(e.to_string()))?; - rows.into_iter() - .map(|row| { - let course_id = CourseId::from_uuid( - uuid::Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let playlist_url = PlaylistUrl::new(&row.source_url) - .map_err(|e| RepositoryError::Database(e.to_string()))?; - - let created_at = - NaiveDateTime::parse_from_str(&row.created_at, "%Y-%m-%d %H:%M:%S") - .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) - .map_err(|e| RepositoryError::Database(e.to_string()))?; - - Ok(Course::new_with_created_at( - course_id, - row.name, - playlist_url, - row.playlist_id, - row.description, - created_at, - )) - }) - .collect() + rows.into_iter().map(row_to_course).collect() } fn update_metadata( @@ -171,6 +131,12 @@ impl ModuleRepository for SqliteModuleRepository { diesel::insert_into(modules::table) .values(&new_module) + .on_conflict(modules::id) + .do_update() + .set(( + modules::title.eq(new_module.title), + modules::sort_order.eq(new_module.sort_order), + )) .execute(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; @@ -180,24 +146,14 @@ impl ModuleRepository for SqliteModuleRepository { fn find_by_id(&self, id: &ModuleId) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let result = modules::table + let row: Option = modules::table .find(id.as_uuid().to_string()) - .first::(&mut conn) + .first(&mut conn) .optional() .map_err(|e| RepositoryError::Database(e.to_string()))?; - match result { - Some(row) => { - let module_id = ModuleId::from_uuid( - uuid::Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let course_id = CourseId::from_uuid( - uuid::Uuid::parse_str(&row.course_id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - Ok(Some(Module::new(module_id, course_id, row.title, row.sort_order as u32))) - }, + match row { + Some(r) => Ok(Some(row_to_module(r)?)), None => Ok(None), } } @@ -211,19 +167,7 @@ impl ModuleRepository for SqliteModuleRepository { .load(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; - rows.into_iter() - .map(|row| { - let module_id = ModuleId::from_uuid( - uuid::Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let cid = CourseId::from_uuid( - uuid::Uuid::parse_str(&row.course_id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - Ok(Module::new(module_id, cid, row.title, row.sort_order as u32)) - }) - .collect() + rows.into_iter().map(row_to_module).collect() } fn update_title(&self, id: &ModuleId, title: &str) -> Result<(), RepositoryError> { @@ -263,29 +207,43 @@ impl VideoRepository for SqliteVideoRepository { fn save(&self, video: &Video) -> Result<(), RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let duration_secs = u32_to_i32(video.duration_secs(), "duration_secs")?; - let sort_order = u32_to_i32(video.sort_order(), "sort_order")?; + let (source_type, source_ref) = match video.source() { + VideoSource::YouTube(id) => ("youtube", id.as_str().to_string()), + VideoSource::LocalPath(path) => ("local", path.clone()), + }; let new_video = NewVideo { id: &video.id().as_uuid().to_string(), module_id: &video.module_id().as_uuid().to_string(), - youtube_id: match video.source().source_type() { - "youtube" => Some(video.source().source_ref()), - _ => Some(""), + youtube_id: match video.source() { + VideoSource::YouTube(id) => Some(id.as_str()), + _ => None, }, - source_type: video.source().source_type(), - source_ref: video.source().source_ref(), title: video.title(), - duration_secs, + duration_secs: video.duration_secs() as i32, is_completed: video.is_completed(), - sort_order, + sort_order: video.sort_order() as i32, description: video.description(), transcript: video.transcript(), summary: video.summary(), + source_type, + source_ref: &source_ref, }; diesel::insert_into(videos::table) .values(&new_video) + .on_conflict(videos::id) + .do_update() + .set(( + videos::title.eq(new_video.title), + videos::duration_secs.eq(new_video.duration_secs), + videos::is_completed.eq(new_video.is_completed), + videos::sort_order.eq(new_video.sort_order), + videos::description.eq(new_video.description), + videos::transcript.eq(new_video.transcript), + videos::summary.eq(new_video.summary), + videos::module_id.eq(new_video.module_id), + )) .execute(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; @@ -295,14 +253,14 @@ impl VideoRepository for SqliteVideoRepository { fn find_by_id(&self, id: &VideoId) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let result = videos::table + let row: Option = videos::table .find(id.as_uuid().to_string()) - .first::(&mut conn) + .first(&mut conn) .optional() .map_err(|e| RepositoryError::Database(e.to_string()))?; - match result { - Some(row) => row_to_video(row).map(Some), + match row { + Some(r) => Ok(Some(row_to_video(r)?)), None => Ok(None), } } @@ -322,7 +280,6 @@ impl VideoRepository for SqliteVideoRepository { fn find_by_course(&self, course_id: &CourseId) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - // Join modules to get videos for a course let rows: Vec = videos::table .inner_join(modules::table) .filter(modules::course_id.eq(course_id.as_uuid().to_string())) @@ -378,12 +335,11 @@ impl VideoRepository for SqliteVideoRepository { sort_order: u32, ) -> Result<(), RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let sort_order = u32_to_i32(sort_order, "sort_order")?; diesel::update(videos::table.find(id.as_uuid().to_string())) .set(( videos::module_id.eq(module_id.as_uuid().to_string()), - videos::sort_order.eq(sort_order), + videos::sort_order.eq(sort_order as i32), )) .execute(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; @@ -402,61 +358,11 @@ impl VideoRepository for SqliteVideoRepository { } } -fn row_to_video(row: VideoRow) -> Result { - let video_id = VideoId::from_uuid( - uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let module_id = ModuleId::from_uuid( - uuid::Uuid::parse_str(&row.module_id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let source = match row.source_type.as_str() { - "youtube" => { - let youtube_id = crate::domain::value_objects::YouTubeVideoId::new(&row.source_ref) - .map_err(|e| RepositoryError::Database(e.to_string()))?; - crate::domain::value_objects::VideoSource::youtube(youtube_id) - }, - "local" => crate::domain::value_objects::VideoSource::LocalPath(row.source_ref.clone()), - other => { - return Err(RepositoryError::Database(format!("Invalid video source type: {other}"))); - }, - }; - - let duration_secs = i32_to_u32(row.duration_secs, "duration_secs")?; - let sort_order = i32_to_u32(row.sort_order, "sort_order")?; - - let mut video = Video::with_description( - video_id, - module_id, - source, - row.title, - row.description, - duration_secs, - sort_order, - ); - video.update_transcript(row.transcript); - video.update_summary(row.summary); - if row.is_completed { - video.mark_completed(); - } - Ok(video) -} - /// SQLite-backed exam repository. pub struct SqliteExamRepository { pool: Arc, } -fn i32_to_u32(value: i32, field: &str) -> Result { - u32::try_from(value) - .map_err(|_| RepositoryError::Database(format!("Invalid {field} value: {value}"))) -} - -fn u32_to_i32(value: u32, field: &str) -> Result { - i32::try_from(value) - .map_err(|_| RepositoryError::Database(format!("Invalid {field} value: {value}"))) -} - impl SqliteExamRepository { pub fn new(pool: Arc) -> Self { Self { pool } @@ -476,6 +382,12 @@ impl ExamRepository for SqliteExamRepository { diesel::insert_into(exams::table) .values(&new_exam) + .on_conflict(exams::id) + .do_update() + .set(( + exams::question_json.eq(new_exam.question_json), + exams::user_answers_json.eq(new_exam.user_answers_json), + )) .execute(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; @@ -485,24 +397,22 @@ impl ExamRepository for SqliteExamRepository { fn find_by_id(&self, id: &ExamId) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let result = exams::table + let row: Option = exams::table .find(id.as_uuid().to_string()) - .first::(&mut conn) + .first(&mut conn) .optional() .map_err(|e| RepositoryError::Database(e.to_string()))?; - match result { - Some(row) => row_to_exam(row).map(Some), + match row { + Some(r) => Ok(Some(row_to_exam(r)?)), None => Ok(None), } } fn find_all(&self) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let rows: Vec = exams::table.load(&mut conn).map_err(|e| RepositoryError::Database(e.to_string()))?; - rows.into_iter().map(row_to_exam).collect() } @@ -539,22 +449,6 @@ impl ExamRepository for SqliteExamRepository { } } -fn row_to_exam(row: ExamRow) -> Result { - let exam_id = ExamId::from_uuid( - uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - let video_id = VideoId::from_uuid( - uuid::Uuid::parse_str(&row.video_id) - .map_err(|e| RepositoryError::Database(e.to_string()))?, - ); - - let mut exam = Exam::new(exam_id, video_id, row.question_json); - if let Some(score) = row.score { - exam.record_result(score, row.user_answers_json); - } - Ok(exam) -} - /// SQLite-backed note repository. pub struct SqliteNoteRepository { pool: Arc, @@ -576,9 +470,11 @@ impl NoteRepository for SqliteNoteRepository { content: note.content(), }; - // Use upsert - ON CONFLICT replace - diesel::replace_into(notes::table) + diesel::insert_into(notes::table) .values(&new_note) + .on_conflict(notes::id) + .do_update() + .set(notes::content.eq(new_note.content)) .execute(&mut conn) .map_err(|e| RepositoryError::Database(e.to_string()))?; @@ -588,14 +484,14 @@ impl NoteRepository for SqliteNoteRepository { fn find_by_video(&self, video_id: &VideoId) -> Result, RepositoryError> { let mut conn = self.pool.get().map_err(|e| RepositoryError::Database(e.to_string()))?; - let result = notes::table + let row: Option = notes::table .filter(notes::video_id.eq(video_id.as_uuid().to_string())) - .first::(&mut conn) + .first(&mut conn) .optional() .map_err(|e| RepositoryError::Database(e.to_string()))?; - match result { - Some(row) => row_to_note(row).map(Some), + match row { + Some(r) => Ok(Some(row_to_note(r)?)), None => Ok(None), } } @@ -611,13 +507,122 @@ impl NoteRepository for SqliteNoteRepository { } } -fn row_to_note(row: NoteRow) -> Result { - let note_id = - NoteId::from_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?; +// --- Mappings --- + +fn row_to_course(row: CourseRow) -> Result { + let course_id = CourseId::from_uuid( + uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, + ); + let playlist_url = + PlaylistUrl::new(&row.source_url).map_err(|e| RepositoryError::Database(e.to_string()))?; + + let created_at = parse_sqlite_timestamp(&row.created_at)?; + + Ok(Course::new_with_created_at( + course_id, + row.name, + playlist_url, + row.playlist_id, + row.description, + created_at, + )) +} + +fn row_to_module(row: ModuleRow) -> Result { + let module_id = ModuleId::from_uuid( + uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, + ); + let course_id = CourseId::from_uuid( + uuid::Uuid::parse_str(&row.course_id) + .map_err(|e| RepositoryError::Database(e.to_string()))?, + ); + let sort_order = i32_to_u32(row.sort_order, "sort_order")?; + + Ok(Module::new(module_id, course_id, row.title, sort_order)) +} + +fn row_to_video(row: VideoRow) -> Result { + let video_id = VideoId::from_uuid( + uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, + ); + let module_id = ModuleId::from_uuid( + uuid::Uuid::parse_str(&row.module_id) + .map_err(|e| RepositoryError::Database(e.to_string()))?, + ); + + let source = match row.source_type.as_str() { + "youtube" => { + let youtube_id = YouTubeVideoId::new(&row.source_ref) + .map_err(|e| RepositoryError::Database(e.to_string()))?; + VideoSource::YouTube(youtube_id) + }, + "local" => VideoSource::LocalPath(row.source_ref.clone()), + other => { + return Err(RepositoryError::Database(format!("Invalid video source type: {other}"))); + }, + }; + + let duration_secs = i32_to_u32(row.duration_secs, "duration_secs")?; + let sort_order = i32_to_u32(row.sort_order, "sort_order")?; + + let mut video = Video::with_description( + video_id, + module_id, + source, + row.title, + row.description, + duration_secs, + sort_order, + ); + video.update_transcript(row.transcript); + video.update_summary(row.summary); + if row.is_completed { + video.mark_completed(); + } + Ok(video) +} + +fn row_to_exam(row: ExamRow) -> Result { + let exam_id = ExamId::from_uuid( + uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, + ); let video_id = VideoId::from_uuid( uuid::Uuid::parse_str(&row.video_id) .map_err(|e| RepositoryError::Database(e.to_string()))?, ); + let mut exam = Exam::new(exam_id, video_id, row.question_json); + if let Some(score) = row.score { + exam.record_result(score, row.user_answers_json); + } + Ok(exam) +} + +fn row_to_note(row: NoteRow) -> Result { + let note_id = NoteId::from_uuid( + uuid::Uuid::parse_str(&row.id).map_err(|e| RepositoryError::Database(e.to_string()))?, + ); + let video_id = VideoId::from_uuid( + uuid::Uuid::parse_str(&row.video_id) + .map_err(|e| RepositoryError::Database(e.to_string()))?, + ); Ok(Note::new(note_id, video_id, row.content)) } + +// --- Internal Helpers --- + +fn i32_to_u32(value: i32, field: &str) -> Result { + u32::try_from(value) + .map_err(|_| RepositoryError::Database(format!("Invalid value for {field}: {value}"))) +} + +fn parse_sqlite_timestamp(ts: &str) -> Result, RepositoryError> { + // SQLite timestamps in Diesel are typically "YYYY-MM-DD HH:MM:SS" + NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") + .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) + .or_else(|_| { + // Fallback for full ISO strings if any + ts.parse::>() + }) + .map_err(|e| RepositoryError::Database(format!("Failed to parse timestamp {ts}: {e}"))) +} diff --git a/src/ui/hooks.rs b/src/ui/hooks.rs index 6d0c265..0424b52 100644 --- a/src/ui/hooks.rs +++ b/src/ui/hooks.rs @@ -25,6 +25,33 @@ pub fn use_load_state() -> LoadState { LoadState { is_loading: use_signal(|| false), error: use_signal(|| None) } } +fn backend_key(backend: &Option>) -> String { + backend + .as_ref() + .map(|ctx| format!("{:p}", Arc::as_ptr(ctx))) + .unwrap_or_else(|| "none".to_string()) +} + +fn use_keyed_effect(key: K, mut effect: impl FnMut(K) + 'static) +where + K: PartialEq + Clone + 'static, +{ + let mut last_key = use_signal(|| None::); + let mut key_signal = use_signal(|| key.clone()); + if *key_signal.read() != key { + key_signal.set(key.clone()); + } + + use_effect(move || { + let current = key_signal.read().clone(); + let should_run = last_key.read().as_ref().map(|k| k != ¤t).unwrap_or(true); + if should_run { + last_key.set(Some(current.clone())); + effect(current); + } + }); +} + /// Load dashboard analytics with loading and error state. pub fn use_load_dashboard_analytics( backend: Option>, @@ -34,7 +61,8 @@ pub fn use_load_dashboard_analytics( let mut is_loading = load_state.is_loading; let mut error = load_state.error; - use_effect(move || { + let key = backend_key(&backend); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -58,8 +86,9 @@ pub fn use_load_dashboard_analytics( /// Load all courses from the database. pub fn use_load_courses(backend: Option>) -> Signal> { let mut courses = use_signal(Vec::new); + let key = backend_key(&backend); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.course_repo.find_all() { Ok(loaded) => courses.set(loaded), @@ -80,7 +109,8 @@ pub fn use_load_courses_state( let mut is_loading = load_state.is_loading; let mut error = load_state.error; - use_effect(move || { + let key = backend_key(&backend); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -105,8 +135,9 @@ pub fn use_load_modules( ) -> Signal> { let mut modules = use_signal(Vec::new); let course_id = course_id.clone(); + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.module_repo.find_by_course(&course_id) { Ok(loaded) => modules.set(loaded), @@ -125,23 +156,12 @@ pub fn use_load_modules_state( ) -> (Signal>, LoadState) { let mut modules = use_signal(Vec::new); let course_id = course_id.clone(); - let mut course_id_signal = use_signal(|| course_id.clone()); - if *course_id_signal.read() != course_id { - course_id_signal.set(course_id.clone()); - } let load_state = use_load_state(); let mut is_loading = load_state.is_loading; let mut error = load_state.error; - let mut last_course_id = use_signal(|| course_id.clone()); - if *last_course_id.read() != course_id { - last_course_id.set(course_id.clone()); - modules.set(Vec::new()); - error.set(None); - is_loading.set(true); - } - use_effect(move || { - let course_id = course_id_signal.read().clone(); + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -166,8 +186,9 @@ pub fn use_load_videos( ) -> Signal> { let mut videos = use_signal(Vec::new); let module_id = module_id.clone(); + let key = format!("{}|{}", backend_key(&backend), module_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.video_repo.find_by_module(&module_id) { Ok(loaded) => videos.set(loaded), @@ -186,8 +207,9 @@ pub fn use_load_course( ) -> Signal> { let mut course = use_signal(|| None); let course_id = course_id.clone(); + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.course_repo.find_by_id(&course_id) { Ok(loaded) => course.set(loaded), @@ -210,7 +232,8 @@ pub fn use_load_course_state( let mut is_loading = load_state.is_loading; let mut error = load_state.error; - use_effect(move || { + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -235,8 +258,9 @@ pub fn use_load_video( ) -> Signal> { let mut video = use_signal(|| None); let video_id = video_id.clone(); + let key = format!("{}|{}", backend_key(&backend), video_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.video_repo.find_by_id(&video_id) { Ok(loaded) => video.set(loaded), @@ -255,23 +279,12 @@ pub fn use_load_video_state( ) -> (Signal>, LoadState) { let mut video = use_signal(|| None); let video_id = video_id.clone(); - let mut video_id_signal = use_signal(|| video_id.clone()); - if *video_id_signal.read() != video_id { - video_id_signal.set(video_id.clone()); - } let load_state = use_load_state(); let mut is_loading = load_state.is_loading; let mut error = load_state.error; - let mut last_video_id = use_signal(|| video_id.clone()); - if *last_video_id.read() != video_id { - last_video_id.set(video_id.clone()); - video.set(None); - error.set(None); - is_loading.set(true); - } - use_effect(move || { - let video_id = video_id_signal.read().clone(); + let key = format!("{}|{}", backend_key(&backend), video_id.as_uuid()); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -293,8 +306,9 @@ pub fn use_load_video_state( pub fn use_load_exam(backend: Option>, exam_id: &ExamId) -> Signal> { let mut exam = use_signal(|| None); let exam_id = exam_id.clone(); + let key = format!("{}|{}", backend_key(&backend), exam_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.exam_repo.find_by_id(&exam_id) { Ok(loaded) => exam.set(loaded), @@ -317,7 +331,8 @@ pub fn use_load_exam_state( let mut is_loading = load_state.is_loading; let mut error = load_state.error; - use_effect(move || { + let key = format!("{}|{}", backend_key(&backend), exam_id.as_uuid()); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -339,8 +354,9 @@ pub fn use_load_exam_state( pub fn use_load_exams(backend: Option>, video_id: &VideoId) -> Signal> { let mut exams = use_signal(Vec::new); let video_id = video_id.clone(); + let key = format!("{}|{}", backend_key(&backend), video_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.exam_repo.find_by_video(&video_id) { Ok(loaded) => exams.set(loaded), @@ -355,8 +371,9 @@ pub fn use_load_exams(backend: Option>, video_id: &VideoId) -> S /// Load all exams from the database. pub fn use_load_all_exams(backend: Option>) -> Signal> { let mut exams = use_signal(Vec::new); + let key = backend_key(&backend); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.exam_repo.find_all() { Ok(loaded) => exams.set(loaded), @@ -377,7 +394,8 @@ pub fn use_load_all_exams_state( let mut is_loading = load_state.is_loading; let mut error = load_state.error; - use_effect(move || { + let key = backend_key(&backend); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -402,8 +420,9 @@ pub fn use_load_videos_by_course( ) -> Signal> { let mut videos = use_signal(Vec::new); let course_id = course_id.clone(); + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { match ctx.video_repo.find_by_course(&course_id) { Ok(loaded) => videos.set(loaded), @@ -422,23 +441,12 @@ pub fn use_load_videos_by_course_state( ) -> (Signal>, LoadState) { let mut videos = use_signal(Vec::new); let course_id = course_id.clone(); - let mut course_id_signal = use_signal(|| course_id.clone()); - if *course_id_signal.read() != course_id { - course_id_signal.set(course_id.clone()); - } let load_state = use_load_state(); let mut is_loading = load_state.is_loading; let mut error = load_state.error; - let mut last_course_id = use_signal(|| course_id.clone()); - if *last_course_id.read() != course_id { - last_course_id.set(course_id.clone()); - videos.set(Vec::new()); - error.set(None); - is_loading.set(true); - } - use_effect(move || { - let course_id = course_id_signal.read().clone(); + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); + use_keyed_effect(key, move |_| { is_loading.set(true); error.set(None); @@ -461,8 +469,9 @@ pub fn use_load_tags( backend: Option>, ) -> Signal> { let mut tags = use_signal(Vec::new); + let key = backend_key(&backend); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { use crate::domain::ports::TagRepository; match ctx.tag_repo.find_all() { @@ -482,8 +491,9 @@ pub fn use_load_course_tags( ) -> Signal> { let mut tags = use_signal(Vec::new); let course_id = course_id.clone(); + let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_effect(move || { + use_keyed_effect(key, move |_| { if let Some(ref ctx) = backend { use crate::domain::ports::TagRepository; match ctx.tag_repo.find_by_course(&course_id) { @@ -502,8 +512,11 @@ pub fn use_search( query: String, ) -> Signal> { let mut results = use_signal(Vec::new); + let trimmed = query.trim().to_string(); + let key = format!("{}|{}", backend_key(&backend), trimmed); - use_effect(move || { + use_keyed_effect(key, move |key| { + let query = key.split_once('|').map(|(_, q)| q.to_string()).unwrap_or_default(); if query.trim().is_empty() { results.set(Vec::new()); return; @@ -527,7 +540,17 @@ pub fn use_presence_sync(backend: Option>) { let route = use_route::(); let state = use_context::(); - use_effect(move || { + let course_id = state + .current_course + .read() + .as_ref() + .map(|c| c.id().as_uuid().to_string()) + .unwrap_or_default(); + let video_id = state.current_video_id.read().clone().unwrap_or_default(); + + let key = format!("{}|{:?}|{}|{}", backend_key(&backend), route, course_id, video_id); + + use_keyed_effect(key, move |_| { let backend = match backend.as_ref() { Some(b) => b, None => return, From a29cf55a71270c915947ec3a15fff11ed41a2be3 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:06:08 +0200 Subject: [PATCH 3/3] Make videos.youtube_id nullable and unify hooks Add SQL migrations to make videos.youtube_id nullable (up/down). Introduce LoadResult for hooks and update UI to use .data/.state. Export title_number_sequence and sort local media by numeric sequences. Exclude FTS search_index tables from Diesel schema generation. --- .env.example | 1 - diesel.toml | 8 + .../down.sql | 54 ++ .../up.sql | 54 ++ src/application/use_cases/ingest_local.rs | 16 +- src/domain/services/boundary_detector.rs | 15 + src/domain/services/mod.rs | 2 +- src/schema.rs | 60 -- src/ui/custom/sidebar.rs | 2 +- src/ui/hooks.rs | 531 ++++++------------ src/ui/pages/course_list.rs | 29 +- src/ui/pages/course_view.rs | 63 ++- src/ui/pages/dashboard.rs | 32 +- src/ui/pages/quiz_list.rs | 15 +- src/ui/pages/quiz_view.rs | 25 +- src/ui/pages/video_player.rs | 33 +- 16 files changed, 407 insertions(+), 533 deletions(-) create mode 100644 migrations/2026-02-02-000000-0000_make_youtube_id_nullable/down.sql create mode 100644 migrations/2026-02-02-000000-0000_make_youtube_id_nullable/up.sql diff --git a/.env.example b/.env.example index 9b305c2..55dfe35 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,3 @@ YOUTUBE_API_KEY= # Gemini API key (optional - for AI companion and exams) # Get from: https://aistudio.google.com/app/apikey GEMINI_API_KEY= - diff --git a/diesel.toml b/diesel.toml index a0d61bf..e90237f 100644 --- a/diesel.toml +++ b/diesel.toml @@ -4,6 +4,14 @@ [print_schema] file = "src/schema.rs" custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] +filter = { except_tables = [ + "search_index", + "search_index_config", + "search_index_content", + "search_index_data", + "search_index_docsize", + "search_index_idx", +] } [migrations_directory] dir = "migrations" diff --git a/migrations/2026-02-02-000000-0000_make_youtube_id_nullable/down.sql b/migrations/2026-02-02-000000-0000_make_youtube_id_nullable/down.sql new file mode 100644 index 0000000..d319aea --- /dev/null +++ b/migrations/2026-02-02-000000-0000_make_youtube_id_nullable/down.sql @@ -0,0 +1,54 @@ +-- Revert videos.youtube_id to NOT NULL by rebuilding the table (SQLite) +PRAGMA foreign_keys=off; + +CREATE TABLE videos_old ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + youtube_id TEXT NOT NULL, + title TEXT NOT NULL, + duration_secs INTEGER NOT NULL, + is_completed BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL, + description TEXT, + transcript TEXT, + summary TEXT, + source_type TEXT NOT NULL DEFAULT 'youtube', + source_ref TEXT NOT NULL DEFAULT '' +); + +INSERT INTO videos_old ( + id, + module_id, + youtube_id, + title, + duration_secs, + is_completed, + sort_order, + description, + transcript, + summary, + source_type, + source_ref +) +SELECT + id, + module_id, + COALESCE(youtube_id, ''), + title, + duration_secs, + is_completed, + sort_order, + description, + transcript, + summary, + source_type, + source_ref +FROM videos; + +DROP TABLE videos; +ALTER TABLE videos_old RENAME TO videos; + +CREATE INDEX idx_videos_module_id ON videos(module_id); +CREATE INDEX idx_videos_youtube_id ON videos(youtube_id); + +PRAGMA foreign_keys=on; diff --git a/migrations/2026-02-02-000000-0000_make_youtube_id_nullable/up.sql b/migrations/2026-02-02-000000-0000_make_youtube_id_nullable/up.sql new file mode 100644 index 0000000..4551567 --- /dev/null +++ b/migrations/2026-02-02-000000-0000_make_youtube_id_nullable/up.sql @@ -0,0 +1,54 @@ +-- Make videos.youtube_id nullable by rebuilding the table +PRAGMA foreign_keys=off; + +CREATE TABLE videos_new ( + id TEXT PRIMARY KEY NOT NULL, + module_id TEXT NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + youtube_id TEXT, + title TEXT NOT NULL, + duration_secs INTEGER NOT NULL, + is_completed BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL, + description TEXT, + transcript TEXT, + summary TEXT, + source_type TEXT NOT NULL DEFAULT 'youtube', + source_ref TEXT NOT NULL DEFAULT '' +); + +INSERT INTO videos_new ( + id, + module_id, + youtube_id, + title, + duration_secs, + is_completed, + sort_order, + description, + transcript, + summary, + source_type, + source_ref +) +SELECT + id, + module_id, + youtube_id, + title, + duration_secs, + is_completed, + sort_order, + description, + transcript, + summary, + source_type, + source_ref +FROM videos; + +DROP TABLE videos; +ALTER TABLE videos_new RENAME TO videos; + +CREATE INDEX idx_videos_module_id ON videos(module_id); +CREATE INDEX idx_videos_youtube_id ON videos(youtube_id); + +PRAGMA foreign_keys=on; diff --git a/src/application/use_cases/ingest_local.rs b/src/application/use_cases/ingest_local.rs index a255d33..141bd0c 100644 --- a/src/application/use_cases/ingest_local.rs +++ b/src/application/use_cases/ingest_local.rs @@ -13,7 +13,7 @@ use crate::domain::{ CourseRepository, LocalMediaScanner, ModuleRepository, RawLocalMediaMetadata, SearchRepository, VideoRepository, }, - services::{BoundaryDetector, SubtitleCleaner, TitleSanitizer}, + services::{BoundaryDetector, SubtitleCleaner, TitleSanitizer, title_number_sequence}, value_objects::{CourseId, ModuleId, PlaylistUrl, VideoId, VideoSource}, }; @@ -217,6 +217,20 @@ fn group_by_folder( grouped.entry(folder).or_default().push(item.clone()); } + for entries in grouped.values_mut() { + entries.sort_by(|a, b| { + let a_key = title_number_sequence(&a.title); + let b_key = title_number_sequence(&b.title); + + match (a_key, b_key) { + (Some(a_seq), Some(b_seq)) => a_seq.cmp(&b_seq).then_with(|| a.title.cmp(&b.title)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.title.cmp(&b.title), + } + }); + } + grouped } diff --git a/src/domain/services/boundary_detector.rs b/src/domain/services/boundary_detector.rs index e5e87e4..3a32bee 100644 --- a/src/domain/services/boundary_detector.rs +++ b/src/domain/services/boundary_detector.rs @@ -123,6 +123,21 @@ fn boundary_key(title: &str) -> Option { None } +/// Extracts a dotted/compound numeric sequence for ordering purposes. +/// Examples: "1.10.2 Topic" -> [1, 10, 2], "Module 2.3" -> [2, 3]. +pub fn title_number_sequence(title: &str) -> Option> { + let title_trimmed = title.trim(); + if title_trimmed.is_empty() { + return None; + } + + if let Some(nums) = parse_labeled_sequence(title_trimmed) { + return Some(nums); + } + + parse_leading_sequence(title_trimmed) +} + fn parse_labeled_sequence(title: &str) -> Option> { let lower = title.to_lowercase(); let labels = [ diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index cbc5725..f9d8329 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -5,7 +5,7 @@ mod sanitizer; mod session_planner; mod subtitle_cleaner; -pub use boundary_detector::BoundaryDetector; +pub use boundary_detector::{BoundaryDetector, title_number_sequence}; pub use sanitizer::TitleSanitizer; pub use session_planner::SessionPlanner; pub use subtitle_cleaner::SubtitleCleaner; diff --git a/src/schema.rs b/src/schema.rs index 53ba89a..4dcf818 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -47,60 +47,6 @@ diesel::table! { } } -diesel::table! { - search_index (rowid) { - rowid -> Integer, - entity_type -> Nullable, - entity_id -> Nullable, - title -> Nullable, - content -> Nullable, - course_id -> Nullable, - #[sql_name = "search_index"] - search_index_ -> Nullable, - rank -> Nullable, - } -} - -diesel::table! { - search_index_config (k) { - k -> Binary, - v -> Nullable, - } -} - -diesel::table! { - search_index_content (id) { - id -> Nullable, - c0 -> Nullable, - c1 -> Nullable, - c2 -> Nullable, - c3 -> Nullable, - c4 -> Nullable, - } -} - -diesel::table! { - search_index_data (id) { - id -> Nullable, - block -> Nullable, - } -} - -diesel::table! { - search_index_docsize (id) { - id -> Nullable, - sz -> Nullable, - } -} - -diesel::table! { - search_index_idx (segid, term) { - segid -> Binary, - term -> Binary, - pgno -> Nullable, - } -} - diesel::table! { tags (id) { id -> Text, @@ -149,12 +95,6 @@ diesel::allow_tables_to_appear_in_same_query!( exams, modules, notes, - search_index, - search_index_config, - search_index_content, - search_index_data, - search_index_docsize, - search_index_idx, tags, user_preferences, videos, diff --git a/src/ui/custom/sidebar.rs b/src/ui/custom/sidebar.rs index 92d2493..c407132 100644 --- a/src/ui/custom/sidebar.rs +++ b/src/ui/custom/sidebar.rs @@ -51,7 +51,7 @@ pub fn Sidebar() -> Element { } { - let results_list = results.read(); + let results_list = results.data.read(); if !search_query.read().is_empty() { if results_list.is_empty() { rsx! { diff --git a/src/ui/hooks.rs b/src/ui/hooks.rs index 0424b52..6d36561 100644 --- a/src/ui/hooks.rs +++ b/src/ui/hooks.rs @@ -1,13 +1,15 @@ -//! Data loading hooks for courses and videos +//! Data loading hooks for courses and videos (async, debounced, unified signatures) use std::sync::Arc; +use std::time::Duration; use dioxus::prelude::*; use crate::application::{AppContext, ServiceFactory}; -use crate::domain::entities::{AppAnalytics, Course, Exam, Module, Video}; +use crate::domain::entities::{AppAnalytics, Course, Exam, Module, SearchResult, Tag, Video}; use crate::domain::ports::{ - Activity, CourseRepository, ExamRepository, ModuleRepository, VideoRepository, + Activity, CourseRepository, ExamRepository, ModuleRepository, SearchRepository, TagRepository, + VideoRepository, }; use crate::domain::value_objects::{CourseId, ExamId, ModuleId, VideoId}; use crate::ui::routes::Route; @@ -20,6 +22,13 @@ pub struct LoadState { pub error: Signal>, } +/// Result wrapper for load hooks. +#[derive(Clone)] +pub struct LoadResult { + pub data: Signal, + pub state: LoadState, +} + /// Initialize loading and error signals for a hook. pub fn use_load_state() -> LoadState { LoadState { is_loading: use_signal(|| false), error: use_signal(|| None) } @@ -52,486 +61,280 @@ where }); } -/// Load dashboard analytics with loading and error state. -pub fn use_load_dashboard_analytics( +type Loader = Arc) -> Result + Send + Sync>; + +fn use_async_loader( backend: Option>, -) -> (Signal>, LoadState) { - let mut analytics = use_signal(|| None); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; + key: K, + loader: Loader, +) -> LoadResult +where + T: Default + Send + 'static, + K: PartialEq + Clone + 'static, +{ + let data = use_signal(T::default); + let state = use_load_state(); + let is_loading = state.is_loading; + let error = state.error; + let mut request_id = use_signal(|| 0u64); + let backend_slot = use_signal(|| backend.clone()); + let mut backend_slot_for_effect = backend_slot; + let backend_for_effect = backend.clone(); + + let loader_for_future = loader.clone(); + let mut future = use_future(move || { + let backend = backend_slot.read().clone(); + let loader = loader_for_future.clone(); + let mut data = data; + let mut is_loading = is_loading; + let mut error = error; + let request_snapshot = *request_id.read(); + + async move { + is_loading.set(true); + error.set(None); + + let result = match backend { + Some(ctx) => match tokio::task::spawn_blocking(move || (loader)(ctx)).await { + Ok(inner) => inner.map_err(|e| format!("Loader task failed: {e}")), + Err(e) => Err(format!("Loader task failed: {e}")), + }, + None => Err("Backend not available".to_string()), + }; + + if request_snapshot != *request_id.read() { + return; + } - let key = backend_key(&backend); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => { - let use_case = ServiceFactory::dashboard(ctx); - match use_case.execute() { - Ok(snapshot) => analytics.set(Some(snapshot)), - Err(e) => error.set(Some(format!("Failed to load analytics: {}", e))), - } - }, - None => error.set(Some("Backend not available".to_string())), + match result { + Ok(value) => data.set(value), + Err(e) => error.set(Some(e)), + } + + is_loading.set(false); } + }); - is_loading.set(false); + use_keyed_effect(key, move |_| { + backend_slot_for_effect.set(backend_for_effect.clone()); + let next = request_id.read().wrapping_add(1); + request_id.set(next); + future.restart(); }); - (analytics, load_state) + LoadResult { data, state } } -/// Load all courses from the database. -pub fn use_load_courses(backend: Option>) -> Signal> { - let mut courses = use_signal(Vec::new); - let key = backend_key(&backend); +/// Debounce a value to reduce downstream work (e.g., search queries). +pub fn use_debounced_value(value: String, delay_ms: u64) -> Signal { + let debounced = use_signal(|| value.clone()); + let mut latest = use_signal(|| value.clone()); + if *latest.read() != value { + latest.set(value.clone()); + } - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.course_repo.find_all() { - Ok(loaded) => courses.set(loaded), - Err(e) => log::error!("Failed to load courses: {}", e), + let mut request_id = use_signal(|| 0u64); + let mut future = use_future(move || { + let mut debounced = debounced; + let latest = latest; + let request_snapshot = *request_id.read(); + async move { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + if request_snapshot != *request_id.read() { + return; } + debounced.set(latest.read().clone()); } }); - courses + use_keyed_effect(value, move |_| { + let next = request_id.read().wrapping_add(1); + request_id.set(next); + future.restart(); + }); + + debounced } -/// Load all courses with loading and error state. -pub fn use_load_courses_state( +/// Load dashboard analytics with loading and error state. +pub fn use_load_dashboard_analytics( backend: Option>, -) -> (Signal>, LoadState) { - let mut courses = use_signal(Vec::new); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - +) -> LoadResult> { let key = backend_key(&backend); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.course_repo.find_all() { - Ok(loaded) => courses.set(loaded), - Err(e) => error.set(Some(format!("Failed to load courses: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + let use_case = ServiceFactory::dashboard(&ctx); + use_case.execute().map(Some).map_err(|e| format!("Failed to load analytics: {e}")) }); - (courses, load_state) + use_async_loader(backend, key, loader) } -/// Load modules for a specific course. -pub fn use_load_modules( - backend: Option>, - course_id: &CourseId, -) -> Signal> { - let mut modules = use_signal(Vec::new); - let course_id = course_id.clone(); - let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.module_repo.find_by_course(&course_id) { - Ok(loaded) => modules.set(loaded), - Err(e) => log::error!("Failed to load modules: {}", e), - } - } +/// Load all courses from the database. +pub fn use_load_courses(backend: Option>) -> LoadResult> { + let key = backend_key(&backend); + let loader: Loader> = Arc::new(move |ctx| { + ctx.course_repo.find_all().map_err(|e| format!("Failed to load courses: {e}")) }); - modules + use_async_loader(backend, key, loader) } -/// Load modules with loading and error state. -pub fn use_load_modules_state( +/// Load modules for a specific course. +pub fn use_load_modules( backend: Option>, course_id: &CourseId, -) -> (Signal>, LoadState) { - let mut modules = use_signal(Vec::new); +) -> LoadResult> { let course_id = course_id.clone(); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.module_repo.find_by_course(&course_id) { - Ok(loaded) => modules.set(loaded), - Err(e) => error.set(Some(format!("Failed to load modules: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + ctx.module_repo + .find_by_course(&course_id) + .map_err(|e| format!("Failed to load modules: {e}")) }); - (modules, load_state) + use_async_loader(backend, key, loader) } /// Load videos for a specific module. pub fn use_load_videos( backend: Option>, module_id: &ModuleId, -) -> Signal> { - let mut videos = use_signal(Vec::new); +) -> LoadResult> { let module_id = module_id.clone(); let key = format!("{}|{}", backend_key(&backend), module_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.video_repo.find_by_module(&module_id) { - Ok(loaded) => videos.set(loaded), - Err(e) => log::error!("Failed to load videos: {}", e), - } - } + let loader: Loader> = Arc::new(move |ctx| { + ctx.video_repo.find_by_module(&module_id).map_err(|e| format!("Failed to load videos: {e}")) }); - videos + use_async_loader(backend, key, loader) } /// Load a single course by ID. pub fn use_load_course( backend: Option>, course_id: &CourseId, -) -> Signal> { - let mut course = use_signal(|| None); - let course_id = course_id.clone(); - let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.course_repo.find_by_id(&course_id) { - Ok(loaded) => course.set(loaded), - Err(e) => log::error!("Failed to load course: {}", e), - } - } - }); - - course -} - -/// Load a single course with loading and error state. -pub fn use_load_course_state( - backend: Option>, - course_id: &CourseId, -) -> (Signal>, LoadState) { - let mut course = use_signal(|| None); +) -> LoadResult> { let course_id = course_id.clone(); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.course_repo.find_by_id(&course_id) { - Ok(loaded) => course.set(loaded), - Err(e) => error.set(Some(format!("Failed to load course: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + ctx.course_repo.find_by_id(&course_id).map_err(|e| format!("Failed to load course: {e}")) }); - (course, load_state) + use_async_loader(backend, key, loader) } /// Load a single video by ID. pub fn use_load_video( backend: Option>, video_id: &VideoId, -) -> Signal> { - let mut video = use_signal(|| None); - let video_id = video_id.clone(); - let key = format!("{}|{}", backend_key(&backend), video_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.video_repo.find_by_id(&video_id) { - Ok(loaded) => video.set(loaded), - Err(e) => log::error!("Failed to load video: {}", e), - } - } - }); - - video -} - -/// Load a single video with loading and error state. -pub fn use_load_video_state( - backend: Option>, - video_id: &VideoId, -) -> (Signal>, LoadState) { - let mut video = use_signal(|| None); +) -> LoadResult> { let video_id = video_id.clone(); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - let key = format!("{}|{}", backend_key(&backend), video_id.as_uuid()); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.video_repo.find_by_id(&video_id) { - Ok(loaded) => video.set(loaded), - Err(e) => error.set(Some(format!("Failed to load video: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + ctx.video_repo.find_by_id(&video_id).map_err(|e| format!("Failed to load video: {e}")) }); - (video, load_state) + use_async_loader(backend, key, loader) } /// Load a single exam by ID. -pub fn use_load_exam(backend: Option>, exam_id: &ExamId) -> Signal> { - let mut exam = use_signal(|| None); - let exam_id = exam_id.clone(); - let key = format!("{}|{}", backend_key(&backend), exam_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.exam_repo.find_by_id(&exam_id) { - Ok(loaded) => exam.set(loaded), - Err(e) => log::error!("Failed to load exam: {}", e), - } - } - }); - - exam -} - -/// Load a single exam with loading and error state. -pub fn use_load_exam_state( +pub fn use_load_exam( backend: Option>, exam_id: &ExamId, -) -> (Signal>, LoadState) { - let mut exam = use_signal(|| None); +) -> LoadResult> { let exam_id = exam_id.clone(); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - let key = format!("{}|{}", backend_key(&backend), exam_id.as_uuid()); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.exam_repo.find_by_id(&exam_id) { - Ok(loaded) => exam.set(loaded), - Err(e) => error.set(Some(format!("Failed to load exam: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + ctx.exam_repo.find_by_id(&exam_id).map_err(|e| format!("Failed to load exam: {e}")) }); - (exam, load_state) + use_async_loader(backend, key, loader) } /// Load exams for a specific video. -pub fn use_load_exams(backend: Option>, video_id: &VideoId) -> Signal> { - let mut exams = use_signal(Vec::new); +pub fn use_load_exams( + backend: Option>, + video_id: &VideoId, +) -> LoadResult> { let video_id = video_id.clone(); let key = format!("{}|{}", backend_key(&backend), video_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.exam_repo.find_by_video(&video_id) { - Ok(loaded) => exams.set(loaded), - Err(e) => log::error!("Failed to load exams: {}", e), - } - } + let loader: Loader> = Arc::new(move |ctx| { + ctx.exam_repo.find_by_video(&video_id).map_err(|e| format!("Failed to load exams: {e}")) }); - exams + use_async_loader(backend, key, loader) } /// Load all exams from the database. -pub fn use_load_all_exams(backend: Option>) -> Signal> { - let mut exams = use_signal(Vec::new); - let key = backend_key(&backend); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.exam_repo.find_all() { - Ok(loaded) => exams.set(loaded), - Err(e) => log::error!("Failed to load exams: {}", e), - } - } - }); - - exams -} - -/// Load all exams with loading and error state. -pub fn use_load_all_exams_state( - backend: Option>, -) -> (Signal>, LoadState) { - let mut exams = use_signal(Vec::new); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - +pub fn use_load_all_exams(backend: Option>) -> LoadResult> { let key = backend_key(&backend); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.exam_repo.find_all() { - Ok(loaded) => exams.set(loaded), - Err(e) => error.set(Some(format!("Failed to load exams: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + ctx.exam_repo.find_all().map_err(|e| format!("Failed to load exams: {e}")) }); - (exams, load_state) + use_async_loader(backend, key, loader) } /// Load all videos for a specific course (across all modules). pub fn use_load_videos_by_course( backend: Option>, course_id: &CourseId, -) -> Signal> { - let mut videos = use_signal(Vec::new); - let course_id = course_id.clone(); - let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - match ctx.video_repo.find_by_course(&course_id) { - Ok(loaded) => videos.set(loaded), - Err(e) => log::error!("Failed to load videos for course: {}", e), - } - } - }); - - videos -} - -/// Load all videos by course with loading and error state. -pub fn use_load_videos_by_course_state( - backend: Option>, - course_id: &CourseId, -) -> (Signal>, LoadState) { - let mut videos = use_signal(Vec::new); +) -> LoadResult> { let course_id = course_id.clone(); - let load_state = use_load_state(); - let mut is_loading = load_state.is_loading; - let mut error = load_state.error; - let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - use_keyed_effect(key, move |_| { - is_loading.set(true); - error.set(None); - - match backend.as_ref() { - Some(ctx) => match ctx.video_repo.find_by_course(&course_id) { - Ok(loaded) => videos.set(loaded), - Err(e) => error.set(Some(format!("Failed to load videos for course: {}", e))), - }, - None => error.set(Some("Backend not available".to_string())), - } - - is_loading.set(false); + let loader: Loader> = Arc::new(move |ctx| { + ctx.video_repo + .find_by_course(&course_id) + .map_err(|e| format!("Failed to load videos for course: {e}")) }); - (videos, load_state) + use_async_loader(backend, key, loader) } /// Load all tags from the database. -pub fn use_load_tags( - backend: Option>, -) -> Signal> { - let mut tags = use_signal(Vec::new); +pub fn use_load_tags(backend: Option>) -> LoadResult> { let key = backend_key(&backend); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - use crate::domain::ports::TagRepository; - match ctx.tag_repo.find_all() { - Ok(loaded) => tags.set(loaded), - Err(e) => log::error!("Failed to load tags: {}", e), - } - } + let loader: Loader> = Arc::new(move |ctx| { + ctx.tag_repo.find_all().map_err(|e| format!("Failed to load tags: {e}")) }); - tags + use_async_loader(backend, key, loader) } /// Load tags for a specific course. pub fn use_load_course_tags( backend: Option>, course_id: &CourseId, -) -> Signal> { - let mut tags = use_signal(Vec::new); +) -> LoadResult> { let course_id = course_id.clone(); let key = format!("{}|{}", backend_key(&backend), course_id.as_uuid()); - - use_keyed_effect(key, move |_| { - if let Some(ref ctx) = backend { - use crate::domain::ports::TagRepository; - match ctx.tag_repo.find_by_course(&course_id) { - Ok(loaded) => tags.set(loaded), - Err(e) => log::error!("Failed to load course tags: {}", e), - } - } + let loader: Loader> = Arc::new(move |ctx| { + ctx.tag_repo + .find_by_course(&course_id) + .map_err(|e| format!("Failed to load course tags: {e}")) }); - tags + use_async_loader(backend, key, loader) } -/// Search across courses, videos, and notes. +/// Search across courses, videos, and notes (debounced). pub fn use_search( backend: Option>, query: String, -) -> Signal> { - let mut results = use_signal(Vec::new); - let trimmed = query.trim().to_string(); - let key = format!("{}|{}", backend_key(&backend), trimmed); +) -> LoadResult> { + let debounced = use_debounced_value(query.trim().to_string(), 300); + let key = format!("{}|{}", backend_key(&backend), debounced.read().clone()); + let debounced_query = debounced.read().clone(); - use_keyed_effect(key, move |key| { - let query = key.split_once('|').map(|(_, q)| q.to_string()).unwrap_or_default(); + let loader: Loader> = Arc::new(move |ctx| { + let query = debounced_query.clone(); if query.trim().is_empty() { - results.set(Vec::new()); - return; - } - - if let Some(ref ctx) = backend { - use crate::domain::ports::SearchRepository; - match ctx.search_repo.search(&query, 20) { - Ok(loaded) => results.set(loaded), - Err(e) => log::error!("Search failed: {}", e), - } + return Ok(Vec::new()); } + ctx.search_repo.search(&query, 20).map_err(|e| format!("Search failed: {e}")) }); - results + use_async_loader(backend, key, loader) } /// Hook to synchronize the application state with Discord Rich Presence. diff --git a/src/ui/pages/course_list.rs b/src/ui/pages/course_list.rs index 8056d48..98adc43 100644 --- a/src/ui/pages/course_list.rs +++ b/src/ui/pages/course_list.rs @@ -3,10 +3,9 @@ use dioxus::prelude::*; use crate::domain::entities::Course; -use crate::domain::ports::VideoRepository; use crate::ui::Route; use crate::ui::custom::{CardSkeleton, CourseCard, ErrorAlert}; -use crate::ui::hooks::{use_load_courses_state, use_load_modules}; +use crate::ui::hooks::{use_load_courses, use_load_modules, use_load_videos_by_course}; use crate::ui::state::AppState; /// List of all imported courses. @@ -22,7 +21,8 @@ pub fn CourseList() -> Element { }); } - let (courses, courses_state) = use_load_courses_state(state.backend.clone()); + let courses = use_load_courses(state.backend.clone()); + let courses_state = courses.state.clone(); rsx! { div { class: "p-6", @@ -33,7 +33,7 @@ pub fn CourseList() -> Element { ErrorAlert { message: err.clone(), on_dismiss: None } } - if *courses_state.is_loading.read() && courses.read().is_empty() { + if *courses_state.is_loading.read() && courses.data.read().is_empty() { div { class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4", CardSkeleton {} CardSkeleton {} @@ -42,7 +42,7 @@ pub fn CourseList() -> Element { CardSkeleton {} CardSkeleton {} } - } else if courses.read().is_empty() { + } else if courses.data.read().is_empty() { div { class: "text-center py-12 bg-base-200 rounded-lg", p { class: "text-xl mb-2", "No courses yet" } p { class: "text-base-content/60", @@ -56,7 +56,7 @@ pub fn CourseList() -> Element { } } else { div { class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4", - for course in courses.read().iter() { + for course in courses.data.read().iter() { CourseCardWithStats { key: "{course.id().as_uuid()}", course: course.clone(), @@ -75,21 +75,10 @@ fn CourseCardWithStats(course: Course) -> Element { let backend = state.backend.clone(); let modules = use_load_modules(backend.clone(), course.id()); - let mut all_videos = use_signal(Vec::new); + let videos = use_load_videos_by_course(backend.clone(), course.id()); - let course_id = course.id().clone(); - let backend_inner = backend.clone(); - - use_effect(move || { - if let Some(ref ctx) = backend_inner { - if let Ok(videos) = ctx.video_repo.find_by_course(&course_id) { - all_videos.set(videos); - } - } - }); - - let module_list = modules.read(); - let video_list = all_videos.read(); + let module_list = modules.data.read(); + let video_list = videos.data.read(); let module_count = module_list.len(); let completed_modules = if video_list.is_empty() { diff --git a/src/ui/pages/course_view.rs b/src/ui/pages/course_view.rs index 5195422..87d602f 100644 --- a/src/ui/pages/course_view.rs +++ b/src/ui/pages/course_view.rs @@ -8,15 +8,15 @@ use crate::application::{ ServiceFactory, use_cases::{MoveVideoInput, PlanSessionInput, UpdateCourseInput, UpdateModuleTitleInput}, }; -use crate::domain::entities::{Module, Tag, Video}; +use crate::domain::entities::{Module, Tag}; use crate::domain::ports::{CourseRepository, TagRepository, VideoRepository}; use crate::domain::value_objects::{CourseId, ModuleId, SessionPlan, TagId}; use crate::ui::Route; use crate::ui::actions::export_course_notes_with_dialog; use crate::ui::custom::{ErrorAlert, PageSkeleton, TagBadge, TagInput, VideoItem}; use crate::ui::hooks::{ - use_load_course_state, use_load_course_tags, use_load_modules_state, use_load_tags, - use_load_videos_by_course_state, + use_load_course, use_load_course_tags, use_load_modules, use_load_tags, + use_load_videos_by_course, }; use crate::ui::state::AppState; @@ -39,27 +39,28 @@ pub fn CourseView(course_id: String) -> Element { let course_id_effective = course_id_parsed.clone().unwrap_or_else(|_| CourseId::new()); // Load course and modules - let (course, course_state) = use_load_course_state(state.backend.clone(), &course_id_effective); + let course = use_load_course(state.backend.clone(), &course_id_effective); + let course_state = course.state.clone(); - let (modules, modules_state): (Signal>, _) = - use_load_modules_state(state.backend.clone(), &course_id_effective); + let modules = use_load_modules(state.backend.clone(), &course_id_effective); + let modules_state = modules.state.clone(); - let (all_videos, videos_state): (Signal>, _) = - use_load_videos_by_course_state(state.backend.clone(), &course_id_effective); + let all_videos = use_load_videos_by_course(state.backend.clone(), &course_id_effective); + let videos_state = all_videos.state.clone(); let course_tags = use_load_course_tags(state.backend.clone(), &course_id_effective); let all_tags = use_load_tags(state.backend.clone()); - let total_videos = all_videos.read().len(); - let completed_videos = all_videos.read().iter().filter(|v| v.is_completed()).count(); + let total_videos = all_videos.data.read().len(); + let completed_videos = all_videos.data.read().iter().filter(|v| v.is_completed()).count(); let progress = if total_videos > 0 { (completed_videos as f32 / total_videos as f32) * 100.0 } else { 0.0 }; - if *course_state.is_loading.read() && course.read().is_none() { + if *course_state.is_loading.read() && course.data.read().is_none() { return rsx! { div { class: "p-6", PageSkeleton {} } }; @@ -97,7 +98,7 @@ pub fn CourseView(course_id: String) -> Element { if *edit_mode.read() { return; } - if let Some(c) = course.read().as_ref() { + if let Some(c) = course.data.read().as_ref() { edit_name.set(c.name().to_string()); edit_description.set(c.description().unwrap_or("").to_string()); } @@ -182,7 +183,7 @@ pub fn CourseView(course_id: String) -> Element { // Course update handler let backend_for_update = state.backend.clone(); let course_id_for_update = course_id_parsed.clone(); - let mut course_for_update = course; + let mut course_for_update = course.clone(); let edit_name_for_update = edit_name; let edit_description_for_update = edit_description; let mut edit_status_for_update = edit_status; @@ -210,7 +211,7 @@ pub fn CourseView(course_id: String) -> Element { Ok(_) => { edit_status_for_update.set(Some((true, "Course updated.".to_string()))); if let Ok(updated) = ctx.course_repo.find_by_id(cid) { - course_for_update.set(updated); + course_for_update.data.set(updated); } edit_mode_for_update.set(false); }, @@ -226,8 +227,8 @@ pub fn CourseView(course_id: String) -> Element { // Tag management handlers let backend_for_create_tag = state.backend.clone(); let course_id_for_create_tag = course_id_parsed.clone(); - let mut course_tags_for_create = course_tags; - let mut all_tags_for_create = all_tags; + let mut course_tags_for_create = course_tags.clone(); + let mut all_tags_for_create = all_tags.clone(); let mut tag_status_for_create = tag_status; let on_create_tag = move |name: String| { let trimmed = name.trim().to_string(); @@ -250,10 +251,10 @@ pub fn CourseView(course_id: String) -> Element { return; } if let Ok(updated) = ctx.tag_repo.find_by_course(cid) { - course_tags_for_create.set(updated); + course_tags_for_create.data.set(updated); } if let Ok(all) = ctx.tag_repo.find_all() { - all_tags_for_create.set(all); + all_tags_for_create.data.set(all); } tag_status_for_create.set(Some((true, "Tag added.".to_string()))); } @@ -262,7 +263,7 @@ pub fn CourseView(course_id: String) -> Element { let backend_for_attach_tag = state.backend.clone(); let course_id_for_attach_tag = course_id_parsed.clone(); - let mut course_tags_for_attach = course_tags; + let mut course_tags_for_attach = course_tags.clone(); let mut tag_status_for_attach = tag_status; let mut selected_tag_for_attach = selected_tag_id; let on_attach_tag = move |_| { @@ -287,7 +288,7 @@ pub fn CourseView(course_id: String) -> Element { return; } if let Ok(updated) = ctx.tag_repo.find_by_course(cid) { - course_tags_for_attach.set(updated); + course_tags_for_attach.data.set(updated); } selected_tag_for_attach.set(String::new()); tag_status_for_attach.set(Some((true, "Tag added.".to_string()))); @@ -295,7 +296,7 @@ pub fn CourseView(course_id: String) -> Element { } }; - let ordered_videos = all_videos.read().clone(); + let ordered_videos = all_videos.data.read().clone(); rsx! { div { class: "p-6", @@ -360,7 +361,7 @@ pub fn CourseView(course_id: String) -> Element { } // Course header - if let Some(ref c) = *course.read() { + if let Some(ref c) = *course.data.read() { div { class: "mb-4", if *edit_mode.read() { @@ -424,14 +425,14 @@ pub fn CourseView(course_id: String) -> Element { TagInput { on_create: on_create_tag } } - if !course_tags.read().is_empty() { + if !course_tags.data.read().is_empty() { div { class: "flex flex-wrap gap-2", - for tag in course_tags.read().iter() { + for tag in course_tags.data.read().iter() { { let tag_id = tag.id().clone(); let backend_clone = state.backend.clone(); let course_id_clone = course_id_parsed.clone(); - let mut course_tags_clone = course_tags; + let mut course_tags_clone = course_tags.clone(); let mut tag_status_clone = tag_status; rsx! { TagBadge { @@ -446,7 +447,7 @@ pub fn CourseView(course_id: String) -> Element { return; } if let Ok(updated) = ctx.tag_repo.find_by_course(cid) { - course_tags_clone.set(updated); + course_tags_clone.data.set(updated); } tag_status_clone.set(Some((true, "Tag removed.".to_string()))); } @@ -467,8 +468,8 @@ pub fn CourseView(course_id: String) -> Element { value: "{selected_tag_id}", oninput: move |e| selected_tag_id.set(e.value()), option { value: "", "Select a tag to add" } - for tag in all_tags.read().iter() { - if !course_tags.read().iter().any(|t| t.id() == tag.id()) { + for tag in all_tags.data.read().iter() { + if !course_tags.data.read().iter().any(|t| t.id() == tag.id()) { option { value: "{tag.id().as_uuid()}", "{tag.name()}" } } } @@ -515,16 +516,16 @@ pub fn CourseView(course_id: String) -> Element { // Modules accordion div { class: "space-y-4", - if modules.read().is_empty() { + if modules.data.read().is_empty() { div { class: "text-center py-8 bg-base-200 rounded-lg", p { class: "text-base-content/60", "No modules found" } } } else { - for module in modules.read().iter() { + for module in modules.data.read().iter() { ModuleAccordion { course_id: course_id.clone(), module: module.clone(), - all_modules: modules.read().clone(), + all_modules: modules.data.read().clone(), boundary_edit_mode: *boundary_edit_mode.read(), } } diff --git a/src/ui/pages/dashboard.rs b/src/ui/pages/dashboard.rs index 1690378..f6835d9 100644 --- a/src/ui/pages/dashboard.rs +++ b/src/ui/pages/dashboard.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; use crate::domain::entities::Course; -use crate::domain::ports::{TagRepository, VideoRepository}; +use crate::domain::ports::TagRepository; use crate::domain::value_objects::TagId; use crate::ui::Route; use crate::ui::actions::{ImportResult, import_local_folder, import_playlist}; @@ -12,7 +12,8 @@ use crate::ui::custom::{ TagBadge, TagFilterChip, }; use crate::ui::hooks::{ - use_load_courses_state, use_load_dashboard_analytics, use_load_modules, use_load_tags, + use_load_courses, use_load_dashboard_analytics, use_load_modules, use_load_tags, + use_load_videos_by_course, }; use crate::ui::state::AppState; @@ -30,9 +31,13 @@ pub fn Dashboard() -> Element { } // Load courses, tags, and analytics from backend - let (mut courses, courses_state) = use_load_courses_state(state.backend.clone()); - let all_tags = use_load_tags(state.backend.clone()); - let (analytics, analytics_state) = use_load_dashboard_analytics(state.backend.clone()); + let courses = use_load_courses(state.backend.clone()); + let courses_state = courses.state.clone(); + let mut courses = courses.data; + let all_tags = use_load_tags(state.backend.clone()).data; + let analytics = use_load_dashboard_analytics(state.backend.clone()); + let analytics_state = analytics.state.clone(); + let analytics = analytics.data; // Tabs let mut active_tab = use_signal(|| "overview".to_string()); @@ -379,21 +384,10 @@ fn CourseCardWithStats(course: Course) -> Element { let backend = state.backend.clone(); let modules = use_load_modules(backend.clone(), course.id()); - let mut all_videos = use_signal(Vec::new); + let videos = use_load_videos_by_course(backend.clone(), course.id()); - let course_id = course.id().clone(); - let backend_inner = backend.clone(); - - use_effect(move || { - if let Some(ref ctx) = backend_inner { - if let Ok(videos) = ctx.video_repo.find_by_course(&course_id) { - all_videos.set(videos); - } - } - }); - - let module_list = modules.read(); - let video_list = all_videos.read(); + let module_list = modules.data.read(); + let video_list = videos.data.read(); let module_count = module_list.len(); let completed_modules = if video_list.is_empty() { diff --git a/src/ui/pages/quiz_list.rs b/src/ui/pages/quiz_list.rs index 671c651..45b6592 100644 --- a/src/ui/pages/quiz_list.rs +++ b/src/ui/pages/quiz_list.rs @@ -5,7 +5,7 @@ use dioxus::prelude::*; use crate::domain::entities::Exam; use crate::ui::Route; use crate::ui::custom::{ErrorAlert, Spinner}; -use crate::ui::hooks::{use_load_all_exams_state, use_load_video}; +use crate::ui::hooks::{use_load_all_exams, use_load_video}; use crate::ui::state::AppState; /// List of pending and completed quizzes. @@ -21,23 +21,24 @@ pub fn QuizList() -> Element { }); } - let (exams, exams_state) = use_load_all_exams_state(state.backend.clone()); + let exams = use_load_all_exams(state.backend.clone()); + let exams_state = exams.state.clone(); rsx! { div { class: "p-6 max-w-4xl mx-auto", div { class: "flex justify-between items-center mb-8", h1 { class: "text-3xl font-bold", "My Quizzes" } - span { class: "badge badge-primary", "{exams.read().len()} Total" } + span { class: "badge badge-primary", "{exams.data.read().len()} Total" } } if let Some(ref err) = *exams_state.error.read() { ErrorAlert { message: err.clone(), on_dismiss: None } } - if *exams_state.is_loading.read() && exams.read().is_empty() { + if *exams_state.is_loading.read() && exams.data.read().is_empty() { Spinner { message: Some("Loading quizzes...".to_string()) } - } else if exams.read().is_empty() { + } else if exams.data.read().is_empty() { div { class: "text-center py-20 bg-base-200 rounded-3xl border-2 border-dashed border-base-300", div { class: "text-6xl mb-4", "๐Ÿ“" } h2 { class: "text-xl font-semibold mb-2", "No quizzes yet" } @@ -47,7 +48,7 @@ pub fn QuizList() -> Element { } } else { div { class: "grid gap-4", - for exam in exams.read().iter() { + for exam in exams.data.read().iter() { QuizItem { key: "{exam.id().as_uuid()}", exam: exam.clone() } } } @@ -62,7 +63,7 @@ fn QuizItem(exam: Exam) -> Element { let state = use_context::(); let video = use_load_video(state.backend.clone(), exam.video_id()); - let video_title = match video.read().as_ref() { + let video_title = match video.data.read().as_ref() { Some(v) => v.title().to_string(), None => "Video #".to_string() + &exam.video_id().as_uuid().to_string()[..8], }; diff --git a/src/ui/pages/quiz_view.rs b/src/ui/pages/quiz_view.rs index f4d055b..4223bbd 100644 --- a/src/ui/pages/quiz_view.rs +++ b/src/ui/pages/quiz_view.rs @@ -10,7 +10,7 @@ use crate::domain::value_objects::{ExamDifficulty, ExamId}; use crate::ui::Route; use crate::ui::actions::start_exam; use crate::ui::custom::{ErrorAlert, MarkdownRenderer, Spinner}; -use crate::ui::hooks::{use_load_exam_state, use_load_video}; +use crate::ui::hooks::{use_load_exam, use_load_video}; use crate::ui::state::AppState; /// Quiz with multiple choice questions. @@ -37,10 +37,11 @@ pub fn QuizView(exam_id: String) -> Element { }, }; - let (mut exam, exam_state) = use_load_exam_state(backend.clone(), &exam_id_vo); + let mut exam = use_load_exam(backend.clone(), &exam_id_vo); + let exam_state = exam.state.clone(); let video = use_load_video( backend.clone(), - &exam.read().as_ref().map(|e| e.video_id().clone()).unwrap_or_default(), + &exam.data.read().as_ref().map(|e| e.video_id().clone()).unwrap_or_default(), ); // UI State @@ -59,7 +60,7 @@ pub fn QuizView(exam_id: String) -> Element { let Some(ctx) = backend.as_ref() else { return; }; - let video_ref = video.read(); + let video_ref = video.data.read(); let Some(video) = video_ref.as_ref() else { return; }; @@ -71,7 +72,7 @@ pub fn QuizView(exam_id: String) -> Element { // Sync answers from database if already taken use_effect(move || { - if let Some(e) = exam.read().as_ref() { + if let Some(e) = exam.data.read().as_ref() { if e.is_taken() && answers.read().is_empty() { if let Some(json) = e.user_answers_json() { if let Ok(loaded) = serde_json::from_str::>(json) { @@ -82,7 +83,7 @@ pub fn QuizView(exam_id: String) -> Element { } }); - if *exam_state.is_loading.read() && exam.read().is_none() { + if *exam_state.is_loading.read() && exam.data.read().is_none() { return rsx! { div { class: "p-6", Spinner { message: Some("Loading exam...".to_string()) } @@ -98,7 +99,7 @@ pub fn QuizView(exam_id: String) -> Element { }; } - let exam_data = exam.read(); + let exam_data = exam.data.read(); let exam_ref = match exam_data.as_ref() { Some(e) => e, None => { @@ -181,7 +182,7 @@ pub fn QuizView(exam_id: String) -> Element { class: "btn btn-ghost btn-lg", onclick: move |_| { let course_id = course_id.clone(); - if let Some(v) = video.read().as_ref() { + if let Some(v) = video.data.read().as_ref() { nav.push(Route::VideoPlayer { course_id, video_id: v.id().as_uuid().to_string(), @@ -204,7 +205,7 @@ pub fn QuizView(exam_id: String) -> Element { div { class: "p-6 max-w-3xl mx-auto", div { class: "flex items-center justify-between mb-8", h1 { class: "text-2xl font-bold", - "Review: {video.read().as_ref().map(|v| v.title()).unwrap_or(\"...\")}" + "Review: {video.data.read().as_ref().map(|v| v.title()).unwrap_or(\"...\")}" } button { class: "btn btn-sm btn-ghost", @@ -280,7 +281,7 @@ pub fn QuizView(exam_id: String) -> Element { class: "btn btn-primary", onclick: move |_| { if let Some(ctx) = backend.as_ref() { - if let Some(v) = video.read().as_ref() { + if let Some(v) = video.data.read().as_ref() { let _ = ctx.video_repo.update_completion(v.id(), true); if let Ok(Some(module)) = ctx.module_repo.find_by_id(v.module_id()) { nav.push(Route::VideoPlayer { @@ -333,7 +334,7 @@ pub fn QuizView(exam_id: String) -> Element { // Reload exam from DB to update UI with results if let Ok(Some(updated_exam)) = ctx.exam_repo.find_by_id(&exam_id_inner) { - exam.set(Some(updated_exam)); + exam.data.set(Some(updated_exam)); } } } @@ -347,7 +348,7 @@ pub fn QuizView(exam_id: String) -> Element { div { class: "p-6 max-w-2xl mx-auto", // Header h1 { class: "text-2xl font-bold mb-2", - "Exam: {video.read().as_ref().map(|v| v.title()).unwrap_or(\"...\")}" + "Exam: {video.data.read().as_ref().map(|v| v.title()).unwrap_or(\"...\")}" } // Progress diff --git a/src/ui/pages/video_player.rs b/src/ui/pages/video_player.rs index e86c246..3956fbc 100644 --- a/src/ui/pages/video_player.rs +++ b/src/ui/pages/video_player.rs @@ -12,9 +12,7 @@ use crate::ui::actions::{import_subtitle_for_video, start_exam}; use crate::ui::custom::{ ErrorAlert, LocalVideoPlayer, MarkdownRenderer, Spinner, SuccessAlert, YouTubePlayer, }; -use crate::ui::hooks::{ - use_load_modules_state, use_load_video_state, use_load_videos_by_course_state, -}; +use crate::ui::hooks::{use_load_modules, use_load_video, use_load_videos_by_course}; use crate::ui::state::AppState; /// Video player with controls and completion actions. @@ -66,10 +64,12 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { }; // Load data - let (video, video_state) = use_load_video_state(backend.clone(), &video_id_vo); - let (modules, modules_state) = use_load_modules_state(backend.clone(), &course_id_vo); - let (all_videos, videos_state) = - use_load_videos_by_course_state(backend.clone(), &course_id_vo); + let video = use_load_video(backend.clone(), &video_id_vo); + let video_state = video.state.clone(); + let modules = use_load_modules(backend.clone(), &course_id_vo); + let modules_state = modules.state.clone(); + let all_videos = use_load_videos_by_course(backend.clone(), &course_id_vo); + let videos_state = all_videos.state.clone(); // Track current video in global state for AI companion context let video_id_for_state = video_id.clone(); @@ -83,7 +83,7 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { }); // Extract video data reactively - let video_read = video.read(); + let video_read = video.data.read(); let v = match video_read.as_ref() { Some(v) => v.clone(), None => { @@ -104,6 +104,7 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { // Find current module name let module_title = modules + .data .read() .iter() .find(|m| m.id() == v.module_id()) @@ -111,7 +112,7 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { .unwrap_or_else(|| "Module".to_string()); // Compute prev/next videos - let videos_list = all_videos.read(); + let videos_list = all_videos.data.read(); let current_idx = videos_list.iter().position(|vid| vid.id() == v.id()); let prev_video = @@ -124,14 +125,14 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { let backend_for_quiz = backend.clone(); let video_id_for_complete = v.id().clone(); let video_id_for_quiz = v.id().clone(); - let is_completed_now = video.read().as_ref().map(|v| v.is_completed()).unwrap_or(false); + let is_completed_now = video.data.read().as_ref().map(|v| v.is_completed()).unwrap_or(false); let is_local_video = use_signal(|| false); let has_transcript = use_signal(|| false); { let mut is_local_video = is_local_video; let mut has_transcript = has_transcript; use_effect(move || { - let value = video.read(); + let value = video.data.read(); let (local, transcript) = value .as_ref() .map(|video| { @@ -152,11 +153,11 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { // Handlers let mut action_status_complete = action_status; - let mut video_for_complete = video; + let mut video_for_complete = video.clone(); let on_mark_complete = move |_| { if let Some(ctx) = backend_for_complete.as_ref() { let current_completed = - video_for_complete.read().as_ref().map(|v| v.is_completed()).unwrap_or(false); + video_for_complete.data.read().as_ref().map(|v| v.is_completed()).unwrap_or(false); let new_status = !current_completed; if let Err(e) = ctx.video_repo.update_completion(&video_id_for_complete, new_status) { log::error!("Failed to update completion: {}", e); @@ -166,7 +167,7 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { } if let Ok(Some(updated)) = ctx.video_repo.find_by_id(&video_id_for_complete) { - video_for_complete.set(Some(updated)); + video_for_complete.data.set(Some(updated)); } let message = if new_status { "Marked as completed." } else { "Marked as incomplete." }; @@ -229,11 +230,11 @@ pub fn VideoPlayer(course_id: String, video_id: String) -> Element { let on_transcript_update = { let backend = backend.clone(); let video_id_for_refresh = v.id().clone(); - let mut video_for_refresh = video; + let mut video_for_refresh = video.clone(); move |_| { if let Some(ctx) = backend.as_ref() { if let Ok(Some(updated)) = ctx.video_repo.find_by_id(&video_id_for_refresh) { - video_for_refresh.set(Some(updated)); + video_for_refresh.data.set(Some(updated)); } } }