Add LICENSE and update README for new repository

- Add MIT LICENSE file
- Update README.md to reflect new API integration
- Include all recent changes to migrate from PocketBase to church API

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
RTSDA 2025-08-16 18:35:53 -04:00
parent fdfd83c747
commit d911689e3a
7 changed files with 1559 additions and 968 deletions

2162
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -21,8 +21,8 @@ announcements, and information in a beautiful and engaging way.
[dependencies]
tokio = { version = "1.36", features = ["full"] }
iced = { git = "https://github.com/iced-rs/iced.git", features = ["image", "tokio", "advanced", "debug", "system"] }
reqwest = { version = "0.11", features = ["json"] }
iced = { version = "0.13", features = ["image", "tokio", "advanced", "debug", "system"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
@ -36,5 +36,6 @@ toml = "0.8"
dirs = "5.0"
ril = { version = "0.10", features = ["all"] }
infer = "0.15"
church-core = { path = "../church-core" }
[package.metadata.iced.assets]
icon = "icons/appicon.png"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Rockville Tolland Seventh-day Adventist Church
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -14,18 +14,18 @@ A modern digital signage application for displaying church events, built with Ru
## Requirements
- Rust 1.70 or higher
- A running Pocketbase instance with events collection
- Access to the church API for events data
## Configuration
Create a `config.toml` file in the application directory with the following settings:
```toml
pocketbase_url = "http://your-pocketbase-url"
api_url = "https://api.rockvilletollandsda.church/api"
window_width = 1920
window_height = 1080
slide_interval_secs = 10
refresh_interval_mins = 5
slide_interval_seconds = 10
refresh_interval_minutes = 5
```
## Building

View file

@ -40,7 +40,7 @@ impl Settings {
impl Default for Settings {
fn default() -> Self {
Self {
api_url: String::from("https://api.rockvilletollandsda.church"),
api_url: String::from("https://api.rockvilletollandsda.church/api"),
window_width: 1920,
window_height: 1080,
slide_interval_seconds: 10,

View file

@ -1,18 +1,16 @@
mod config;
mod pocketbase;
use crate::pocketbase::ApiEvent;
use church_core::models::event::Event;
use church_core::client::ChurchApiClient;
use church_core::config::ChurchCoreConfig;
use iced::widget::{column, row, image, container, text};
use iced::{
window, Element,
Length, Settings, Subscription, Theme, Task,
};
use iced::executor;
pub use iced::Program as IcedProgram;
use iced::Color;
use once_cell::sync::Lazy;
use std::time::Instant;
use iced::window::settings::PlatformSpecific;
@ -24,8 +22,10 @@ static SETTINGS: Lazy<config::Settings> = Lazy::new(|| {
})
});
static API_CLIENT: Lazy<pocketbase::ApiClient> = Lazy::new(|| {
pocketbase::ApiClient::new(SETTINGS.api_url.clone())
static API_CLIENT: Lazy<ChurchApiClient> = Lazy::new(|| {
let config = ChurchCoreConfig::new()
.with_base_url(SETTINGS.api_url.clone());
ChurchApiClient::new(config).expect("Failed to create API client")
});
// Define some constants for styling
@ -54,20 +54,6 @@ struct DigitalSign {
is_fetching: bool,
}
#[derive(Debug, Clone)]
struct Event {
title: String,
description: String,
start_time: String,
end_time: String,
date: String,
location: String,
//location_url: Option<String>,
image_url: Option<String>,
category: String,
//is_featured: bool,
timestamp: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
enum Message {
@ -77,43 +63,44 @@ enum Message {
ImageLoaded(String, image::Handle),
}
impl IcedProgram for DigitalSign {
type Message = Message;
type Theme = Theme;
type Executor = executor::Default;
type State = Self;
type Renderer = iced::Renderer;
impl DigitalSign {
fn new() -> (Self, Task<Message>) {
let mut app = Self::default();
app.is_fetching = true;
let task = Task::perform(fetch_events(), Message::handle_result);
(app, task)
}
fn title(&self, _state: &Self::State, _window_id: window::Id) -> String {
fn title(&self) -> String {
String::from("Beacon")
}
fn update(&self, state: &mut Self::State, message: Message) -> Task<Message> {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Tick => {
let mut tasks = vec![];
state.loading_frame = (state.loading_frame + 1) % LOADING_FRAMES.len();
self.loading_frame = (self.loading_frame + 1) % LOADING_FRAMES.len();
if state.should_refresh() && !state.is_fetching {
if self.should_refresh() && !self.is_fetching {
tracing::info!("Refresh needed, starting event fetch");
state.is_fetching = true;
self.is_fetching = true;
tasks.push(Task::perform(fetch_events(), Message::handle_result));
}
if !state.events.is_empty()
&& Instant::now().duration_since(state.last_update) >= SETTINGS.slide_interval()
if !self.events.is_empty()
&& Instant::now().duration_since(self.last_update) >= SETTINGS.slide_interval()
{
let next_index = (state.current_event_index + 1) % state.events.len();
let next_index = (self.current_event_index + 1) % self.events.len();
tracing::info!("Updating current event index from {} to {}",
state.current_event_index,
self.current_event_index,
next_index
);
// Clear all images that aren't needed anymore
let mut urls_to_remove = Vec::new();
for url in state.loaded_images.keys() {
let is_needed = state.events.iter().any(|e| {
e.image_url.as_ref().map_or(false, |event_url| event_url == url)
for url in self.loaded_images.keys() {
let is_needed = self.events.iter().any(|e| {
e.image.as_ref().map_or(false, |event_url| event_url == url)
});
if !is_needed {
urls_to_remove.push(url.clone());
@ -121,17 +108,17 @@ impl IcedProgram for DigitalSign {
}
for url in urls_to_remove {
tracing::info!("Removing unused image: {}", url);
state.loaded_images.remove(&url);
self.loaded_images.remove(&url);
}
// Update current index and load new image if needed
state.current_event_index = next_index;
state.last_update = Instant::now();
self.current_event_index = next_index;
self.last_update = Instant::now();
if let Some(current_event) = state.events.get(state.current_event_index) {
if let Some(url) = &current_event.image_url {
if let Some(current_event) = self.events.get(self.current_event_index) {
if let Some(url) = &current_event.image {
let url_clone = url.clone();
if !state.loaded_images.contains_key(&url_clone) {
if !self.loaded_images.contains_key(&url_clone) {
tracing::info!("Starting image load for new current event: {}", url_clone);
let url_for_closure = url_clone.clone();
tasks.push(Task::perform(
@ -156,26 +143,26 @@ impl IcedProgram for DigitalSign {
tracing::info!("Events loaded: {} events", events.len());
// Clear all existing images as we have a new set of events
state.loaded_images.clear();
self.loaded_images.clear();
tracing::info!("Cleared all existing images");
state.events = events;
self.events = events;
// Reset current event index if needed
if state.current_event_index >= state.events.len() && !state.events.is_empty() {
tracing::info!("Resetting current event index from {} to 0", state.current_event_index);
state.current_event_index = 0;
if self.current_event_index >= self.events.len() && !self.events.is_empty() {
tracing::info!("Resetting current event index from {} to 0", self.current_event_index);
self.current_event_index = 0;
}
state.last_refresh = Instant::now();
state.is_fetching = false;
self.last_refresh = Instant::now();
self.is_fetching = false;
// Load all images in parallel
let mut image_tasks = Vec::new();
// First, add the current event's image if it exists
if let Some(event) = state.events.get(state.current_event_index) {
if let Some(url) = &event.image_url {
if let Some(event) = self.events.get(self.current_event_index) {
if let Some(url) = &event.image {
tracing::info!("Starting immediate load for current image: {}", url);
let url_clone = url.clone();
image_tasks.push(Task::perform(
@ -186,9 +173,9 @@ impl IcedProgram for DigitalSign {
}
// Then queue the rest of the images
for (index, event) in state.events.iter().enumerate() {
if index != state.current_event_index {
if let Some(url) = &event.image_url {
for (index, event) in self.events.iter().enumerate() {
if index != self.current_event_index {
if let Some(url) = &event.image {
tracing::info!("Queueing image preload for: {}", url);
let url_clone = url.clone();
image_tasks.push(Task::perform(
@ -208,23 +195,25 @@ impl IcedProgram for DigitalSign {
}
Message::ImageLoaded(url, handle) => {
tracing::info!("Image loaded: {}", url);
state.loaded_images.insert(url, handle);
self.loaded_images.insert(url, handle);
Task::none()
}
Message::Error(error) => {
tracing::error!("Error: {}", error);
state.is_fetching = false;
self.is_fetching = false;
Task::none()
}
}
}
fn view<'a>(
&self,
state: &'a Self::State,
_window_id: window::Id,
) -> Element<'a, Message, Theme, Self::Renderer> {
let content: Element<'a, Message, Theme, Self::Renderer> = if let Some(event) = state.events.get(state.current_event_index) {
fn view(&self) -> Element<Message> {
let content: Element<Message> = if let Some(event) = self.events.get(self.current_event_index) {
let formatted_date = event.formatted_date();
let formatted_start_time = event.formatted_start_time();
let formatted_end_time = event.formatted_end_time();
let clean_description = event.clean_description();
let category_string = event.category.to_string();
let mut main_column = column![].spacing(40).padding(60).width(Length::Fill);
// Left column with title and image
@ -240,8 +229,8 @@ impl IcedProgram for DigitalSign {
// Image container with enhanced styling
container(
if let Some(ref image_url) = event.image_url {
if let Some(handle) = state.loaded_images.get(image_url) {
if let Some(ref image_url) = event.image {
if let Some(handle) = self.loaded_images.get(image_url) {
container(
image::Image::new(handle.clone())
.width(Length::Fixed(900.0))
@ -254,7 +243,7 @@ impl IcedProgram for DigitalSign {
} else {
container(
column![
text(LOADING_FRAMES[state.loading_frame])
text(LOADING_FRAMES[self.loading_frame])
.size(80)
.style(|_: &Theme| text::Style { color: Some(ACCENT_COLOR), ..Default::default() }),
text("Loading image...")
@ -286,7 +275,7 @@ impl IcedProgram for DigitalSign {
let right_column = column![
// Category badge with gradient-like effect
container(
text(event.category.to_uppercase())
text(category_string.to_uppercase())
.size(36)
.style(|_: &Theme| text::Style { color: Some(TEXT_COLOR), ..Default::default() })
)
@ -299,10 +288,10 @@ impl IcedProgram for DigitalSign {
// Date and time with enhanced colors
container(
column![
text(&event.date)
text(formatted_date)
.size(64)
.style(|_: &Theme| text::Style { color: Some(DATE_COLOR), ..Default::default() }),
text(format!("{} - {}", event.start_time, event.end_time))
text(format!("{} - {}", formatted_start_time, formatted_end_time))
.size(56)
.style(|_: &Theme| text::Style { color: Some(TIME_COLOR), ..Default::default() })
]
@ -332,7 +321,7 @@ impl IcedProgram for DigitalSign {
// Description with styled background
container(
text(&event.description)
text(clean_description)
.size(44)
.style(|_: &Theme| text::Style { color: Some(TEXT_COLOR), ..Default::default() })
)
@ -386,12 +375,12 @@ impl IcedProgram for DigitalSign {
.into()
}
fn subscription(&self, _state: &Self::State) -> Subscription<Message> {
fn subscription(&self) -> Subscription<Message> {
iced::time::every(std::time::Duration::from_millis(100))
.map(|_| Message::Tick)
}
fn theme(&self, _state: &Self::State, _window_id: window::Id) -> Theme {
fn theme(&self) -> Theme {
Theme::Dark
}
}
@ -407,36 +396,30 @@ impl Message {
async fn fetch_events() -> Result<Vec<Event>, anyhow::Error> {
tracing::info!("Starting to fetch upcoming events from API");
let api_events = match API_CLIENT.fetch_events().await {
let mut events = match API_CLIENT.get_upcoming_events(None).await {
Ok(events) => {
tracing::info!("Successfully fetched {} upcoming events from API", events.len());
tracing::info!("Successfully fetched {} events from API", events.len());
events
},
Err(e) => {
tracing::error!("Failed to fetch events from API: {}", e);
return Err(e);
return Err(e.into());
}
};
// Convert API events to display events (no filtering needed since /upcoming endpoint handles it)
let mut events: Vec<Event> = api_events
.into_iter()
.map(Event::from)
.collect();
if events.is_empty() {
tracing::warn!("No upcoming events found");
} else {
tracing::info!(
"Found {} upcoming events, from {} to {}",
events.len(),
events.first().map(|e| e.date.as_str()).unwrap_or("unknown"),
events.last().map(|e| e.date.as_str()).unwrap_or("unknown")
events.first().map(|e| e.formatted_date()).unwrap_or("unknown".to_string()),
events.last().map(|e| e.formatted_date()).unwrap_or("unknown".to_string())
);
}
// Sort by start time (API should already provide them sorted, but ensure consistency)
events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
// Sort by start time
events.sort_by(|a, b| a.start_time.cmp(&b.start_time));
tracing::info!("Processed {} upcoming events", events.len());
Ok(events)
}
@ -488,38 +471,6 @@ async fn load_image(url: String) -> image::Handle {
}
}
impl From<ApiEvent> for Event {
fn from(event: ApiEvent) -> Self {
let clean_description = html2text::from_read(event.description.as_bytes(), 80)
.replace('\n', " ")
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let date = event.start_time.format("%A, %B %d, %Y").to_string();
let start_time = event.start_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string();
let end_time = event.end_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string();
let image_url = event.image.clone();
if let Some(ref url) = image_url {
tracing::info!("Using image URL: {}", url);
}
Self {
title: event.title,
description: clean_description,
start_time,
end_time,
date,
location: event.location,
//location_url: event.location_url,
image_url,
category: event.category,
//is_featured: event.is_featured,
timestamp: event.start_time,
}
}
}
fn main() -> iced::Result {
tracing_subscriber::fmt()
@ -574,23 +525,6 @@ fn main() -> iced::Result {
}
};
let window_settings = window::Settings {
size: iced::Size::new(SETTINGS.window_width as f32, SETTINGS.window_height as f32),
position: window::Position::Centered,
fullscreen: true,
resizable: false,
decorations: false,
icon: icon_data,
#[cfg(target_os = "macos")]
platform_specific: PlatformSpecific {
title_hidden: true,
titlebar_transparent: true,
fullsize_content_view: true,
},
#[cfg(not(target_os = "macos"))]
platform_specific: PlatformSpecific::default(),
..Default::default()
};
// Load additional fonts for better Unicode support
let fonts = vec![
@ -605,34 +539,17 @@ fn main() -> iced::Result {
].into_iter().filter_map(|f| f).map(|bytes| std::borrow::Cow::Owned(bytes)).collect();
let settings = Settings {
// window: window_settings,
//flags: (),
fonts,
default_font: iced::Font::default(),
antialiasing: true,
default_text_size: iced::Pixels(16.0),
..Default::default()
};
// Create the initial state and start loading events
let mut app = DigitalSign::default();
app.is_fetching = true;
DigitalSign::run_with(
app,
settings,
Some(window_settings),
|| {
let mut state = DigitalSign::default();
state.is_fetching = true;
(
state,
Task::perform(
fetch_events(),
Message::handle_result
)
)
}
)
iced::application("Beacon", DigitalSign::update, DigitalSign::view)
.subscription(DigitalSign::subscription)
.theme(DigitalSign::theme)
.settings(settings)
.window_size(iced::Size::new(800.0, 600.0))
.run_with(DigitalSign::new)
}
impl DigitalSign {

View file

@ -1,92 +0,0 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const API_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiEvent {
pub id: String,
pub title: String,
pub description: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: String,
pub location_url: Option<String>,
pub image: Option<String>,
pub thumbnail: Option<String>,
pub category: String,
pub is_featured: bool,
pub recurring_type: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ApiClient {
client: reqwest::Client,
base_url: String,
}
impl ApiClient {
pub fn new(base_url: String) -> Self {
let client = reqwest::Client::builder()
.timeout(API_TIMEOUT)
.build()
.expect("Failed to create HTTP client");
Self { client, base_url }
}
pub async fn fetch_events(&self) -> Result<Vec<ApiEvent>> {
let url = format!("{}/api/events/upcoming", self.base_url);
tracing::info!("Fetching events from URL: {}", url);
let response = match self.client.get(&url)
.header("Cache-Control", "max-age=60") // Cache for 60 seconds
.send()
.await
{
Ok(resp) => {
tracing::info!("Got response with status: {}", resp.status());
resp
},
Err(e) => {
tracing::error!("HTTP request failed: {}", e);
return Err(e.into());
}
};
let response = match response.error_for_status() {
Ok(resp) => resp,
Err(e) => {
tracing::error!("HTTP error status: {}", e);
return Err(e.into());
}
};
#[derive(Deserialize)]
struct ApiResponse {
success: bool,
data: Vec<ApiEvent>,
}
match response.json().await {
Ok(api_response) => {
let ApiResponse { success, data } = api_response;
if success {
tracing::info!("Successfully parsed {} events from response", data.len());
Ok(data)
} else {
tracing::error!("API returned success: false");
Err(anyhow::anyhow!("API request failed"))
}
},
Err(e) => {
tracing::error!("Failed to parse JSON response: {}", e);
Err(e.into())
}
}
}
}