Add loading screen, native menu bar, and About dialog

Features:
- Loading screen with spinner while FastAPI server starts
- Full native macOS menu bar with:
  - Navigate menu: Home, Books, Search, Verse of the Day, Random
  - Study menu: Study Guides, Topics, Stories, Strong's, Interlinear, Reading Plans
  - View menu: Back, Forward, Reload, Fullscreen
  - Standard Edit, Window, Help menus
- About dialog with version info and description
- Keyboard shortcuts for common navigation (Cmd+K search, etc.)
- Version updated to 0.1.0

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 13:59:25 -05:00
parent 06382e83a0
commit ba71e55bca
2 changed files with 311 additions and 23 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "kjvstudy"
version = "1.0.0"
version = "0.1.0"
description = "KJV Study - Offline Bible Study App"
authors = ["Kenneth Reitz"]
license = "MIT"
@@ -11,12 +11,13 @@ edition = "2021"
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["webview-data-url"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["process", "time"] }
reqwest = { version = "0.12", features = ["blocking"] }
urlencoding = "2"
[features]
default = ["custom-protocol"]
+308 -21
View File
@@ -4,20 +4,79 @@
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{Manager, RunEvent, WebviewUrl, WebviewWindowBuilder};
use tauri::{
Manager, RunEvent, WebviewUrl, WebviewWindowBuilder,
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
Emitter,
};
struct ServerProcess(Mutex<Option<Child>>);
const SERVER_URL: &str = "http://127.0.0.1:31102";
const APP_VERSION: &str = "0.1.0";
// Loading screen HTML
const LOADING_HTML: &str = r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #f5f5dc 0%, #e8e4d4 100%);
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #333;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
h1 {
font-size: 28px;
font-weight: 300;
letter-spacing: 2px;
margin-bottom: 40px;
color: #4a4a4a;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #ddd;
border-top-color: #8b7355;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status {
margin-top: 20px;
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<div class="logo">📖</div>
<h1>KJV STUDY</h1>
<div class="spinner"></div>
<p class="status">Starting server...</p>
</body>
</html>
"#;
fn start_server() -> Option<Child> {
// Get the current working directory (project root in dev)
let working_dir = std::env::current_dir().ok()?;
println!("Starting KJV Study server...");
println!("Working directory: {:?}", working_dir);
// Try uv first (preferred), then fall back to python3
let child = Command::new("uv")
.args([
"run", "uvicorn",
@@ -65,20 +124,183 @@ fn wait_for_server(max_attempts: u32) -> bool {
println!("Server is ready!");
return true;
}
Ok(response) => {
println!("Server responded with status: {}", response.status());
}
Err(e) => {
println!("Connection error: {}", e);
_ => {
std::thread::sleep(Duration::from_millis(500));
}
}
std::thread::sleep(Duration::from_millis(500));
}
println!("Server failed to start after {} attempts", max_attempts);
false
}
fn create_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
// App menu (macOS)
let about = MenuItem::with_id(app, "about", "About KJV Study", true, None::<&str>)?;
let quit = PredefinedMenuItem::quit(app, Some("Quit KJV Study"))?;
let app_menu = Submenu::with_items(
app,
"KJV Study",
true,
&[&about, &PredefinedMenuItem::separator(app)?, &quit],
)?;
// File menu
let close = PredefinedMenuItem::close_window(app, Some("Close Window"))?;
let file_menu = Submenu::with_items(app, "File", true, &[&close])?;
// Edit menu
let edit_menu = Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?;
// Navigate menu
let nav_home = MenuItem::with_id(app, "nav_home", "Home", true, Some("CmdOrCtrl+Shift+H"))?;
let nav_books = MenuItem::with_id(app, "nav_books", "Books", true, Some("CmdOrCtrl+B"))?;
let nav_search = MenuItem::with_id(app, "nav_search", "Search", true, Some("CmdOrCtrl+K"))?;
let nav_votd = MenuItem::with_id(app, "nav_votd", "Verse of the Day", true, Some("CmdOrCtrl+D"))?;
let nav_random = MenuItem::with_id(app, "nav_random", "Random Verse", true, Some("CmdOrCtrl+R"))?;
let navigate_menu = Submenu::with_items(
app,
"Navigate",
true,
&[
&nav_home,
&nav_books,
&PredefinedMenuItem::separator(app)?,
&nav_search,
&nav_votd,
&nav_random,
],
)?;
// Study menu
let study_guides = MenuItem::with_id(app, "study_guides", "Study Guides", true, None::<&str>)?;
let topics = MenuItem::with_id(app, "topics", "Topics", true, None::<&str>)?;
let stories = MenuItem::with_id(app, "stories", "Bible Stories", true, None::<&str>)?;
let strongs = MenuItem::with_id(app, "strongs", "Strong's Concordance", true, None::<&str>)?;
let interlinear = MenuItem::with_id(app, "interlinear", "Interlinear", true, None::<&str>)?;
let reading_plans = MenuItem::with_id(app, "reading_plans", "Reading Plans", true, None::<&str>)?;
let study_menu = Submenu::with_items(
app,
"Study",
true,
&[
&study_guides,
&topics,
&stories,
&PredefinedMenuItem::separator(app)?,
&strongs,
&interlinear,
&PredefinedMenuItem::separator(app)?,
&reading_plans,
],
)?;
// View menu
let reload = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+Shift+R"))?;
let back = MenuItem::with_id(app, "back", "Back", true, Some("CmdOrCtrl+["))?;
let forward = MenuItem::with_id(app, "forward", "Forward", true, Some("CmdOrCtrl+]"))?;
let fullscreen = PredefinedMenuItem::fullscreen(app, None)?;
let view_menu = Submenu::with_items(
app,
"View",
true,
&[
&back,
&forward,
&reload,
&PredefinedMenuItem::separator(app)?,
&fullscreen,
],
)?;
// Window menu
let minimize = PredefinedMenuItem::minimize(app, None)?;
let zoom = PredefinedMenuItem::maximize(app, Some("Zoom"))?;
let window_menu = Submenu::with_items(
app,
"Window",
true,
&[&minimize, &zoom],
)?;
// Help menu
let website = MenuItem::with_id(app, "website", "Visit kjvstudy.org", true, None::<&str>)?;
let help_menu = Submenu::with_items(app, "Help", true, &[&website])?;
Menu::with_items(
app,
&[&app_menu, &file_menu, &edit_menu, &navigate_menu, &study_menu, &view_menu, &window_menu, &help_menu],
)
}
fn show_about_dialog(app: &tauri::AppHandle) {
let about_html = format!(r#"
<!DOCTYPE html>
<html>
<head>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
padding: 30px;
text-align: center;
background: #fafafa;
color: #333;
}}
.logo {{ font-size: 64px; margin-bottom: 10px; }}
h1 {{ font-size: 24px; font-weight: 500; margin: 0; }}
.version {{ color: #666; margin: 8px 0 20px; }}
.desc {{ font-size: 13px; color: #555; line-height: 1.6; max-width: 280px; margin: 0 auto; }}
.footer {{ margin-top: 20px; font-size: 11px; color: #999; }}
</style>
</head>
<body>
<div class="logo">📖</div>
<h1>KJV Study</h1>
<p class="version">Version {}</p>
<p class="desc">
A comprehensive offline Bible study application featuring the 1769 Cambridge King James Version
with commentary, cross-references, and original language tools.
</p>
<p class="footer">© 2024 Kenneth Reitz</p>
</body>
</html>
"#, APP_VERSION);
let about_url = format!("data:text/html,{}", urlencoding::encode(&about_html));
if let Ok(_) = WebviewWindowBuilder::new(
app,
"about",
WebviewUrl::External(about_url.parse().unwrap())
)
.title("About KJV Study")
.inner_size(350.0, 320.0)
.resizable(false)
.minimizable(false)
.maximizable(false)
.center()
.build() {
println!("About dialog opened");
}
}
fn main() {
// Start server BEFORE Tauri
let server_child = start_server();
@@ -89,22 +311,20 @@ fn main() {
std::process::exit(1);
}
// Wait for server to be ready
if !wait_for_server(30) {
eprintln!("ERROR: Server failed to become ready!");
std::process::exit(1);
}
// Now start Tauri
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(ServerProcess(Mutex::new(server_child)))
.setup(|app| {
// Create window pointing to our server
let _window = WebviewWindowBuilder::new(
// Create menu
let menu = create_menu(app.handle())?;
app.set_menu(menu)?;
// Create loading window first
let loading_url = format!("data:text/html,{}", urlencoding::encode(LOADING_HTML));
let window = WebviewWindowBuilder::new(
app,
"main",
WebviewUrl::External(SERVER_URL.parse().unwrap())
WebviewUrl::External(loading_url.parse().unwrap())
)
.title("KJV Study")
.inner_size(1200.0, 800.0)
@@ -112,14 +332,81 @@ fn main() {
.center()
.build()?;
println!("Window created, loading {}", SERVER_URL);
// Wait for server in background, then navigate
let window_clone = window.clone();
std::thread::spawn(move || {
if wait_for_server(30) {
let _ = window_clone.eval(&format!("window.location.href = '{}'", SERVER_URL));
println!("Navigated to server");
} else {
let _ = window_clone.eval(
"document.body.innerHTML = '<h1 style=\"color:red;text-align:center;margin-top:100px\">Failed to start server</h1>'"
);
}
});
Ok(())
})
.on_menu_event(|app, event| {
let window = app.get_webview_window("main");
match event.id().as_ref() {
"about" => show_about_dialog(app),
"website" => {
let _ = tauri_plugin_shell::ShellExt::shell(app).open("https://kjvstudy.org", None);
}
// Navigation
"nav_home" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}'", SERVER_URL)); }
}
"nav_books" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/books'", SERVER_URL)); }
}
"nav_search" => {
if let Some(w) = window { let _ = w.eval("document.querySelector('input[type=search]')?.focus()"); }
}
"nav_votd" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/verse-of-the-day'", SERVER_URL)); }
}
"nav_random" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/random'", SERVER_URL)); }
}
// Study
"study_guides" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/study-guides'", SERVER_URL)); }
}
"topics" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/topics'", SERVER_URL)); }
}
"stories" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/stories'", SERVER_URL)); }
}
"strongs" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/strongs'", SERVER_URL)); }
}
"interlinear" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/interlinear'", SERVER_URL)); }
}
"reading_plans" => {
if let Some(w) = window { let _ = w.eval(&format!("window.location.href = '{}/reading-plans'", SERVER_URL)); }
}
// View
"reload" => {
if let Some(w) = window { let _ = w.eval("window.location.reload()"); }
}
"back" => {
if let Some(w) = window { let _ = w.eval("window.history.back()"); }
}
"forward" => {
if let Some(w) = window { let _ = w.eval("window.history.forward()"); }
}
_ => {}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let RunEvent::Exit = event {
// Clean up: kill the server process
let state = app_handle.state::<ServerProcess>();
if let Some(mut child) = state.0.lock().unwrap().take() {
println!("Shutting down server...");