feat: add HTML endpoints for item listing and details pages

Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
This commit is contained in:
Andrew Phillips
2025-08-12 16:37:15 -03:00
parent 6ec8e7a669
commit d4c3f5a090
2 changed files with 196 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ use tower_http::trace::TraceLayer;
mod common; mod common;
mod api; mod api;
mod docs; mod docs;
mod pages;
pub use common::{ServerConfig, AppState, logging_middleware}; pub use common::{ServerConfig, AppState, logging_middleware};
@@ -62,9 +63,10 @@ async fn run_server(
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
); );
// Add API and documentation routes // Add API, documentation, and pages routes
let app = api::add_routes(app); let app = api::add_routes(app);
let app = docs::add_routes(app); let app = docs::add_routes(app);
let app = pages::add_routes(app);
// Apply state to the router after all routes are added // Apply state to the router after all routes are added
let app = app.with_state(state); let app = app.with_state(state);

View File

@@ -0,0 +1,193 @@
use crate::db::{self, Item, Tag, Meta};
use crate::modes::server::AppState;
use anyhow::Result;
use axum::{
extract::{Path, Query, State},
response::Html,
};
use rusqlite::Connection;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Deserialize)]
pub struct ListQueryParams {
#[serde(default = "default_sort")]
sort: String,
#[serde(default)]
tags: Option<String>,
#[serde(default = "default_count")]
count: usize,
#[serde(default)]
start: usize,
}
fn default_sort() -> String {
"newest".to_string()
}
fn default_count() -> usize {
1000
}
pub fn add_routes(app: axum::Router<AppState>) -> axum::Router<AppState> {
app.route("/", axum::routing::get(list_items))
.route("/item/:id", axum::routing::get(show_item))
}
async fn list_items(
State(state): State<AppState>,
Query(params): Query<ListQueryParams>,
) -> Result<Html<String>, Html<String>> {
let conn = state.db.lock().await;
let result = build_item_list(&conn, &params);
match result {
Ok(html) => Ok(Html(html)),
Err(e) => Err(Html(format!("<html><body>Error: {}</body></html>", e))),
}
}
fn build_item_list(conn: &Connection, params: &ListQueryParams) -> Result<String> {
let tags: Vec<String> = params.tags
.as_ref()
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let items = if tags.is_empty() {
db::query_all_items(conn)?
} else {
db::query_tagged_items(conn, &tags)?
};
// Sort items
let mut sorted_items = items;
if params.sort == "newest" {
sorted_items.sort_by(|a, b| b.id.cmp(&a.id));
} else {
sorted_items.sort_by(|a, b| a.id.cmp(&b.id));
}
// Apply pagination
let start = params.start;
let end = std::cmp::min(start + params.count, sorted_items.len());
let page_items = if start < sorted_items.len() {
sorted_items[start..std::cmp::min(end, sorted_items.len())].to_vec()
} else {
vec![]
};
// Get tags for all items in the page
let item_ids: Vec<i64> = page_items.iter().filter_map(|item| item.id).collect();
let tags_map = db::get_tags_for_items(conn, &item_ids)?;
let mut html = String::new();
html.push_str("<html><head><title>Items</title></head><body>");
html.push_str("<h1>Items</h1>");
html.push_str("<ul>");
for item in page_items {
let item_id = item.id.unwrap_or(0);
let tags_html = if let Some(tags) = tags_map.get(&item_id) {
let tag_names: Vec<String> = tags.iter().map(|t| t.name.clone()).collect();
if tag_names.is_empty() {
String::new()
} else {
format!(" [{}]", tag_names.join(", "))
}
} else {
String::new()
};
html.push_str(&format!(
"<li><a href=\"/item/{}\">Item #{}</a> - {}{} - <a href=\"/api/item/{}/content\">Download</a></li>",
item_id,
item_id,
item.ts.format("%Y-%m-%d %H:%M:%S"),
tags_html,
item_id
));
}
html.push_str("</ul>");
// Add pagination info
html.push_str(&format!("<p>Showing {} to {} of {} items</p>",
start + 1,
std::cmp::min(end, sorted_items.len()),
sorted_items.len()));
html.push_str("</body></html>");
Ok(html)
}
async fn show_item(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Html<String>, Html<String>> {
let conn = state.db.lock().await;
let result = build_item_details(&conn, id);
match result {
Ok(html) => Ok(Html(html)),
Err(e) => Err(Html(format!("<html><body>Error: {}</body></html>", e))),
}
}
fn build_item_details(conn: &Connection, id: i64) -> Result<String> {
let item = match db::get_item(conn, id)? {
Some(item) => item,
None => return Err(anyhow::anyhow!("Item not found")),
};
let tags = db::get_item_tags(conn, &item)?;
let metas = db::get_item_meta(conn, &item)?;
let mut html = String::new();
html.push_str("<html><head><title>Item Details</title></head><body>");
html.push_str(&format!("<h1>Item #{}</h1>", id));
html.push_str("<table>");
html.push_str(&format!("<tr><td>ID:</td><td>{}</td></tr>", item.id.unwrap_or(0)));
html.push_str(&format!("<tr><td>Timestamp:</td><td>{}</td></tr>", item.ts.format("%Y-%m-%d %H:%M:%S")));
html.push_str(&format!("<tr><td>Size:</td><td>{}</td></tr>", item.size.unwrap_or(0)));
html.push_str(&format!("<tr><td>Compression:</td><td>{}</td></tr>", item.compression));
html.push_str("</table>");
// Tags section
html.push_str("<h2>Tags</h2>");
if tags.is_empty() {
html.push_str("<p>No tags</p>");
} else {
html.push_str("<ul>");
for tag in tags {
html.push_str(&format!("<li>{}</li>", tag.name));
}
html.push_str("</ul>");
}
// Metadata section
html.push_str("<h2>Metadata</h2>");
if metas.is_empty() {
html.push_str("<p>No metadata</p>");
} else {
html.push_str("<table>");
html.push_str("<tr><th>Name</th><th>Value</th></tr>");
for meta in metas {
html.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", meta.name, meta.value));
}
html.push_str("</table>");
}
// Links
html.push_str("<h2>Actions</h2>");
html.push_str(&format!("<p><a href=\"/api/item/{}/content\">Download Content</a></p>", id));
html.push_str("<p><a href=\"/\">Back to list</a></p>");
html.push_str("</body></html>");
Ok(html)
}