pub mod battler;
pub mod debug;
pub mod guard;
pub mod layout;
pub mod pokedex;
use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web};
use serde::{Deserialize, Serialize};
use crate::auth;
use crate::config::Config;
use crate::db::{DuckDb, SqliteDb};
use crate::session::{SESSION_COOKIE_NAME, SessionStore};
use debug::DbTableSummary;
use layout::page_shell;
pub fn is_htmx(req: &HttpRequest) -> bool {
req.headers().contains_key("hx-request")
}
const PAGE_SIZE: usize = 24;
#[derive(Deserialize, Serialize)]
pub struct SearchQuery {
#[serde(default)]
pub q: String,
}
#[derive(Deserialize, Serialize)]
pub struct PageQuery {
#[serde(default)]
pub offset: usize,
#[serde(default)]
pub q: String,
}
#[derive(Deserialize, Serialize)]
pub struct PinForm {
pub pin: String,
}
#[get("/")]
pub async fn index(duck_db: web::Data<DuckDb>) -> impl Responder {
let pokemon_list = duck_db
.search_pokemon("", PAGE_SIZE, 0)
.await
.unwrap_or_default();
let total = duck_db.total_pokemon_count("").await.unwrap_or(0);
let has_more = total > PAGE_SIZE;
let content = pokedex::pokedex_tab_view(&pokemon_list, "", total, has_more, PAGE_SIZE);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(page_shell("Pokédex • FireRed & LeafGreen", "pokedex", &content).into_string())
}
#[get("/pokedex")]
pub async fn pokedex_tab(req: HttpRequest, duck_db: web::Data<DuckDb>) -> impl Responder {
let pokemon_list = duck_db
.search_pokemon("", PAGE_SIZE, 0)
.await
.unwrap_or_default();
let total = duck_db.total_pokemon_count("").await.unwrap_or(0);
let has_more = total > PAGE_SIZE;
let content = pokedex::pokedex_tab_view(&pokemon_list, "", total, has_more, PAGE_SIZE);
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(page_shell("Pokédex • FireRed & LeafGreen", "pokedex", &content).into_string())
}
}
#[get("/pokedex/search")]
pub async fn pokedex_search(
query: web::Query<SearchQuery>,
duck_db: web::Data<DuckDb>,
) -> impl Responder {
let q = query.q.trim();
let pokemon_list = duck_db
.search_pokemon(q, PAGE_SIZE, 0)
.await
.unwrap_or_default();
let total = duck_db.total_pokemon_count(q).await.unwrap_or(0);
let has_more = total > PAGE_SIZE;
let content = pokedex::pokemon_grid_items(&pokemon_list, q, has_more, PAGE_SIZE);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
}
#[get("/pokedex/page")]
pub async fn pokedex_page(
query: web::Query<PageQuery>,
duck_db: web::Data<DuckDb>,
) -> impl Responder {
let q = query.q.trim();
let offset = query.offset;
let pokemon_list = duck_db
.search_pokemon(q, PAGE_SIZE, offset)
.await
.unwrap_or_default();
let total = duck_db.total_pokemon_count(q).await.unwrap_or(0);
let next_offset = offset + pokemon_list.len();
let has_more = total > next_offset;
let content = pokedex::pokemon_grid_items(&pokemon_list, q, has_more, next_offset);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
}
#[get("/pokemon/{id}")]
pub async fn pokemon_detail(
req: HttpRequest,
path: web::Path<i32>,
duck_db: web::Data<DuckDb>,
) -> impl Responder {
let id = path.into_inner();
match duck_db.get_pokemon_by_id(id).await {
Ok(Some(p)) => {
let content = pokedex::pokemon_detail_page(&p);
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(
page_shell(
&format!("{} • Pokédex", p.display_name),
"pokedex",
&content,
)
.into_string(),
)
}
}
Ok(None) => HttpResponse::NotFound().body("Pokémon not found"),
Err(e) => {
log::error!("Failed to get pokemon {id}: {e}");
HttpResponse::InternalServerError().finish()
}
}
}
#[get("/battler")]
pub async fn battler_tab(req: HttpRequest) -> impl Responder {
let content = battler::battler_view();
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(page_shell("AutoBattler Arena • Coming Soon", "battler", &content).into_string())
}
}
#[get("/battler/ticket")]
pub async fn battler_ticket() -> impl Responder {
let pin = crate::battle_ticket::generate_battle_pin();
let qr_svg =
crate::battle_ticket::generate_qr_svg(&format!("/battle?pin={pin}")).unwrap_or_default();
let content = battler::battle_ticket_card(&pin, &qr_svg);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
}
#[derive(Deserialize, Serialize)]
pub struct BattleQuery {
pub pin: Option<String>,
}
#[get("/battle")]
pub async fn battle_entry(req: HttpRequest, query: web::Query<BattleQuery>) -> impl Responder {
let content = battler::battle_coming_soon_view(query.pin.as_deref());
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(page_shell("Match Lobby • Coming Soon", "battler", &content).into_string())
}
}
#[get("/debug")]
pub async fn debug_tab(
req: HttpRequest,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
duck_db: web::Data<DuckDb>,
sqlite_db: web::Data<SqliteDb>,
) -> impl Responder {
if !auth::is_admin(&req, &config, &session_store) {
let guard_html = guard::admin_pin_guard(None);
return if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(guard_html.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(page_shell("Admin Access • Pokédex", "debug", &guard_html).into_string())
};
}
let duck_summary = fetch_duck_summary(&duck_db).await;
let sqlite_summary = fetch_sqlite_summary(&sqlite_db).await;
let content = debug::debug_view(
duck_db.db_path(),
sqlite_db.db_path(),
&duck_summary,
&sqlite_summary,
None,
);
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(page_shell("Debug Diagnostics • Pokédex", "debug", &content).into_string())
}
}
#[post("/pin")]
pub async fn verify_pin(
form: web::Form<PinForm>,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
duck_db: web::Data<DuckDb>,
sqlite_db: web::Data<SqliteDb>,
) -> impl Responder {
if config.is_valid_pin(&form.pin) {
let token = session_store.create_session();
let cookie = actix_web::cookie::Cookie::build(SESSION_COOKIE_NAME, token)
.path("/")
.http_only(true)
.same_site(actix_web::cookie::SameSite::Lax)
.finish();
let duck_summary = fetch_duck_summary(&duck_db).await;
let sqlite_summary = fetch_sqlite_summary(&sqlite_db).await;
let content = debug::debug_view(
duck_db.db_path(),
sqlite_db.db_path(),
&duck_summary,
&sqlite_summary,
None,
);
HttpResponse::Ok()
.cookie(cookie)
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
let guard_html = guard::admin_pin_guard(Some("Invalid PIN code. Please try again."));
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(guard_html.into_string())
}
}
#[post("/logout")]
pub async fn logout(req: HttpRequest, session_store: web::Data<SessionStore>) -> impl Responder {
session_store.destroy_session(&req);
let cookie = actix_web::cookie::Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(actix_web::cookie::time::Duration::seconds(0))
.finish();
let guard_html = guard::admin_pin_guard(None);
HttpResponse::Ok()
.cookie(cookie)
.content_type("text/html; charset=utf-8")
.body(guard_html.into_string())
}
#[post("/debug/sync")]
pub async fn debug_sync(
req: HttpRequest,
config: web::Data<Config>,
session_store: web::Data<SessionStore>,
duck_db: web::Data<DuckDb>,
) -> impl Responder {
if !auth::is_admin(&req, &config, &session_store) {
return HttpResponse::Unauthorized().finish();
}
match duck_db.seed_if_empty().await {
Ok(count) => {
let msg = format!("✓ DuckDB verified ({count} Pokémon active)");
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format!("<span class='text-xs font-semibold px-2 py-1 bg-green-100 text-green-800 rounded'>{msg}</span>"))
}
Err(e) => HttpResponse::InternalServerError().body(format!(
"<span class='text-xs text-red-600'>Sync error: {e}</span>"
)),
}
}
async fn fetch_duck_summary(duck_db: &DuckDb) -> Vec<DbTableSummary> {
let mut list = Vec::new();
if let Ok(tables) = duck_db.list_tables().await {
for table in tables {
let row_count = duck_db.table_row_count(&table).await.unwrap_or(0);
let columns = duck_db.describe_table(&table).await.unwrap_or_default();
let preview = duck_db.preview_table(&table, 10).await.unwrap_or_else(|_| {
crate::db::TablePreview {
table_name: table.clone(),
total_rows: 0,
columns: Vec::new(),
rows: Vec::new(),
}
});
list.push(DbTableSummary {
name: table,
row_count,
columns,
preview,
});
}
}
list
}
async fn fetch_sqlite_summary(sqlite_db: &SqliteDb) -> Vec<DbTableSummary> {
let mut list = Vec::new();
if let Ok(tables) = sqlite_db.list_tables().await {
for table in tables {
let row_count = sqlite_db.table_row_count(&table).await.unwrap_or(0);
let columns = sqlite_db.describe_table(&table).await.unwrap_or_default();
let preview = sqlite_db
.preview_table(&table, 10)
.await
.unwrap_or_else(|_| crate::db::TablePreview {
table_name: table.clone(),
total_rows: 0,
columns: Vec::new(),
rows: Vec::new(),
});
list.push(DbTableSummary {
name: table,
row_count,
columns,
preview,
});
}
}
list
}