
Complete church management system with bulletin management, media processing, live streaming integration, and web interface. Includes authentication, email notifications, database migrations, and comprehensive test suite.
757 lines
39 KiB
Rust
757 lines
39 KiB
Rust
use axum::{extract::{Path, Query}, response::Html};
|
|
use crate::layout::layout;
|
|
use crate::services::{ApiService, parse_sermon_title, format_duration, format_date};
|
|
use serde::Deserialize;
|
|
use std::collections::HashMap;
|
|
use chrono::Datelike;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ArchiveQuery {
|
|
collection: Option<String>,
|
|
}
|
|
|
|
pub async fn sermons_handler() -> Html<String> {
|
|
let api_service = ApiService::new();
|
|
|
|
match api_service.get_jellyfin_libraries().await {
|
|
Ok(libraries) => {
|
|
if libraries.is_empty() {
|
|
return render_no_sermons_page();
|
|
}
|
|
|
|
let mut collection_data = std::collections::HashMap::new();
|
|
|
|
for library in &libraries {
|
|
match api_service.get_jellyfin_sermons(Some(&library.id), Some(6)).await {
|
|
Ok(mut sermons) => {
|
|
// Sort sermons by date
|
|
sermons.sort_by(|a, b| {
|
|
let get_valid_date = |sermon: &crate::models::JellyfinItem| {
|
|
let parsed = parse_sermon_title(&sermon.name);
|
|
if let Some(ref date_str) = parsed.date_from_title {
|
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
|
return date;
|
|
}
|
|
}
|
|
|
|
if let Some(ref premiere_date) = sermon.premiere_date {
|
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(&premiere_date.split('T').next().unwrap_or(""), "%Y-%m-%d") {
|
|
return date;
|
|
}
|
|
}
|
|
|
|
chrono::Utc::now().naive_utc().date()
|
|
};
|
|
|
|
let date_a = get_valid_date(a);
|
|
let date_b = get_valid_date(b);
|
|
date_b.cmp(&date_a)
|
|
});
|
|
|
|
collection_data.insert(library.name.clone(), sermons);
|
|
},
|
|
Err(_) => {
|
|
collection_data.insert(library.name.clone(), vec![]);
|
|
}
|
|
}
|
|
}
|
|
|
|
Html(layout(&render_sermons_content(collection_data), "Latest Sermons & Live Streams"))
|
|
},
|
|
Err(_) => render_no_sermons_page()
|
|
}
|
|
}
|
|
|
|
fn render_no_sermons_page() -> Html<String> {
|
|
let content = r#"
|
|
<!-- Sermons Hero -->
|
|
<section class="section-2025" style="background: var(--gradient-primary); color: white; padding: 6rem 0 4rem 0;">
|
|
<div class="container-2025">
|
|
<div class="section-header scroll-reveal">
|
|
<h1 class="section-title serif" style="color: white;">Latest Sermons & Live Streams</h1>
|
|
<p class="section-subtitle" style="color: rgba(255,255,255,0.9);">Listen to our most recent inspiring messages from God's Word</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section-2025">
|
|
<div class="container-2025">
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; padding: 3rem;">
|
|
<div class="card-icon-2025" style="margin: 0 auto 2rem;">
|
|
<i class="fas fa-microphone-alt"></i>
|
|
</div>
|
|
<h3 class="card-title">Sermons Coming Soon</h3>
|
|
<p class="card-text">Sermons are currently being prepared for online streaming.</p>
|
|
<p class="card-text">Please check back later or contact us for more information.</p>
|
|
<p style="color: var(--medium-gray); font-style: italic; margin-top: 1rem;"><em>Note: Make sure Jellyfin server credentials are configured properly.</em></p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
"#;
|
|
|
|
Html(layout(content, "Sermons"))
|
|
}
|
|
|
|
fn render_sermons_content(collection_data: std::collections::HashMap<String, Vec<crate::models::JellyfinItem>>) -> String {
|
|
format!(r#"
|
|
<!-- Sermons Hero -->
|
|
<section class="section-2025" style="background: var(--gradient-primary); color: white; padding: 6rem 0 4rem 0;">
|
|
<div class="container-2025">
|
|
<div class="section-header scroll-reveal">
|
|
<h1 class="section-title serif" style="color: white;">Latest Sermons & Live Streams</h1>
|
|
<p class="section-subtitle" style="color: rgba(255,255,255,0.9);">Listen to our most recent inspiring messages from God's Word. These are the latest sermons and live stream recordings for your spiritual growth.</p>
|
|
<div style="text-align: center; margin-top: 2rem;">
|
|
<a href="/sermons/archive" class="btn-2025 btn-outline" style="color: white; border-color: rgba(255,255,255,0.5); background: rgba(255,255,255,0.1); backdrop-filter: blur(10px); font-size: 1.1rem; padding: 1rem 2rem;">
|
|
<i class="fas fa-archive" style="margin-right: 0.5rem;"></i>
|
|
Browse Complete Archive
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{}
|
|
|
|
<section class="section-2025" style="background: var(--soft-gray);">
|
|
<div class="container-2025">
|
|
<div class="section-header scroll-reveal">
|
|
<h2 class="section-title">About Our Sermons</h2>
|
|
<p class="section-subtitle">Our sermons focus on the Three Angels' Messages and the teachings of Jesus Christ. Each message is designed to strengthen your faith and deepen your understanding of God's Word.</p>
|
|
</div>
|
|
|
|
<div class="cards-grid">
|
|
<div class="card-2025 scroll-reveal stagger-1">
|
|
<div class="card-icon-2025">
|
|
<i class="fas fa-calendar-week"></i>
|
|
</div>
|
|
<h3 class="card-title">Sabbath Sermons</h3>
|
|
<p class="card-text">Weekly messages during Divine Worship</p>
|
|
</div>
|
|
|
|
<div class="card-2025 scroll-reveal stagger-2">
|
|
<div class="card-icon-2025">
|
|
<i class="fas fa-scroll"></i>
|
|
</div>
|
|
<h3 class="card-title">Prophecy Studies</h3>
|
|
<p class="card-text">Deep dives into Biblical prophecy</p>
|
|
</div>
|
|
|
|
<div class="card-2025 scroll-reveal stagger-3">
|
|
<div class="card-icon-2025">
|
|
<i class="fas fa-heart"></i>
|
|
</div>
|
|
<h3 class="card-title">Practical Christianity</h3>
|
|
<p class="card-text">Applying Bible principles to daily life</p>
|
|
</div>
|
|
|
|
<div class="card-2025 scroll-reveal stagger-1">
|
|
<div class="card-icon-2025">
|
|
<i class="fas fa-star"></i>
|
|
</div>
|
|
<h3 class="card-title">Special Events</h3>
|
|
<p class="card-text">Revival meetings and guest speakers</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
"#,
|
|
collection_data.iter().enumerate().map(|(collection_index, (collection_name, sermons))| {
|
|
format!(r#"
|
|
<section class="section-2025" style="{}">
|
|
<div class="container-2025">
|
|
<div class="section-header scroll-reveal">
|
|
<h2 class="section-title" style="display: flex; align-items: center; gap: 1rem;">
|
|
<i class="fas fa-{}" style="color: var(--soft-gold);"></i>
|
|
{}
|
|
</h2>
|
|
<p class="section-subtitle">
|
|
{}
|
|
</p>
|
|
<div style="text-align: center; margin-top: 2rem;">
|
|
<a href="/sermons/archive?collection={}" class="btn-2025 btn-outline" style="color: var(--deep-navy); border-color: var(--deep-navy);">
|
|
<i class="fas fa-archive" style="margin-right: 0.5rem;"></i>
|
|
View All {}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{}
|
|
</div>
|
|
</section>
|
|
"#,
|
|
if collection_index > 0 { "background: var(--soft-gray);" } else { "" },
|
|
if collection_name == "LiveStreams" { "broadcast-tower" } else { "church" },
|
|
if collection_name == "LiveStreams" { "Live Stream Recordings" } else { "Sabbath Sermons" },
|
|
if collection_name == "LiveStreams" {
|
|
"Recorded live streams from our worship services and special events"
|
|
} else {
|
|
"Messages from our regular Sabbath worship services"
|
|
},
|
|
collection_name,
|
|
if collection_name == "LiveStreams" { "Live Streams" } else { "Sermons" },
|
|
if sermons.is_empty() {
|
|
format!(r#"
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; padding: 2rem;">
|
|
<h4 style="color: var(--deep-navy); margin-bottom: 1rem;">No {} Available</h4>
|
|
<p style="color: var(--medium-gray);">Check back later for new content in this collection.</p>
|
|
</div>
|
|
"#, collection_name)
|
|
} else {
|
|
format!(r#"
|
|
<div class="cards-grid">
|
|
{}
|
|
</div>
|
|
"#, sermons.iter().enumerate().map(|(index, sermon)| {
|
|
let parsed = parse_sermon_title(&sermon.name);
|
|
format!(r#"
|
|
<div class="card-2025 scroll-reveal stagger-{}">
|
|
<div class="card-icon-2025">
|
|
<i class="fas fa-{}"></i>
|
|
</div>
|
|
<h3 class="card-title">{}</h3>
|
|
{}
|
|
|
|
{}
|
|
|
|
{}
|
|
|
|
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;">
|
|
{}
|
|
<span style="background: var(--soft-gold); color: var(--deep-navy); padding: 0.5rem 1rem; border-radius: 20px; font-size: 0.9rem; font-weight: 500;">
|
|
<i class="fas fa-{}" style="margin-right: 0.25rem;"></i>
|
|
{}
|
|
</span>
|
|
</div>
|
|
|
|
<a href="/sermons/{}" class="btn-2025 btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
<i class="fas fa-{}"></i>
|
|
{} {}
|
|
</a>
|
|
</div>
|
|
"#,
|
|
(index % 3) + 1,
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "music" } else { "video" },
|
|
parsed.title,
|
|
if let Some(ref speaker) = parsed.speaker {
|
|
format!(r#"
|
|
<p style="color: var(--soft-gold); margin-bottom: 1rem; font-weight: 500;">
|
|
<i class="fas fa-user" style="margin-right: 0.5rem;"></i>{}
|
|
</p>
|
|
"#, speaker)
|
|
} else {
|
|
String::new()
|
|
},
|
|
if let Some(ref premiere_date) = sermon.premiere_date {
|
|
format!(r#"
|
|
<p style="color: var(--medium-gray); margin-bottom: 1rem;">
|
|
<i class="fas fa-calendar" style="color: var(--soft-gold); margin-right: 0.5rem;"></i>
|
|
{}
|
|
</p>
|
|
"#, format_date(premiere_date))
|
|
} else if let Some(ref date_from_title) = parsed.date_from_title {
|
|
format!(r#"
|
|
<p style="color: var(--medium-gray); margin-bottom: 1rem;">
|
|
<i class="fas fa-calendar" style="color: var(--soft-gold); margin-right: 0.5rem;"></i>
|
|
{}
|
|
</p>
|
|
"#, date_from_title)
|
|
} else {
|
|
String::new()
|
|
},
|
|
if let Some(ref overview) = sermon.overview {
|
|
let preview = if overview.len() > 150 {
|
|
format!("{}...", &overview[..150])
|
|
} else {
|
|
overview.clone()
|
|
};
|
|
format!(r#"
|
|
<p style="color: var(--medium-gray); margin-bottom: 1.5rem; line-height: 1.5;">
|
|
{}
|
|
</p>
|
|
"#, preview)
|
|
} else {
|
|
String::new()
|
|
},
|
|
if let Some(ticks) = sermon.run_time_ticks {
|
|
format!(r#"
|
|
<span style="background: var(--soft-gray); color: var(--deep-navy); padding: 0.5rem 1rem; border-radius: 20px; font-size: 0.9rem; font-weight: 500;">
|
|
<i class="fas fa-clock" style="margin-right: 0.25rem;"></i>
|
|
{}
|
|
</span>
|
|
"#, format_duration(ticks))
|
|
} else {
|
|
String::new()
|
|
},
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "music" } else { "video" },
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "Audio" } else { "Video" },
|
|
sermon.id,
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "play" } else { "play-circle" },
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "Listen" } else { "Watch" },
|
|
if collection_name == "LiveStreams" { "Recording" } else { "Sermon" }
|
|
)
|
|
}).collect::<Vec<_>>().join(""))
|
|
})
|
|
}).collect::<Vec<_>>().join(""))
|
|
}
|
|
|
|
pub async fn sermon_detail_handler(Path(id): Path<String>) -> Html<String> {
|
|
let api_service = ApiService::new();
|
|
|
|
match api_service.get_jellyfin_sermon(&id).await {
|
|
Ok(Some(sermon)) => {
|
|
match api_service.authenticate_jellyfin().await {
|
|
Ok(Some((token, _))) => {
|
|
let parsed = parse_sermon_title(&sermon.name);
|
|
let stream_url = api_service.get_jellyfin_stream_url(&sermon.id, &token);
|
|
|
|
let content = format!(r#"
|
|
<!-- Sermon Detail Hero -->
|
|
<section class="section-2025" style="background: var(--gradient-primary); color: white; padding: 4rem 0 3rem 0;">
|
|
<div class="container-2025">
|
|
<a href="/sermons" style="color: rgba(255,255,255,0.8); text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-weight: 500; margin-top: 1rem;">
|
|
<i class="fas fa-arrow-left"></i> Back to Sermons
|
|
</a>
|
|
<h1 class="section-title serif" style="color: white; font-size: clamp(2.5rem, 5vw, 4rem); margin-bottom: 1rem;">{}</h1>
|
|
{}
|
|
|
|
<div style="display: flex; gap: 2rem; flex-wrap: wrap; color: rgba(255,255,255,0.9);">
|
|
{}
|
|
|
|
{}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Sermon Player -->
|
|
<section class="section-2025">
|
|
<div class="container-2025">
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; max-width: 900px; margin: 0 auto;">
|
|
<div class="card-icon-2025" style="margin: 0 auto 2rem;">
|
|
<i class="fas fa-{}"></i>
|
|
</div>
|
|
<h3 class="card-title">{}</h3>
|
|
|
|
{}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{}
|
|
|
|
<!-- Share Section -->
|
|
<section class="section-2025">
|
|
<div class="container-2025">
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; max-width: 600px; margin: 0 auto;">
|
|
<div class="card-icon-2025" style="margin: 0 auto 2rem;">
|
|
<i class="fas fa-share-alt"></i>
|
|
</div>
|
|
<h3 class="card-title">Share This Sermon</h3>
|
|
<p class="card-text">Invite others to listen to this inspiring message:</p>
|
|
<button onclick="copyToClipboard(window.location.href)" class="btn-2025 btn-primary" style="margin-top: 1rem;">
|
|
<i class="fas fa-copy"></i>
|
|
Copy Link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<script>
|
|
function copyToClipboard(text) {{
|
|
navigator.clipboard.writeText(text).then(function() {{
|
|
alert('Link copied to clipboard!');
|
|
}});
|
|
}}
|
|
</script>
|
|
"#,
|
|
parsed.title,
|
|
if let Some(ref speaker) = parsed.speaker {
|
|
format!(r#"
|
|
<p style="color: var(--soft-gold); font-size: 1.3rem; margin-bottom: 1rem; font-weight: 500;">
|
|
<i class="fas fa-user" style="margin-right: 0.5rem;"></i>Speaker: {}
|
|
</p>
|
|
"#, speaker)
|
|
} else {
|
|
String::new()
|
|
},
|
|
if let Some(ref premiere_date) = sermon.premiere_date {
|
|
format!(r#"
|
|
<p style="display: flex; align-items: center; gap: 0.5rem; font-size: 1.1rem;">
|
|
<i class="fas fa-calendar-alt" style="color: var(--soft-gold);"></i>
|
|
{}
|
|
</p>
|
|
"#, format_date(premiere_date))
|
|
} else if let Some(ref date_from_title) = parsed.date_from_title {
|
|
format!(r#"
|
|
<p style="display: flex; align-items: center; gap: 0.5rem; font-size: 1.1rem;">
|
|
<i class="fas fa-calendar-alt" style="color: var(--soft-gold);"></i>
|
|
{}
|
|
</p>
|
|
"#, date_from_title)
|
|
} else {
|
|
String::new()
|
|
},
|
|
if let Some(ticks) = sermon.run_time_ticks {
|
|
format!(r#"
|
|
<p style="display: flex; align-items: center; gap: 0.5rem; font-size: 1.1rem;">
|
|
<i class="fas fa-clock" style="color: var(--soft-gold);"></i>
|
|
{}
|
|
</p>
|
|
"#, format_duration(ticks))
|
|
} else {
|
|
String::new()
|
|
},
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "music" } else { "video" },
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" { "Audio Sermon" } else { "Video Sermon" },
|
|
if sermon.media_type.as_ref().unwrap_or(&"Audio".to_string()) == "Audio" {
|
|
format!(r#"
|
|
<audio src="{}" controls preload="auto" style="width: 100%; max-width: 600px; margin: 2rem 0; border-radius: 8px;" crossorigin="anonymous">
|
|
Your browser does not support the audio element.
|
|
</audio>
|
|
"#, stream_url)
|
|
} else {
|
|
format!(r#"
|
|
<video src="{}" controls playsinline webkit-playsinline preload="auto" x-webkit-airplay="allow" crossorigin="anonymous" style="width: 100%; max-width: 800px; margin: 2rem 0; border-radius: 8px;">
|
|
Your browser does not support the video element.
|
|
</video>
|
|
"#, stream_url)
|
|
},
|
|
if let Some(ref overview) = sermon.overview {
|
|
format!(r#"
|
|
<section class="section-2025" style="background: var(--soft-gray);">
|
|
<div class="container-2025">
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; max-width: 800px; margin: 0 auto;">
|
|
<div class="card-icon-2025" style="margin: 0 auto 2rem;">
|
|
<i class="fas fa-book-open"></i>
|
|
</div>
|
|
<h3 class="card-title">Description</h3>
|
|
<p style="color: var(--medium-gray); line-height: 1.6; font-size: 1.1rem;">{}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
"#, overview)
|
|
} else {
|
|
String::new()
|
|
}
|
|
);
|
|
|
|
Html(layout(&content, &parsed.title))
|
|
},
|
|
_ => render_sermon_error("Unable to access sermon content. Please try again later.")
|
|
}
|
|
},
|
|
Ok(None) => render_sermon_error("The requested sermon could not be found."),
|
|
Err(_) => render_sermon_error("Unable to load sermon. Please try again later.")
|
|
}
|
|
}
|
|
|
|
fn render_sermon_error(message: &str) -> Html<String> {
|
|
let content = format!(r#"
|
|
<section class="section-2025">
|
|
<div class="container-2025">
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; padding: 3rem;">
|
|
<h1>Error</h1>
|
|
<p>{}</p>
|
|
<a href="/sermons" class="btn-2025 btn-primary">← Back to Sermons</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
"#, message);
|
|
|
|
Html(layout(&content, "Error"))
|
|
}
|
|
|
|
pub async fn sermons_archive_handler(Query(params): Query<ArchiveQuery>) -> Html<String> {
|
|
let api_service = ApiService::new();
|
|
|
|
match api_service.get_jellyfin_libraries().await {
|
|
Ok(libraries) => {
|
|
// If a specific collection is selected, show only that one
|
|
if let Some(selected_collection) = params.collection {
|
|
if let Some(library) = libraries.iter().find(|lib| lib.name == selected_collection) {
|
|
match api_service.get_jellyfin_sermons(Some(&library.id), None).await {
|
|
Ok(sermons) => {
|
|
let organized = organize_sermons_by_year_month(&sermons);
|
|
let mut years: Vec<String> = organized.keys().cloned().collect();
|
|
years.sort_by(|a, b| b.parse::<i32>().unwrap_or(0).cmp(&a.parse::<i32>().unwrap_or(0)));
|
|
|
|
return render_archive_page(&organized, &years, &selected_collection, &libraries);
|
|
}
|
|
Err(_) => return render_error_page()
|
|
}
|
|
} else {
|
|
return render_error_page();
|
|
}
|
|
}
|
|
|
|
// Default to showing Sermons collection (skip multi-collection view)
|
|
let default_collection = libraries.iter()
|
|
.find(|lib| lib.name == "Sermons")
|
|
.or_else(|| libraries.first());
|
|
|
|
if let Some(library) = default_collection {
|
|
match api_service.get_jellyfin_sermons(Some(&library.id), None).await {
|
|
Ok(sermons) => {
|
|
let organized = organize_sermons_by_year_month(&sermons);
|
|
let mut years: Vec<String> = organized.keys().cloned().collect();
|
|
years.sort_by(|a, b| b.parse::<i32>().unwrap_or(0).cmp(&a.parse::<i32>().unwrap_or(0)));
|
|
|
|
return render_archive_page(&organized, &years, &library.name, &libraries);
|
|
}
|
|
Err(_) => return render_error_page()
|
|
}
|
|
} else {
|
|
return render_error_page();
|
|
}
|
|
}
|
|
Err(_) => return render_error_page()
|
|
}
|
|
}
|
|
|
|
fn organize_sermons_by_year_month(sermons: &[crate::models::JellyfinItem]) -> HashMap<String, HashMap<String, Vec<&crate::models::JellyfinItem>>> {
|
|
let mut organized: HashMap<String, HashMap<String, Vec<&crate::models::JellyfinItem>>> = HashMap::new();
|
|
|
|
for sermon in sermons {
|
|
let parsed = parse_sermon_title(&sermon.name);
|
|
|
|
let date = if let Some(ref date_str) = parsed.date_from_title {
|
|
// Try parsing the date from title using multiple formats
|
|
chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
|
.or_else(|_| chrono::NaiveDate::parse_from_str(date_str, "%m/%d/%Y"))
|
|
.or_else(|_| chrono::NaiveDate::parse_from_str(date_str, "%m-%d-%Y"))
|
|
.unwrap_or_else(|_| {
|
|
// If date parsing fails, use premiere date or created date as fallback
|
|
if let Some(ref premiere_date) = sermon.premiere_date {
|
|
chrono::NaiveDate::parse_from_str(&premiere_date[..10], "%Y-%m-%d")
|
|
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date())
|
|
} else if let Some(ref created_date) = sermon.date_created {
|
|
chrono::NaiveDate::parse_from_str(&created_date[..10], "%Y-%m-%d")
|
|
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date())
|
|
} else {
|
|
chrono::Utc::now().naive_utc().date()
|
|
}
|
|
})
|
|
} else if let Some(ref premiere_date) = sermon.premiere_date {
|
|
chrono::NaiveDate::parse_from_str(&premiere_date[..10], "%Y-%m-%d")
|
|
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date())
|
|
} else if let Some(ref created_date) = sermon.date_created {
|
|
chrono::NaiveDate::parse_from_str(&created_date[..10], "%Y-%m-%d")
|
|
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date())
|
|
} else {
|
|
chrono::Utc::now().naive_utc().date()
|
|
};
|
|
|
|
let year = date.year().to_string();
|
|
let month = date.format("%B").to_string();
|
|
|
|
organized
|
|
.entry(year)
|
|
.or_insert_with(HashMap::new)
|
|
.entry(month)
|
|
.or_insert_with(Vec::new)
|
|
.push(sermon);
|
|
}
|
|
|
|
organized
|
|
}
|
|
|
|
|
|
fn render_archive_page(
|
|
organized: &HashMap<String, HashMap<String, Vec<&crate::models::JellyfinItem>>>,
|
|
years: &[String],
|
|
selected_collection: &str,
|
|
libraries: &[crate::models::JellyfinLibrary]
|
|
) -> Html<String> {
|
|
let collection_display_name = if selected_collection == "LiveStreams" {
|
|
"Live Stream Recordings"
|
|
} else {
|
|
"Sabbath Sermons"
|
|
};
|
|
|
|
let content = format!(r#"
|
|
<!-- Archive Hero -->
|
|
<section class="section-2025" style="background: var(--gradient-primary); color: white; padding: 6rem 0 4rem 0;">
|
|
<div class="container-2025">
|
|
<div class="section-header scroll-reveal">
|
|
<a href="/sermons" style="color: rgba(255,255,255,0.8); text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-weight: 500;">
|
|
<i class="fas fa-arrow-left"></i> Back to Latest Sermons
|
|
</a>
|
|
<h1 class="section-title serif" style="color: white;">{} Archive</h1>
|
|
<p class="section-subtitle" style="color: rgba(255,255,255,0.9);">Browse the complete collection organized by year and month.</p>
|
|
|
|
<!-- Collection Navigation -->
|
|
<div style="display: flex; gap: 1rem; margin-top: 2rem; flex-wrap: wrap; justify-content: center;">
|
|
{}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section-2025">
|
|
<div class="container-2025">
|
|
{}
|
|
</div>
|
|
</section>
|
|
|
|
<script>
|
|
function toggleYear(yearId) {{
|
|
const content = document.getElementById(yearId);
|
|
const chevron = document.getElementById('chevron-' + yearId);
|
|
|
|
if (content.style.display === 'none' || content.style.display === '') {{
|
|
content.style.display = 'block';
|
|
chevron.style.transform = 'rotate(180deg)';
|
|
}} else {{
|
|
content.style.display = 'none';
|
|
chevron.style.transform = 'rotate(0deg)';
|
|
}}
|
|
}}
|
|
|
|
function toggleMonth(monthId) {{
|
|
const content = document.getElementById(monthId);
|
|
const chevron = document.getElementById('chevron-' + monthId);
|
|
|
|
if (content.style.display === 'none' || content.style.display === '') {{
|
|
content.style.display = 'block';
|
|
chevron.style.transform = 'rotate(180deg)';
|
|
}} else {{
|
|
content.style.display = 'none';
|
|
chevron.style.transform = 'rotate(0deg)';
|
|
}}
|
|
}}
|
|
|
|
// Auto-expand current year
|
|
const currentYear = new Date().getFullYear();
|
|
const currentYearElement = document.getElementById('year-' + currentYear);
|
|
if (currentYearElement) {{
|
|
toggleYear('year-' + currentYear);
|
|
}}
|
|
</script>
|
|
"#,
|
|
collection_display_name,
|
|
libraries.iter().map(|lib| {
|
|
let display_name = if lib.name == "LiveStreams" { "Live Streams" } else { "Sermons" };
|
|
let icon = if lib.name == "LiveStreams" { "broadcast-tower" } else { "church" };
|
|
let active_class = if lib.name == selected_collection { " active" } else { "" };
|
|
|
|
format!(r#"<a href="/sermons/archive?collection={}" class="btn-2025{}" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
<i class="fas fa-{}"></i> {}
|
|
</a>"#, lib.name, if active_class.is_empty() { " btn-outline" } else { " btn-primary" }, icon, display_name)
|
|
}).collect::<Vec<_>>().join(""),
|
|
if years.is_empty() {
|
|
format!(r#"<div class="card-2025 scroll-reveal" style="text-align: center; padding: 3rem;">
|
|
<div class="card-icon-2025" style="margin: 0 auto 2rem;">
|
|
<i class="fas fa-archive"></i>
|
|
</div>
|
|
<h3 class="card-title">No {} Found</h3>
|
|
<p class="card-text">This collection doesn't contain any items yet. Please check back later.</p>
|
|
</div>"#, collection_display_name)
|
|
} else {
|
|
years.iter().map(|year| {
|
|
let year_data = organized.get(year).unwrap();
|
|
let mut months: Vec<&String> = year_data.keys().collect();
|
|
months.sort_by(|a, b| {
|
|
let month_a = chrono::NaiveDate::parse_from_str(&format!("{} 1, 2020", a), "%B %d, %Y").unwrap().month();
|
|
let month_b = chrono::NaiveDate::parse_from_str(&format!("{} 1, 2020", b), "%B %d, %Y").unwrap().month();
|
|
month_b.cmp(&month_a)
|
|
});
|
|
|
|
let total_items: usize = year_data.values().map(|sermons| sermons.len()).sum();
|
|
|
|
format!(r#"
|
|
<div class="year-section scroll-reveal" style="margin-bottom: 3rem; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
|
<div class="year-header" style="background: var(--deep-navy); color: white; padding: 1.5rem 2rem; display: flex; align-items: center; justify-content: space-between; cursor: pointer;" onclick="toggleYear('year-{}')">
|
|
<h2 style="margin: 0; font-family: 'Playfair Display', serif; font-size: 2rem; color: white;">
|
|
<i class="fas fa-calendar-alt" style="margin-right: 1rem; color: var(--soft-gold);"></i>{}
|
|
</h2>
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<span style="background: var(--soft-gold); color: var(--deep-navy); padding: 0.5rem 1rem; border-radius: 20px; font-weight: 600;">{} items</span>
|
|
<i class="fas fa-chevron-down" id="chevron-year-{}" style="color: var(--soft-gold); font-size: 1.2rem; transition: transform 0.3s ease;"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="year-content" id="year-{}" style="background: white; border: 1px solid #e5e7eb; border-top: none; display: none;">
|
|
{}
|
|
</div>
|
|
</div>
|
|
"#, year, year, total_items, year, year,
|
|
months.iter().map(|month| {
|
|
let month_sermons = year_data.get(*month).unwrap();
|
|
let month_id = format!("{}-{}", year, month.replace(" ", ""));
|
|
|
|
format!(r#"
|
|
<div class="month-section" style="border-bottom: 1px solid #f3f4f6;">
|
|
<div class="month-header" style="background: var(--soft-gray); padding: 1.25rem 2rem; display: flex; align-items: center; justify-content: space-between; cursor: pointer;" onclick="toggleMonth('month-{}')">
|
|
<h3 style="margin: 0; color: var(--deep-navy); font-size: 1.25rem; font-weight: 600;">
|
|
<i class="fas fa-folder" style="margin-right: 0.75rem; color: var(--soft-gold);"></i>
|
|
{} {}
|
|
</h3>
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<span style="background: white; color: var(--deep-navy); padding: 0.25rem 0.75rem; border-radius: 12px; font-weight: 500;">{} item{}</span>
|
|
<i class="fas fa-chevron-down" id="chevron-month-{}" style="color: var(--deep-navy); transition: transform 0.3s ease;"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="month-content" id="month-{}" style="padding: 1.5rem 2rem; display: none;">
|
|
<div style="display: grid; gap: 1.5rem;">
|
|
{}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"#, month_id, month, year, month_sermons.len(), if month_sermons.len() == 1 { "" } else { "s" }, month_id, month_id,
|
|
month_sermons.iter().map(|sermon| {
|
|
let parsed = parse_sermon_title(&sermon.name);
|
|
let premiere_date = sermon.premiere_date.as_ref().map(|d| format_date(d)).unwrap_or_default();
|
|
let default_media_type = "Video".to_string();
|
|
let media_type = sermon.media_type.as_ref().unwrap_or(&default_media_type);
|
|
|
|
format!(r#"
|
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.25rem; background: var(--soft-gray); border-radius: 12px; transition: all 0.3s ease; cursor: pointer;" onclick="window.open('/sermons/{}', '_blank')" onmouseover="this.style.background='#f8fafc'; this.style.transform='translateY(-2px)'" onmouseout="this.style.background='var(--soft-gray)'; this.style.transform='translateY(0)'">
|
|
<div style="flex-grow: 1;">
|
|
<h4 style="margin: 0 0 0.5rem 0; color: var(--deep-navy); font-size: 1.1rem; font-weight: 600;">{}</h4>
|
|
<div style="display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap;">
|
|
{}
|
|
{}
|
|
<span style="background: var(--soft-gold); color: var(--deep-navy); padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.8rem; font-weight: 500;">
|
|
<i class="fas fa-{}"></i> {}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div style="margin-left: 1rem;">
|
|
<i class="fas fa-play-circle" style="color: var(--deep-navy); font-size: 2rem;"></i>
|
|
</div>
|
|
</div>
|
|
"#, sermon.id, parsed.title,
|
|
if let Some(speaker) = parsed.speaker {
|
|
format!(r#"<span style="color: var(--medium-gray); display: flex; align-items: center; gap: 0.5rem;"><i class="fas fa-user" style="color: var(--soft-gold);"></i>{}</span>"#, speaker)
|
|
} else { String::new() },
|
|
if !premiere_date.is_empty() {
|
|
format!(r#"<span style="color: var(--medium-gray); display: flex; align-items: center; gap: 0.5rem;"><i class="fas fa-calendar" style="color: var(--soft-gold);"></i>{}</span>"#, premiere_date)
|
|
} else { String::new() },
|
|
if media_type == "Audio" { "music" } else { "video" },
|
|
media_type)
|
|
}).collect::<Vec<_>>().join(""))
|
|
}).collect::<Vec<_>>().join(""))
|
|
}).collect::<Vec<_>>().join("")
|
|
});
|
|
|
|
Html(layout(&content, &format!("{} Archive", collection_display_name)))
|
|
}
|
|
|
|
fn render_error_page() -> Html<String> {
|
|
let content = r#"
|
|
<section class="section-2025">
|
|
<div class="container-2025">
|
|
<div class="card-2025 scroll-reveal" style="text-align: center; padding: 3rem;">
|
|
<div class="card-icon-2025" style="margin: 0 auto 2rem;">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
</div>
|
|
<h3 class="card-title">Archive Unavailable</h3>
|
|
<p class="card-text">Unable to load sermon archive at this time. Please check back later or contact us for assistance.</p>
|
|
<a href="/sermons" class="btn-2025 btn-primary" style="margin-top: 2rem;">← Back to Latest Sermons</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
"#;
|
|
|
|
Html(layout(content, "Sermon Archive"))
|
|
} |