simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 6c2ae8100cd5d380068ebf532a1e4efbb8fbd82f
parent 30fcb4529e8eaf0909961f76dbda2018edd44b05
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 25 Apr 2026 13:03:12 +0200

feat: refactor to HN/Lobsters-style link aggregator

- Add story submission, upvoting, nested comments
- Add user profiles with karma tracking
- Add tag/category filtering
- Implement hot/new/top sorting feeds
- HN-style CSS redesign with dark mode
- Database migration for new schema (story, comment, vote, user_profile)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 3+++
Msrc/db/mod.rs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/error.rs | 16++++++++--------
Msrc/extractors.rs | 1+
Msrc/handlers/auth.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Asrc/handlers/comment.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/handlers/feed.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/handlers/home.rs | 179-------------------------------------------------------------------------------
Msrc/handlers/mod.rs | 14++++++++++++--
Msrc/handlers/search.rs | 18+++++++++++-------
Asrc/handlers/story.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/handlers/tag.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/handlers/user.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/handlers/vote.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/routes.rs | 33++++++++++++++++++++++++++++++++-
Msrc/templates.rs | 92++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/trailbase.rs | 473+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/trailbase_types.rs | 54+++++++++++++++++++++++++++++++++++++++++++++---------
Mstatic/style.css | 735++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mstatic/style.css.gz | 0
Mtemplates/application.html | 23++++++++++++++++++++---
Atemplates/feed.html | 32++++++++++++++++++++++++++++++++
Dtemplates/index.html | 177-------------------------------------------------------------------------------
Dtemplates/load_more.html | 12------------
Mtemplates/login.html | 42+++++++++++++++++++++---------------------
Dtemplates/news_card.html | 30------------------------------
Dtemplates/news_cards.html | 1-
Dtemplates/oob_swap.html | 1-
Mtemplates/register.html | 48+++++++++++++++++++++++++++---------------------
Atemplates/reply_form.html | 32++++++++++++++++++++++++++++++++
Mtemplates/search.html | 45+++++++--------------------------------------
Mtemplates/search_results.html | 28++++++++++++++++++----------
Atemplates/story.html | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/story_row.html | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/submit.html | 39+++++++++++++++++++++++++++++++++++++++
Atemplates/tag.html | 19+++++++++++++++++++
Atemplates/user.html | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/vote_button.html | 6++++++
Mtraildepot/config.textproto | 33+++++++++++++++++++++++++++------
Atraildepot/migrations/main/U101__hn_clone_schema.sql | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
41 files changed, 3041 insertions(+), 612 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3,6 +3,15 @@ version = 4 [[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] name = "askama" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -251,6 +260,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] name = "cmake" version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -835,6 +858,30 @@ dependencies = [ ] [[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] name = "icu_collections" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1864,11 +1911,14 @@ dependencies = [ "askama", "axum", "axum-extra", + "chrono", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tracing", "trailbase-client", + "url", ] [[package]] @@ -2414,6 +2464,41 @@ dependencies = [ ] [[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -7,8 +7,11 @@ edition = "2021" axum = "0.8" axum-extra = { version = "0.10", features = ["cookie"] } askama = "0.12" +chrono = { version = "0.4", features = ["serde"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" +tracing = "0.1" trailbase-client = "0.8" +url = "2" diff --git a/src/db/mod.rs b/src/db/mod.rs @@ -1,29 +1,90 @@ // Re-export types from trailbase client -pub use crate::trailbase::{Category, NewsItem, SearchResult}; +pub use crate::trailbase::{Category, Story, Comment, UserProfile}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct NewsWithCategories { +pub struct StoryWithMeta { pub id: i64, + pub url: Option<String>, pub title: String, pub text: String, + pub domain: Option<String>, + pub score: i64, + pub comment_count: i64, pub published: String, - pub categories: Vec<Category>, - pub time_since_published: String, + pub created_by: Option<String>, + pub tags: Vec<Category>, + pub author_username: Option<String>, + pub time_ago: String, + pub user_voted: bool, + pub rank: usize, } -impl NewsWithCategories { - pub fn from_news_item( - item: NewsItem, - categories: Vec<Category>, - time_since_published: String, +impl StoryWithMeta { + pub fn from_story( + story: Story, + tags: Vec<Category>, + author_username: Option<String>, + time_ago: String, + user_voted: bool, + rank: usize, ) -> Self { Self { - id: item.id, - title: item.title, - text: item.text, - published: item.published, - categories, - time_since_published, + id: story.id, + url: story.url, + title: story.title, + text: story.text, + domain: story.domain, + score: story.score, + comment_count: story.comment_count, + published: story.published, + created_by: story.created_by, + tags, + author_username, + time_ago, + user_voted, + rank, + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CommentWithMeta { + pub id: i64, + pub story_id: i64, + pub parent_id: Option<i64>, + pub path: String, + pub depth: i64, + pub indent_px: i64, // Pre-computed: depth * 20 + pub text: String, + pub score: i64, + pub created_at: String, + pub created_by: String, + pub author_username: Option<String>, + pub time_ago: String, + pub user_voted: bool, +} + +impl CommentWithMeta { + pub fn from_comment( + comment: Comment, + author_username: Option<String>, + time_ago: String, + user_voted: bool, + ) -> Self { + Self { + id: comment.id, + story_id: comment.story_id, + parent_id: comment.parent_id, + path: comment.path.clone(), + depth: comment.depth, + indent_px: comment.depth * 20, + text: comment.text, + score: comment.score, + created_at: comment.created_at, + created_by: comment.created_by, + author_username, + time_ago, + user_voted, } } } diff --git a/src/error.rs b/src/error.rs @@ -12,11 +12,11 @@ pub enum AppError { #[error("Template error: {0}")] Template(#[from] askama::Error), - #[error("Not found")] - NotFound, + #[error("Not found: {0}")] + NotFound(String), - #[error("Unauthorized")] - Unauthorized, + #[error("Unauthorized: {0}")] + Unauthorized(String), } impl IntoResponse for AppError { @@ -24,14 +24,14 @@ impl IntoResponse for AppError { let (status, message) = match &self { AppError::Api(e) => { eprintln!("API error: {}", e); - (StatusCode::BAD_GATEWAY, "API error") + (StatusCode::BAD_GATEWAY, format!("API error: {}", e)) } AppError::Template(e) => { eprintln!("Template error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "Template error") + (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {}", e)) } - AppError::NotFound => (StatusCode::NOT_FOUND, "Not found"), - AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), }; (status, message).into_response() diff --git a/src/extractors.rs b/src/extractors.rs @@ -5,6 +5,7 @@ use axum::{ /// Extractor that checks if the request is an HTMX request. /// Returns true if HX-Request header is present OR HX-History-Restore-Request is present. +#[allow(dead_code)] pub struct HtmxRequest(pub bool); impl<S> FromRequestParts<S> for HtmxRequest diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs @@ -42,7 +42,11 @@ pub async fn show_login() -> Result<Html<String>, AppError> { } pub async fn show_register() -> Result<Html<String>, AppError> { - let register = RegisterTemplate { error: None }; + let register = RegisterTemplate { + error: None, + username: String::new(), + email: String::new(), + }; let app = ApplicationTemplate { content: register.render()?, }; @@ -73,22 +77,75 @@ pub async fn login( } } +#[derive(Debug, Deserialize)] +pub struct RegisterForm { + pub username: String, + pub email: String, + pub password: String, +} + pub async fn register( State(state): State<AppState>, jar: CookieJar, - Form(form): Form<LoginForm>, + Form(form): Form<RegisterForm>, ) -> Result<(CookieJar, Redirect), Html<String>> { + // Validate username + if form.username.trim().is_empty() { + return Err(render_register_error(&form, "Username is required")); + } + + if form.username.len() < 2 || form.username.len() > 30 { + return Err(render_register_error( + &form, + "Username must be between 2 and 30 characters", + )); + } + + // Only allow alphanumeric and underscores + if !form + .username + .chars() + .all(|c| c.is_alphanumeric() || c == '_') + { + return Err(render_register_error( + &form, + "Username can only contain letters, numbers, and underscores", + )); + } + let client = trailbase::new_client(&state.trailbase_url).map_err(|e| { - render_register_error(&format!("Client error: {}", e)) + render_register_error(&form, &format!("Client error: {}", e)) })?; - match trailbase::register(&client, &form.email, &form.password).await { - Ok(tokens) => { - let jar = set_auth_cookie(jar, &tokens); - Ok((jar, Redirect::to("/"))) + // Check if username is already taken + if let Ok(Some(_)) = trailbase::get_user_profile_by_username(&client, &form.username).await { + return Err(render_register_error(&form, "Username is already taken")); + } + + // Register the user + let tokens = match trailbase::register(&client, &form.email, &form.password).await { + Ok(t) => t, + Err(e) => { + return Err(render_register_error( + &form, + &format!("Registration failed: {}", e), + )); } - Err(e) => Err(render_register_error(&format!("Registration failed: {}", e))), + }; + + // Create authenticated client + let auth_client = trailbase::client_with_tokens(&state.trailbase_url, &tokens).map_err(|e| { + render_register_error(&form, &format!("Client error: {}", e)) + })?; + + // Create user profile + if let Err(e) = trailbase::create_user_profile(&auth_client, &form.username).await { + // Log the error but don't fail registration + tracing::warn!("Failed to create user profile: {}", e); } + + let jar = set_auth_cookie(jar, &tokens); + Ok((jar, Redirect::to("/"))) } pub async fn logout(jar: CookieJar) -> (CookieJar, Redirect) { @@ -106,9 +163,11 @@ fn render_login_error(error: &str) -> Html<String> { Html(app.render().unwrap_or_default()) } -fn render_register_error(error: &str) -> Html<String> { +fn render_register_error(form: &RegisterForm, error: &str) -> Html<String> { let register = RegisterTemplate { error: Some(error.to_string()), + username: form.username.clone(), + email: form.email.clone(), }; let app = ApplicationTemplate { content: register.render().unwrap_or_default(), diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs @@ -0,0 +1,158 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{Html, Redirect}, + Form, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +use crate::error::AppError; +use crate::handlers::auth::get_tokens_from_cookies; +use crate::state::AppState; +use crate::templates::{ApplicationTemplate, ReplyFormTemplate}; +use crate::trailbase; + +#[derive(Debug, Deserialize)] +pub struct CommentForm { + pub text: String, +} + +/// Create a top-level comment on a story +pub async fn create_comment( + State(state): State<AppState>, + jar: CookieJar, + Path(story_id): Path<i64>, + Form(form): Form<CommentForm>, +) -> Result<Redirect, Html<String>> { + let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) + })?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens) + .map_err(|e| Html(format!("Client error: {}", e)))?; + + if form.text.trim().is_empty() { + return Err(Html("Comment text is required".to_string())); + } + + // Calculate path for root comment + let (path, depth) = trailbase::calculate_comment_path(&client, story_id, None) + .await + .map_err(|e| Html(format!("Failed to calculate path: {}", e)))?; + + let create_comment = trailbase::CreateComment { + story_id, + parent_id: None, + path, + depth, + text: form.text.trim().to_string(), + }; + + trailbase::create_comment(&client, create_comment) + .await + .map_err(|e| Html(format!("Failed to create comment: {}", e)))?; + + Ok(Redirect::to(&format!("/story/{}", story_id))) +} + +/// Show reply form for a comment +pub async fn show_reply_form( + State(state): State<AppState>, + jar: CookieJar, + Path(comment_id): Path<i64>, +) -> Result<Html<String>, AppError> { + let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + + if !is_logged_in { + return Ok(Html( + r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), + )); + } + + let tokens = get_tokens_from_cookies(&jar).unwrap(); + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; + + // Get the parent comment to show what we're replying to + let args = trailbase_client::ListArguments::new() + .with_filters(trailbase_client::Filter::new( + "id", + trailbase_client::CompareOp::Equal, + comment_id.to_string(), + )); + + let response = client + .records("comment") + .list::<trailbase::Comment>(args) + .await + .map_err(trailbase::ClientError::from)?; + + let parent_comment = response + .records + .into_iter() + .next() + .ok_or_else(|| AppError::NotFound("Comment not found".to_string()))?; + + let parent_author = trailbase::get_user_profile_by_id(&client, &parent_comment.created_by) + .await + .ok() + .flatten() + .map(|p| p.username); + + let reply_form = ReplyFormTemplate { + comment_id, + story_id: parent_comment.story_id, + parent_text: parent_comment.text, + parent_author, + error: None, + }; + + let app = ApplicationTemplate { + content: reply_form.render()?, + }; + Ok(Html(app.render()?)) +} + +#[derive(Debug, Deserialize)] +pub struct ReplyForm { + pub text: String, + pub story_id: i64, +} + +/// Submit a reply to a comment +pub async fn submit_reply( + State(state): State<AppState>, + jar: CookieJar, + Path(parent_id): Path<i64>, + Form(form): Form<ReplyForm>, +) -> Result<Redirect, Html<String>> { + let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) + })?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens) + .map_err(|e| Html(format!("Client error: {}", e)))?; + + if form.text.trim().is_empty() { + return Err(Html("Reply text is required".to_string())); + } + + // Calculate path for reply + let (path, depth) = trailbase::calculate_comment_path(&client, form.story_id, Some(parent_id)) + .await + .map_err(|e| Html(format!("Failed to calculate path: {}", e)))?; + + let create_comment = trailbase::CreateComment { + story_id: form.story_id, + parent_id: Some(parent_id), + path, + depth, + text: form.text.trim().to_string(), + }; + + trailbase::create_comment(&client, create_comment) + .await + .map_err(|e| Html(format!("Failed to create reply: {}", e)))?; + + Ok(Redirect::to(&format!("/story/{}", form.story_id))) +} diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs @@ -0,0 +1,196 @@ +use askama::Template; +use axum::{ + extract::{Query, State}, + response::Html, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +use crate::db::{Category, StoryWithMeta}; +use crate::error::AppError; +use crate::handlers::auth::get_tokens_from_cookies; +use crate::state::AppState; +use crate::templates::{ApplicationTemplate, FeedTemplate}; +use crate::trailbase::{self, TARGET_TYPE_STORY}; + +const PAGE_LIMIT: usize = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeedSort { + Hot, + New, + Top, +} + +impl Default for FeedSort { + fn default() -> Self { + FeedSort::Hot + } +} + +#[derive(Debug, Deserialize)] +pub struct FeedQuery { + #[serde(default)] + pub page: usize, + pub tag: Option<i64>, +} + +async fn fetch_feed( + state: &AppState, + jar: &CookieJar, + query: &FeedQuery, + sort: FeedSort, +) -> Result<(Vec<StoryWithMeta>, Vec<Category>, bool), AppError> { + let offset = query.page * PAGE_LIMIT; + + // Create client (with auth if available) + let tokens = get_tokens_from_cookies(jar); + let client = match &tokens { + Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?, + None => trailbase::new_client(&state.trailbase_url)?, + }; + + // Fetch categories/tags + let categories = trailbase::get_categories(&client, 20).await?; + + // Fetch stories based on sort and optional tag filter + let stories = match query.tag { + Some(tag_id) => { + trailbase::get_stories_by_tag(&client, tag_id, PAGE_LIMIT + 1, offset).await? + } + None => match sort { + FeedSort::Hot => { + trailbase::get_stories_hot(&client, PAGE_LIMIT + 1, offset).await? + } + FeedSort::New => { + trailbase::get_stories_new(&client, PAGE_LIMIT + 1, offset).await? + } + FeedSort::Top => { + trailbase::get_stories_top(&client, PAGE_LIMIT + 1, offset).await? + } + }, + }; + + let reached_end = stories.len() <= PAGE_LIMIT; + let stories: Vec<_> = stories.into_iter().take(PAGE_LIMIT).collect(); + + // Get story IDs for vote checking + let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); + + // Check which stories the user has voted on + let voted_ids = if tokens.is_some() { + trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) + .await + .unwrap_or_default() + } else { + vec![] + }; + + // Fetch tags for each story and build enriched stories + let mut enriched_stories = Vec::with_capacity(stories.len()); + let base_rank = query.page * PAGE_LIMIT; + + for (idx, story) in stories.into_iter().enumerate() { + let tags = trailbase::get_categories_for_story(&client, story.id) + .await + .unwrap_or_default(); + + // Get author username if available + let author_username = if let Some(ref created_by) = story.created_by { + trailbase::get_user_profile_by_id(&client, created_by) + .await + .ok() + .flatten() + .map(|p| p.username) + } else { + None + }; + + let time_ago = trailbase::time_ago(&story.published); + let user_voted = voted_ids.contains(&story.id); + let rank = base_rank + idx + 1; + + enriched_stories.push(StoryWithMeta::from_story( + story, + tags, + author_username, + time_ago, + user_voted, + rank, + )); + } + + Ok((enriched_stories, categories, reached_end)) +} + +pub async fn show_feed( + State(state): State<AppState>, + jar: CookieJar, + Query(query): Query<FeedQuery>, +) -> Result<Html<String>, AppError> { + let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::Hot).await?; + let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + + let feed = FeedTemplate { + stories, + tags, + page: query.page, + tag_id: query.tag, + reached_end, + sort: "hot".to_string(), + is_logged_in, + }; + + let app = ApplicationTemplate { + content: feed.render()?, + }; + Ok(Html(app.render()?)) +} + +pub async fn show_new( + State(state): State<AppState>, + jar: CookieJar, + Query(query): Query<FeedQuery>, +) -> Result<Html<String>, AppError> { + let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::New).await?; + let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + + let feed = FeedTemplate { + stories, + tags, + page: query.page, + tag_id: query.tag, + reached_end, + sort: "new".to_string(), + is_logged_in, + }; + + let app = ApplicationTemplate { + content: feed.render()?, + }; + Ok(Html(app.render()?)) +} + +pub async fn show_top( + State(state): State<AppState>, + jar: CookieJar, + Query(query): Query<FeedQuery>, +) -> Result<Html<String>, AppError> { + let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::Top).await?; + let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + + let feed = FeedTemplate { + stories, + tags, + page: query.page, + tag_id: query.tag, + reached_end, + sort: "top".to_string(), + is_logged_in, + }; + + let app = ApplicationTemplate { + content: feed.render()?, + }; + Ok(Html(app.render()?)) +} diff --git a/src/handlers/home.rs b/src/handlers/home.rs @@ -1,179 +0,0 @@ -use askama::Template; -use axum::{ - extract::{Query, State}, - response::Html, -}; -use axum_extra::extract::cookie::CookieJar; -use serde::Deserialize; - -use crate::db::{Category, NewsItem, NewsWithCategories}; -use crate::error::AppError; -use crate::extractors::HtmxRequest; -use crate::handlers::auth::get_tokens_from_cookies; -use crate::state::AppState; -use crate::templates::{ApplicationTemplate, IndexTemplate, OobSwapTemplate}; -use crate::trailbase; - -const PAGE_LIMIT: usize = 5; - -#[derive(Debug, Deserialize)] -pub struct HomeQuery { - #[serde(default)] - pub page: usize, - pub current_time: Option<String>, - pub category_id: Option<i64>, -} - -fn now_rfc3339() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); - let secs = duration.as_secs(); - - let days_since_epoch = secs / 86400; - let time_of_day = secs % 86400; - let hours = time_of_day / 3600; - let minutes = (time_of_day % 3600) / 60; - let seconds = time_of_day % 60; - - let mut days = days_since_epoch as i64; - let mut year = 1970i32; - - loop { - let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) { 366 } else { 365 }; - if days < days_in_year { break; } - days -= days_in_year; - year += 1; - } - - let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_months: [i64; 12] = [31, if is_leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - - let mut month = 0; - for (i, &d) in days_in_months.iter().enumerate() { - if days < d { month = i + 1; break; } - days -= d; - } - let day = days + 1; - - format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hours, minutes, seconds) -} - -fn parse_rfc3339_secs(s: &str) -> Option<i64> { - if s.len() < 19 { return None; } - let year: i32 = s[0..4].parse().ok()?; - let month: u32 = s[5..7].parse().ok()?; - let day: u32 = s[8..10].parse().ok()?; - let hour: u32 = s[11..13].parse().ok()?; - let min: u32 = s[14..16].parse().ok()?; - let sec: u32 = s[17..19].parse().ok()?; - - let mut days: i64 = 0; - for y in 1970..year { - days += if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 }; - } - - let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_months: [i64; 12] = [31, if is_leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - for m in 0..(month as usize - 1) { - days += days_in_months[m]; - } - days += day as i64 - 1; - - Some(days * 86400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64) -} - -fn resolve_current_time(current_time_param: Option<&str>) -> (i64, String) { - match current_time_param.and_then(parse_rfc3339_secs) { - Some(secs) => (secs, current_time_param.unwrap().to_string()), - None => { - let now = now_rfc3339(); - let secs = parse_rfc3339_secs(&now).unwrap_or(0); - (secs, now) - } - } -} - -fn format_time_since_published(published: &str, current_time_secs: i64) -> String { - let published_secs = parse_rfc3339_secs(published).unwrap_or(current_time_secs); - let diff = current_time_secs - published_secs; - let days = diff / 86400; - let hours = (diff % 86400) / 3600; - format!("{} days, {} hours ago", days, hours) -} - -fn enrich_news( - rows: Vec<NewsItem>, - categories: Vec<Vec<Category>>, - current_time_secs: i64, -) -> Vec<NewsWithCategories> { - rows.into_iter() - .zip(categories) - .map(|(row, cats)| { - let time_since = format_time_since_published(&row.published, current_time_secs); - NewsWithCategories::from_news_item(row, cats, time_since) - }) - .collect() -} - -pub async fn show_home_page( - State(state): State<AppState>, - jar: CookieJar, - HtmxRequest(is_htmx): HtmxRequest, - Query(query): Query<HomeQuery>, -) -> Result<Html<String>, AppError> { - let (current_time_secs, current_time_str) = - resolve_current_time(query.current_time.as_deref()); - let offset = query.page * PAGE_LIMIT; - - // Create client (with auth if available) - let client = match get_tokens_from_cookies(&jar) { - Some(tokens) => trailbase::client_with_tokens(&state.trailbase_url, &tokens)?, - None => trailbase::new_client(&state.trailbase_url)?, - }; - - // Fetch data - let categories = trailbase::get_categories(&client, 20).await?; - let rows = match query.category_id { - Some(cat_id) => { - trailbase::get_news_by_category(&client, cat_id, PAGE_LIMIT + 1, offset).await? - } - None => trailbase::get_news(&client, PAGE_LIMIT + 1, offset).await?, - }; - - let reached_end = rows.len() <= PAGE_LIMIT; - let rows: Vec<_> = rows.into_iter().take(PAGE_LIMIT).collect(); - - // Fetch categories for each news item - let mut news_categories = Vec::with_capacity(rows.len()); - for row in &rows { - news_categories.push(trailbase::get_categories_for_news(&client, row.id).await?); - } - - let news = enrich_news(rows, news_categories, current_time_secs); - - if is_htmx { - let template = OobSwapTemplate { - news, - page: query.page as i64, - current_time: current_time_str, - category_id: query.category_id, - reached_end, - oob: true, - }; - Ok(Html(template.render()?)) - } else { - let index = IndexTemplate { - news, - categories, - page: query.page as i64, - current_time: current_time_str, - category_id: query.category_id, - reached_end, - oob: false, - }; - let app = ApplicationTemplate { - content: index.render()?, - }; - Ok(Html(app.render()?)) - } -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs @@ -1,9 +1,19 @@ pub mod auth; -pub mod home; +pub mod comment; +pub mod feed; pub mod search; pub mod settings; +pub mod story; +pub mod tag; +pub mod user; +pub mod vote; pub use auth::*; -pub use home::*; +pub use comment::*; +pub use feed::*; pub use search::*; pub use settings::*; +pub use story::*; +pub use tag::*; +pub use user::*; +pub use vote::*; diff --git a/src/handlers/search.rs b/src/handlers/search.rs @@ -6,12 +6,16 @@ use serde::Deserialize; use crate::error::AppError; use crate::handlers::auth::get_tokens_from_cookies; use crate::state::AppState; -use crate::templates::{SearchResultsTemplate, SearchTemplate}; +use crate::templates::{ApplicationTemplate, SearchResultsTemplate, SearchTemplate}; use crate::trailbase; -pub async fn open_search() -> Result<Html<String>, AppError> { - let template = SearchTemplate; - Ok(Html(template.render()?)) +pub async fn open_search(jar: CookieJar) -> Result<Html<String>, AppError> { + let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + let template = SearchTemplate { is_logged_in }; + let app = ApplicationTemplate { + content: template.render()?, + }; + Ok(Html(app.render()?)) } #[derive(Debug, Deserialize)] @@ -29,12 +33,12 @@ pub async fn search( None => trailbase::new_client(&state.trailbase_url)?, }; - let news = if form.search.is_empty() { + let results = if form.search.is_empty() { Vec::new() } else { - trailbase::search_news(&client, &form.search, 10).await? + trailbase::search_stories(&client, &form.search, 20).await? }; - let template = SearchResultsTemplate { news }; + let template = SearchResultsTemplate { results }; Ok(Html(template.render()?)) } diff --git a/src/handlers/story.rs b/src/handlers/story.rs @@ -0,0 +1,197 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{Html, Redirect}, + Form, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +use crate::db::{CommentWithMeta, StoryWithMeta}; +use crate::error::AppError; +use crate::handlers::auth::get_tokens_from_cookies; +use crate::state::AppState; +use crate::templates::{ApplicationTemplate, StoryTemplate, SubmitTemplate}; +use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; + +pub async fn show_story( + State(state): State<AppState>, + jar: CookieJar, + Path(id): Path<i64>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar); + let client = match &tokens { + Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?, + None => trailbase::new_client(&state.trailbase_url)?, + }; + + // Fetch the story + let story = trailbase::get_story(&client, id) + .await? + .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; + + // Fetch comments (already ordered by path from the API) + let comments = trailbase::get_comments_for_story(&client, id).await?; + + // Get tags for the story + let tags = trailbase::get_categories_for_story(&client, id) + .await + .unwrap_or_default(); + + // Get author username + let author_username = if let Some(ref created_by) = story.created_by { + trailbase::get_user_profile_by_id(&client, created_by) + .await + .ok() + .flatten() + .map(|p| p.username) + } else { + None + }; + + // Check if user voted on the story + let story_voted = if tokens.is_some() { + trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &[id]) + .await + .map(|v| v.contains(&id)) + .unwrap_or(false) + } else { + false + }; + + // Check which comments the user voted on + let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect(); + let voted_comment_ids = if tokens.is_some() && !comment_ids.is_empty() { + trailbase::get_user_votes(&client, TARGET_TYPE_COMMENT, &comment_ids) + .await + .unwrap_or_default() + } else { + vec![] + }; + + // Enrich story + let time_ago = trailbase::time_ago(&story.published); + let story_with_meta = StoryWithMeta::from_story(story, tags, author_username, time_ago, story_voted, 0); + + // Enrich comments (flat list sorted by path for display) + let mut enriched_comments = Vec::with_capacity(comments.len()); + for comment in comments { + let comment_author = trailbase::get_user_profile_by_id(&client, &comment.created_by) + .await + .ok() + .flatten() + .map(|p| p.username); + let comment_time_ago = trailbase::time_ago(&comment.created_at); + let comment_voted = voted_comment_ids.contains(&comment.id); + + enriched_comments.push(CommentWithMeta::from_comment( + comment, + comment_author, + comment_time_ago, + comment_voted, + )); + } + + let is_logged_in = tokens.is_some(); + + let story_template = StoryTemplate { + story: story_with_meta, + comments: enriched_comments, + is_logged_in, + }; + + let app = ApplicationTemplate { + content: story_template.render()?, + }; + Ok(Html(app.render()?)) +} + +pub async fn show_submit(jar: CookieJar) -> Result<Html<String>, AppError> { + let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + + if !is_logged_in { + // Redirect to login if not logged in + return Ok(Html( + r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), + )); + } + + let submit = SubmitTemplate { + error: None, + title: String::new(), + url: String::new(), + text: String::new(), + }; + + let app = ApplicationTemplate { + content: submit.render()?, + }; + Ok(Html(app.render()?)) +} + +#[derive(Debug, Deserialize)] +pub struct SubmitForm { + pub title: String, + pub url: Option<String>, + pub text: Option<String>, +} + +pub async fn submit_story( + State(state): State<AppState>, + jar: CookieJar, + Form(form): Form<SubmitForm>, +) -> Result<Redirect, Html<String>> { + let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) + })?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens) + .map_err(|e| render_submit_error(&form, &format!("Client error: {}", e)))?; + + // Validate: must have either URL or text + let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); + let text = form.text.as_ref().filter(|t| !t.trim().is_empty()); + + if url.is_none() && text.is_none() { + return Err(render_submit_error( + &form, + "Please provide either a URL or text", + )); + } + + if form.title.trim().is_empty() { + return Err(render_submit_error(&form, "Title is required")); + } + + // Extract domain from URL + let domain = url.and_then(|u| trailbase::extract_domain(u)); + + // Create the story + let create_story = trailbase::CreateStory { + url: url.map(|s| s.to_string()), + title: form.title.trim().to_string(), + text: text.map(|s| s.to_string()).unwrap_or_default(), + domain, + published: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + language: "en".to_string(), + }; + + let story = trailbase::create_story(&client, create_story) + .await + .map_err(|e| render_submit_error(&form, &format!("Failed to create story: {}", e)))?; + + Ok(Redirect::to(&format!("/story/{}", story.id))) +} + +fn render_submit_error(form: &SubmitForm, error: &str) -> Html<String> { + let submit = SubmitTemplate { + error: Some(error.to_string()), + title: form.title.clone(), + url: form.url.clone().unwrap_or_default(), + text: form.text.clone().unwrap_or_default(), + }; + let app = ApplicationTemplate { + content: submit.render().unwrap_or_default(), + }; + Html(app.render().unwrap_or_default()) +} diff --git a/src/handlers/tag.rs b/src/handlers/tag.rs @@ -0,0 +1,108 @@ +use askama::Template; +use axum::{ + extract::{Path, Query, State}, + response::Html, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +use crate::db::StoryWithMeta; +use crate::error::AppError; +use crate::handlers::auth::get_tokens_from_cookies; +use crate::state::AppState; +use crate::templates::{ApplicationTemplate, TagTemplate}; +use crate::trailbase::{self, TARGET_TYPE_STORY}; + +const PAGE_LIMIT: usize = 30; + +#[derive(Debug, Deserialize)] +pub struct TagQuery { + #[serde(default)] + pub page: usize, +} + +pub async fn show_tag( + State(state): State<AppState>, + jar: CookieJar, + Path(tag_name): Path<String>, + Query(query): Query<TagQuery>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar); + let client = match &tokens { + Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?, + None => trailbase::new_client(&state.trailbase_url)?, + }; + + // Find the category by name + let categories = trailbase::get_categories(&client, 100).await?; + let category = categories + .iter() + .find(|c| c.name.as_deref() == Some(&tag_name)) + .ok_or_else(|| AppError::NotFound(format!("Tag '{}' not found", tag_name)))?; + + let offset = query.page * PAGE_LIMIT; + let stories = trailbase::get_stories_by_tag(&client, category.id, PAGE_LIMIT + 1, offset).await?; + + let reached_end = stories.len() <= PAGE_LIMIT; + let stories: Vec<_> = stories.into_iter().take(PAGE_LIMIT).collect(); + + // Get story IDs for vote checking + let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); + + // Check which stories the user has voted on + let voted_ids = if tokens.is_some() && !story_ids.is_empty() { + trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) + .await + .unwrap_or_default() + } else { + vec![] + }; + + // Enrich stories + let mut enriched_stories = Vec::with_capacity(stories.len()); + let base_rank = query.page * PAGE_LIMIT; + + for (idx, story) in stories.into_iter().enumerate() { + let tags = trailbase::get_categories_for_story(&client, story.id) + .await + .unwrap_or_default(); + + let author_username = if let Some(ref created_by) = story.created_by { + trailbase::get_user_profile_by_id(&client, created_by) + .await + .ok() + .flatten() + .map(|p| p.username) + } else { + None + }; + + let time_ago = trailbase::time_ago(&story.published); + let user_voted = voted_ids.contains(&story.id); + let rank = base_rank + idx + 1; + + enriched_stories.push(StoryWithMeta::from_story( + story, + tags, + author_username, + time_ago, + user_voted, + rank, + )); + } + + let is_logged_in = tokens.is_some(); + + let tag_template = TagTemplate { + tag_name: tag_name.clone(), + stories: enriched_stories, + page: query.page, + reached_end, + is_logged_in, + }; + + let app = ApplicationTemplate { + content: tag_template.render()?, + }; + Ok(Html(app.render()?)) +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs @@ -0,0 +1,108 @@ +use askama::Template; +use axum::{ + extract::{Path, Query, State}, + response::Html, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +use crate::db::{CommentWithMeta, StoryWithMeta}; +use crate::error::AppError; +use crate::handlers::auth::get_tokens_from_cookies; +use crate::state::AppState; +use crate::templates::{ApplicationTemplate, UserTemplate}; +use crate::trailbase::{self, TARGET_TYPE_STORY}; + +#[derive(Debug, Deserialize)] +pub struct UserQuery { + #[serde(default = "default_tab")] + pub tab: String, +} + +fn default_tab() -> String { + "submissions".to_string() +} + +pub async fn show_user( + State(state): State<AppState>, + jar: CookieJar, + Path(username): Path<String>, + Query(query): Query<UserQuery>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar); + let client = match &tokens { + Some(t) => trailbase::client_with_tokens(&state.trailbase_url, t)?, + None => trailbase::new_client(&state.trailbase_url)?, + }; + + // Get user profile + let profile = trailbase::get_user_profile_by_username(&client, &username) + .await? + .ok_or_else(|| AppError::NotFound(format!("User '{}' not found", username)))?; + + // Fetch content based on tab + let mut submissions: Vec<StoryWithMeta> = Vec::new(); + let mut comments: Vec<CommentWithMeta> = Vec::new(); + + match query.tab.as_str() { + "submissions" => { + let stories = trailbase::get_user_stories(&client, &profile.user_id, 30, 0).await?; + + // Get voted story IDs if logged in + let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); + let voted_ids = if tokens.is_some() && !story_ids.is_empty() { + trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) + .await + .unwrap_or_default() + } else { + vec![] + }; + + for (idx, story) in stories.into_iter().enumerate() { + let tags = trailbase::get_categories_for_story(&client, story.id) + .await + .unwrap_or_default(); + let time_ago = trailbase::time_ago(&story.published); + let user_voted = voted_ids.contains(&story.id); + + submissions.push(StoryWithMeta::from_story( + story, + tags, + Some(username.clone()), + time_ago, + user_voted, + idx + 1, + )); + } + } + "comments" => { + let user_comments = trailbase::get_user_comments(&client, &profile.user_id, 30, 0).await?; + + for comment in user_comments { + let time_ago = trailbase::time_ago(&comment.created_at); + comments.push(CommentWithMeta::from_comment( + comment, + Some(username.clone()), + time_ago, + false, + )); + } + } + _ => {} + } + + let is_logged_in = tokens.is_some(); + + let user_template = UserTemplate { + profile, + submissions, + comments, + tab: query.tab, + is_logged_in, + }; + + let app = ApplicationTemplate { + content: user_template.render()?, + }; + Ok(Html(app.render()?)) +} diff --git a/src/handlers/vote.rs b/src/handlers/vote.rs @@ -0,0 +1,102 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::Html, +}; +use axum_extra::extract::cookie::CookieJar; + +use crate::error::AppError; +use crate::handlers::auth::get_tokens_from_cookies; +use crate::state::AppState; +use crate::templates::VoteButtonTemplate; +use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; + +/// Vote on a story (HTMX endpoint) +pub async fn vote_story( + State(state): State<AppState>, + jar: CookieJar, + Path(story_id): Path<i64>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar) + .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; + + // Check if already voted + let voted = trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &[story_id]) + .await + .map(|v| v.contains(&story_id)) + .unwrap_or(false); + + if voted { + // Unvote + trailbase::unvote(&client, TARGET_TYPE_STORY, story_id).await?; + } else { + // Vote + trailbase::vote(&client, TARGET_TYPE_STORY, story_id).await?; + } + + // Get updated story score + let story = trailbase::get_story(&client, story_id).await?; + let new_score = story.map(|s| s.score).unwrap_or(0); + let new_voted = !voted; + + let template = VoteButtonTemplate { + target_type: "story", + target_id: story_id, + score: new_score, + voted: new_voted, + }; + + Ok(Html(template.render()?)) +} + +/// Vote on a comment (HTMX endpoint) +pub async fn vote_comment( + State(state): State<AppState>, + jar: CookieJar, + Path(comment_id): Path<i64>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar) + .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; + + // Check if already voted + let voted = trailbase::get_user_votes(&client, TARGET_TYPE_COMMENT, &[comment_id]) + .await + .map(|v| v.contains(&comment_id)) + .unwrap_or(false); + + if voted { + // Unvote + trailbase::unvote(&client, TARGET_TYPE_COMMENT, comment_id).await?; + } else { + // Vote + trailbase::vote(&client, TARGET_TYPE_COMMENT, comment_id).await?; + } + + // Get updated comment score + let args = trailbase_client::ListArguments::new() + .with_filters(trailbase_client::Filter::new( + "id", + trailbase_client::CompareOp::Equal, + comment_id.to_string(), + )); + let response = client + .records("comment") + .list::<trailbase::Comment>(args) + .await + .map_err(trailbase::ClientError::from)?; + let new_score = response.records.first().map(|c| c.score).unwrap_or(0); + let new_voted = !voted; + + let template = VoteButtonTemplate { + target_type: "comment", + target_id: comment_id, + score: new_score, + voted: new_voted, + }; + + Ok(Html(template.render()?)) +} diff --git a/src/routes.rs b/src/routes.rs @@ -45,16 +45,47 @@ async fn static_404() -> impl IntoResponse { pub fn create_router(state: AppState) -> Router { Router::new() - .route("/", get(handlers::show_home_page)) + // Feeds + .route("/", get(handlers::show_feed)) + .route("/new", get(handlers::show_new)) + .route("/top", get(handlers::show_top)) + + // Stories + .route("/story/{id}", get(handlers::show_story)) + .route("/submit", get(handlers::show_submit)) + .route("/submit", post(handlers::submit_story)) + + // Comments + .route("/story/{id}/comment", post(handlers::create_comment)) + .route("/comment/{id}/reply", get(handlers::show_reply_form)) + .route("/comment/{id}/reply", post(handlers::submit_reply)) + + // Voting (HTMX) + .route("/vote/story/{id}", post(handlers::vote_story)) + .route("/vote/comment/{id}", post(handlers::vote_comment)) + + // Users + .route("/user/{username}", get(handlers::show_user)) + + // Tags + .route("/tag/{name}", get(handlers::show_tag)) + + // Search .route("/search", get(handlers::open_search)) .route("/search", post(handlers::search)) + + // Settings .route("/settings", get(handlers::open_settings)) .route("/settings/tab", get(handlers::open_settings_tab)) + + // Auth .route("/login", get(handlers::show_login)) .route("/login", post(handlers::login)) .route("/register", get(handlers::show_register)) .route("/register", post(handlers::register)) .route("/logout", get(handlers::logout)) + + // Static files .route("/static/style.css", get(serve_style)) .route("/static/{*path}", get(static_404)) .with_state(state) diff --git a/src/templates.rs b/src/templates.rs @@ -1,6 +1,7 @@ use askama::Template; -use crate::db::{Category, NewsWithCategories, SearchResult}; +use crate::db::{Category, CommentWithMeta, StoryWithMeta, UserProfile}; +use crate::trailbase::SearchResult; #[derive(Template)] #[template(path = "application.html")] @@ -9,58 +10,85 @@ pub struct ApplicationTemplate { } #[derive(Template)] -#[template(path = "index.html")] -pub struct IndexTemplate { - pub news: Vec<NewsWithCategories>, - pub categories: Vec<Category>, - pub page: i64, - pub current_time: String, - pub category_id: Option<i64>, +#[template(path = "feed.html")] +pub struct FeedTemplate { + pub stories: Vec<StoryWithMeta>, + pub tags: Vec<Category>, + pub page: usize, + pub tag_id: Option<i64>, pub reached_end: bool, - pub oob: bool, + pub sort: String, + pub is_logged_in: bool, } #[derive(Template)] -#[template(path = "oob_swap.html")] -pub struct OobSwapTemplate { - pub news: Vec<NewsWithCategories>, - pub page: i64, - pub current_time: String, - pub category_id: Option<i64>, - pub reached_end: bool, - pub oob: bool, +#[template(path = "story.html")] +pub struct StoryTemplate { + pub story: StoryWithMeta, + pub comments: Vec<CommentWithMeta>, + pub is_logged_in: bool, +} + +#[derive(Template)] +#[template(path = "submit.html")] +pub struct SubmitTemplate { + pub error: Option<String>, + pub title: String, + pub url: String, + pub text: String, } #[derive(Template)] -#[template(path = "news_cards.html")] -pub struct NewsCardsTemplate<'a> { - pub news: &'a [NewsWithCategories], +#[template(path = "reply_form.html")] +pub struct ReplyFormTemplate { + pub comment_id: i64, + pub story_id: i64, + pub parent_text: String, + pub parent_author: Option<String>, + pub error: Option<String>, } #[derive(Template)] -#[template(path = "news_card.html")] -pub struct NewsCardTemplate<'a> { - pub new: &'a NewsWithCategories, +#[template(path = "vote_button.html")] +pub struct VoteButtonTemplate<'a> { + pub target_type: &'a str, + pub target_id: i64, + #[allow(dead_code)] + pub score: i64, + pub voted: bool, } #[derive(Template)] -#[template(path = "load_more.html")] -pub struct LoadMoreTemplate { - pub page: i64, - pub current_time: String, - pub category_id: Option<i64>, +#[template(path = "user.html")] +pub struct UserTemplate { + pub profile: UserProfile, + pub submissions: Vec<StoryWithMeta>, + pub comments: Vec<CommentWithMeta>, + pub tab: String, + pub is_logged_in: bool, +} + +#[derive(Template)] +#[template(path = "tag.html")] +pub struct TagTemplate { + pub tag_name: String, + pub stories: Vec<StoryWithMeta>, + pub page: usize, pub reached_end: bool, - pub oob: bool, + pub is_logged_in: bool, } #[derive(Template)] #[template(path = "search.html")] -pub struct SearchTemplate; +pub struct SearchTemplate { + #[allow(dead_code)] + pub is_logged_in: bool, +} #[derive(Template)] #[template(path = "search_results.html")] pub struct SearchResultsTemplate { - pub news: Vec<SearchResult>, + pub results: Vec<SearchResult>, } #[derive(Template)] @@ -85,4 +113,6 @@ pub struct LoginTemplate { #[template(path = "register.html")] pub struct RegisterTemplate { pub error: Option<String>, + pub username: String, + pub email: String, } diff --git a/src/trailbase.rs b/src/trailbase.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; use trailbase_client::{Client, ClientOptions, CompareOp, Filter, ListArguments, Pagination, Tokens}; -pub use crate::trailbase_types::{Category, NewsItem}; +pub use crate::trailbase_types::{Category, Story, Comment, Vote, UserProfile}; + +// Target type constants for votes +pub const TARGET_TYPE_STORY: i64 = 1; +pub const TARGET_TYPE_COMMENT: i64 = 2; #[derive(Debug, thiserror::Error)] pub enum ClientError { @@ -13,8 +17,11 @@ pub enum ClientError { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResult { + pub id: i64, pub title: String, pub text: String, + pub score: i64, + pub comment_count: i64, } /// Tokens stored in cookies for authenticated requests @@ -42,44 +49,119 @@ pub fn client_with_tokens(base_url: &str, tokens: &StoredTokens) -> Result<Clien Ok(Client::new(base_url, Some(opts))?) } -pub async fn get_news( +// ============================================================================ +// Story functions +// ============================================================================ + +/// Get stories ordered by published date (newest first) +pub async fn get_stories_new( client: &Client, limit: usize, offset: usize, -) -> Result<Vec<NewsItem>, ClientError> { +) -> Result<Vec<Story>, ClientError> { let args = ListArguments::new() .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) .with_order(&["-published"]); - let response = client.records("news_item").list::<NewsItem>(args).await?; + let response = client.records("story").list::<Story>(args).await?; Ok(response.records) } -pub async fn get_news_by_category( +/// Get stories ordered by score (highest first) +pub async fn get_stories_top( + client: &Client, + limit: usize, + offset: usize, +) -> Result<Vec<Story>, ClientError> { + let args = ListArguments::new() + .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) + .with_order(&["-score", "-published"]); + + let response = client.records("story").list::<Story>(args).await?; + Ok(response.records) +} + +/// Get stories with hot ranking (client-side calculation) +pub async fn get_stories_hot( + client: &Client, + limit: usize, + offset: usize, +) -> Result<Vec<Story>, ClientError> { + // Fetch more than needed for client-side ranking + let fetch_limit = (limit + offset) * 2; + let args = ListArguments::new() + .with_pagination(Pagination::new().with_limit(fetch_limit)) + .with_order(&["-published"]); + + let response = client.records("story").list::<Story>(args).await?; + let mut stories = response.records; + + // Sort by hot score + stories.sort_by(|a, b| { + let score_a = hot_score(a.score, &a.published); + let score_b = hot_score(b.score, &b.published); + score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + // Apply offset and limit + let result: Vec<Story> = stories.into_iter().skip(offset).take(limit).collect(); + Ok(result) +} + +/// Get a single story by ID +pub async fn get_story(client: &Client, id: i64) -> Result<Option<Story>, ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("id", CompareOp::Equal, id.to_string())); + + let response = client.records("story").list::<Story>(args).await?; + Ok(response.records.into_iter().next()) +} + +/// Create a new story +#[derive(Debug, Serialize)] +pub struct CreateStory { + pub url: Option<String>, + pub title: String, + pub text: String, + pub domain: Option<String>, + pub published: String, + pub language: String, +} + +pub async fn create_story(client: &Client, story: CreateStory) -> Result<Story, ClientError> { + let id_str = client.records("story").create(&story).await?; + let id: i64 = id_str.parse().unwrap_or(0); + get_story(client, id).await?.ok_or_else(|| { + ClientError::Auth("Failed to retrieve created story".to_string()) + }) +} + +/// Get stories by tag/category +pub async fn get_stories_by_tag( client: &Client, category_id: i64, limit: usize, offset: usize, -) -> Result<Vec<NewsItem>, ClientError> { - // First get news_item_ids for this category +) -> Result<Vec<Story>, ClientError> { + // First get story IDs for this category let junction_args = ListArguments::new() .with_filters(Filter::new("category_id", CompareOp::Equal, category_id.to_string())); let junction: trailbase_client::ListResponse<serde_json::Value> = - client.records("news_item_category").list(junction_args).await?; + client.records("story_category").list(junction_args).await?; - let news_ids: Vec<i64> = junction + let story_ids: Vec<i64> = junction .records .iter() .filter_map(|v| v.get("news_item_id").and_then(|id| id.as_i64())) .collect(); - if news_ids.is_empty() { + if story_ids.is_empty() { return Ok(vec![]); } - // Build filters for news items (OR of all IDs) - let filters: Vec<Filter> = news_ids + // Build filters for stories (OR of all IDs) + let filters: Vec<Filter> = story_ids .iter() .map(|id| Filter::new("id", CompareOp::Equal, id.to_string())) .collect(); @@ -89,10 +171,245 @@ pub async fn get_news_by_category( .with_order(&["-published"]) .with_filters(filters); - let response = client.records("news_item").list::<NewsItem>(args).await?; + let response = client.records("story").list::<Story>(args).await?; + Ok(response.records) +} + +// ============================================================================ +// Comment functions +// ============================================================================ + +/// Get all comments for a story, ordered by path (thread order) +pub async fn get_comments_for_story( + client: &Client, + story_id: i64, +) -> Result<Vec<Comment>, ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("story_id", CompareOp::Equal, story_id.to_string())) + .with_order(&["path"]) + .with_pagination(Pagination::new().with_limit(1000)); + + let response = client.records("comment").list::<Comment>(args).await?; + Ok(response.records) +} + +/// Create a new comment +#[derive(Debug, Serialize)] +pub struct CreateComment { + pub story_id: i64, + pub parent_id: Option<i64>, + pub path: String, + pub depth: i64, + pub text: String, +} + +pub async fn create_comment(client: &Client, comment: CreateComment) -> Result<Comment, ClientError> { + let id_str = client.records("comment").create(&comment).await?; + let id: i64 = id_str.parse().unwrap_or(0); + + // Fetch the created comment + let args = ListArguments::new() + .with_filters(Filter::new("id", CompareOp::Equal, id.to_string())); + let response = client.records("comment").list::<Comment>(args).await?; + response.records.into_iter().next().ok_or_else(|| { + ClientError::Auth("Failed to retrieve created comment".to_string()) + }) +} + +/// Calculate the path for a new comment +pub async fn calculate_comment_path( + client: &Client, + story_id: i64, + parent_id: Option<i64>, +) -> Result<(String, i64), ClientError> { + match parent_id { + Some(pid) => { + // Get parent comment + let args = ListArguments::new() + .with_filters(Filter::new("id", CompareOp::Equal, pid.to_string())); + let response = client.records("comment").list::<Comment>(args).await?; + + if let Some(parent) = response.records.into_iter().next() { + // Count siblings to determine new ID + let sibling_args = ListArguments::new() + .with_filters(Filter::new("parent_id", CompareOp::Equal, pid.to_string())); + let siblings = client.records("comment").list::<Comment>(sibling_args).await?; + let sibling_count = siblings.records.len() + 1; + + let path = format!("{}.{}", parent.path, sibling_count); + let depth = parent.depth + 1; + Ok((path, depth)) + } else { + // Parent not found, treat as root + let root_args = ListArguments::new() + .with_filters(vec![ + Filter::new("story_id", CompareOp::Equal, story_id.to_string()), + Filter::new("depth", CompareOp::Equal, "0".to_string()), + ]); + let roots = client.records("comment").list::<Comment>(root_args).await?; + let root_count = roots.records.len() + 1; + Ok((root_count.to_string(), 0)) + } + } + None => { + // Root level comment + let root_args = ListArguments::new() + .with_filters(vec![ + Filter::new("story_id", CompareOp::Equal, story_id.to_string()), + Filter::new("depth", CompareOp::Equal, "0".to_string()), + ]); + let roots = client.records("comment").list::<Comment>(root_args).await?; + let root_count = roots.records.len() + 1; + Ok((root_count.to_string(), 0)) + } + } +} + +// ============================================================================ +// Vote functions +// ============================================================================ + +/// Create a vote +#[derive(Debug, Serialize)] +struct CreateVote { + target_type: i64, + target_id: i64, +} + +pub async fn vote(client: &Client, target_type: i64, target_id: i64) -> Result<(), ClientError> { + let vote_data = CreateVote { target_type, target_id }; + client.records("vote").create(&vote_data).await?; + Ok(()) +} + +/// Remove a vote +pub async fn unvote(client: &Client, target_type: i64, target_id: i64) -> Result<(), ClientError> { + // First find the vote + let args = ListArguments::new() + .with_filters(vec![ + Filter::new("target_type", CompareOp::Equal, target_type.to_string()), + Filter::new("target_id", CompareOp::Equal, target_id.to_string()), + ]); + + let response = client.records("vote").list::<Vote>(args).await?; + if let Some(vote) = response.records.into_iter().next() { + client.records("vote").delete(&vote.id.to_string()).await?; + } + Ok(()) +} + +/// Check if user has voted on targets +pub async fn get_user_votes( + client: &Client, + target_type: i64, + target_ids: &[i64], +) -> Result<Vec<i64>, ClientError> { + if target_ids.is_empty() { + return Ok(vec![]); + } + + let mut filters = vec![ + Filter::new("target_type", CompareOp::Equal, target_type.to_string()), + ]; + + // Add filters for each target ID + let id_filters: Vec<Filter> = target_ids + .iter() + .map(|id| Filter::new("target_id", CompareOp::Equal, id.to_string())) + .collect(); + filters.extend(id_filters); + + let args = ListArguments::new().with_filters(filters); + + let response = client.records("vote").list::<Vote>(args).await?; + let voted_ids: Vec<i64> = response.records.iter().map(|v| v.target_id).collect(); + Ok(voted_ids) +} + +// ============================================================================ +// User Profile functions +// ============================================================================ + +/// Get user profile by username +pub async fn get_user_profile_by_username( + client: &Client, + username: &str, +) -> Result<Option<UserProfile>, ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("username", CompareOp::Equal, username)); + + let response = client.records("user_profile").list::<UserProfile>(args).await?; + Ok(response.records.into_iter().next()) +} + +/// Get user profile by user ID +pub async fn get_user_profile_by_id( + client: &Client, + user_id: &str, +) -> Result<Option<UserProfile>, ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("user_id", CompareOp::Equal, user_id)); + + let response = client.records("user_profile").list::<UserProfile>(args).await?; + Ok(response.records.into_iter().next()) +} + +/// Create a new user profile +#[derive(Debug, Serialize)] +pub struct CreateUserProfile { + pub username: String, +} + +pub async fn create_user_profile( + client: &Client, + username: &str, +) -> Result<UserProfile, ClientError> { + let profile = CreateUserProfile { + username: username.to_string(), + }; + client.records("user_profile").create(&profile).await?; + // Fetch the created profile + get_user_profile_by_username(client, username).await?.ok_or_else(|| { + ClientError::Auth("Failed to retrieve created profile".to_string()) + }) +} + +/// Get stories created by a user +pub async fn get_user_stories( + client: &Client, + user_id: &str, + limit: usize, + offset: usize, +) -> Result<Vec<Story>, ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("created_by", CompareOp::Equal, user_id)) + .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) + .with_order(&["-published"]); + + let response = client.records("story").list::<Story>(args).await?; + Ok(response.records) +} + +/// Get comments created by a user +pub async fn get_user_comments( + client: &Client, + user_id: &str, + limit: usize, + offset: usize, +) -> Result<Vec<Comment>, ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("created_by", CompareOp::Equal, user_id)) + .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) + .with_order(&["-created_at"]); + + let response = client.records("comment").list::<Comment>(args).await?; Ok(response.records) } +// ============================================================================ +// Category/Tag functions +// ============================================================================ + pub async fn get_categories(client: &Client, limit: usize) -> Result<Vec<Category>, ClientError> { let args = ListArguments::new() .with_pagination(Pagination::new().with_limit(limit)); @@ -101,16 +418,16 @@ pub async fn get_categories(client: &Client, limit: usize) -> Result<Vec<Categor Ok(response.records) } -pub async fn get_categories_for_news( +pub async fn get_categories_for_story( client: &Client, - news_item_id: i64, + story_id: i64, ) -> Result<Vec<Category>, ClientError> { // Get category IDs from junction table let junction_args = ListArguments::new() - .with_filters(Filter::new("news_item_id", CompareOp::Equal, news_item_id.to_string())); + .with_filters(Filter::new("news_item_id", CompareOp::Equal, story_id.to_string())); let junction: trailbase_client::ListResponse<serde_json::Value> = - client.records("news_item_category").list(junction_args).await?; + client.records("story_category").list(junction_args).await?; let category_ids: Vec<i64> = junction .records @@ -135,7 +452,33 @@ pub async fn get_categories_for_news( Ok(response.records) } -pub async fn search_news( +/// Add a tag to a story +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct CreateStoryCategory { + news_item_id: i64, + category_id: i64, +} + +#[allow(dead_code)] +pub async fn add_tag_to_story( + client: &Client, + story_id: i64, + category_id: i64, +) -> Result<(), ClientError> { + let junction = CreateStoryCategory { + news_item_id: story_id, + category_id, + }; + client.records("story_category").create(&junction).await?; + Ok(()) +} + +// ============================================================================ +// Search functions +// ============================================================================ + +pub async fn search_stories( client: &Client, query: &str, limit: usize, @@ -144,17 +487,24 @@ pub async fn search_news( .with_pagination(Pagination::new().with_limit(limit)) .with_filters(Filter::new("title", CompareOp::Like, query)); - let response = client.records("news_item").list::<NewsItem>(args).await?; + let response = client.records("story").list::<Story>(args).await?; Ok(response .records .into_iter() - .map(|n| SearchResult { - title: n.title, - text: n.text, + .map(|s| SearchResult { + id: s.id, + title: s.title, + text: s.text, + score: s.score, + comment_count: s.comment_count, }) .collect()) } +// ============================================================================ +// Auth functions +// ============================================================================ + pub async fn login(client: &Client, email: &str, password: &str) -> Result<StoredTokens, ClientError> { client.login(email, password).await?; @@ -183,6 +533,7 @@ pub async fn register(client: &Client, email: &str, password: &str) -> Result<St }) } +#[allow(dead_code)] pub async fn refresh_tokens(client: &Client) -> Result<StoredTokens, ClientError> { client.refresh().await?; @@ -195,3 +546,81 @@ pub async fn refresh_tokens(client: &Client) -> Result<StoredTokens, ClientError refresh_token: tokens.refresh_token, }) } + +// ============================================================================ +// Helper functions +// ============================================================================ + +/// Calculate hot score for ranking (HN-style algorithm) +pub fn hot_score(points: i64, published: &str) -> f64 { + let age_hours = hours_since(published); + (points as f64) / (age_hours + 2.0).powf(1.8) +} + +/// Calculate hours since a timestamp +fn hours_since(timestamp: &str) -> f64 { + use chrono::{DateTime, Utc}; + + let published = timestamp + .parse::<DateTime<Utc>>() + .or_else(|_| { + // Try parsing as naive datetime + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") + .map(|dt| dt.and_utc()) + }) + .unwrap_or_else(|_| Utc::now()); + + let now = Utc::now(); + let duration = now.signed_duration_since(published); + duration.num_minutes() as f64 / 60.0 +} + +/// Format a timestamp as "X ago" string +pub fn time_ago(timestamp: &str) -> String { + use chrono::{DateTime, Utc}; + + let published = timestamp + .parse::<DateTime<Utc>>() + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") + .map(|dt| dt.and_utc()) + }) + .unwrap_or_else(|_| Utc::now()); + + let now = Utc::now(); + let duration = now.signed_duration_since(published); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + + if days > 0 { + if days == 1 { + "1 day ago".to_string() + } else { + format!("{} days ago", days) + } + } else if hours > 0 { + if hours == 1 { + "1 hour ago".to_string() + } else { + format!("{} hours ago", hours) + } + } else if minutes > 0 { + if minutes == 1 { + "1 minute ago".to_string() + } else { + format!("{} minutes ago", minutes) + } + } else { + "just now".to_string() + } +} + +/// Extract domain from URL +pub fn extract_domain(url: &str) -> Option<String> { + url::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .map(|h| h.trim_start_matches("www.").to_string()) +} diff --git a/src/trailbase_types.rs b/src/trailbase_types.rs @@ -1,18 +1,53 @@ //! Auto-generated types from Trailbase schema. //! Run `./scripts/generate-types.sh` to regenerate. -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NewsItem { +pub struct Story { + pub id: i64, + pub url: Option<String>, + pub title: String, + pub text: String, + pub published: String, pub author: Option<String>, + pub language: String, pub created_by: Option<String>, + pub score: i64, + pub comment_count: i64, + pub domain: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Comment { pub id: i64, - pub language: String, - pub published: String, + pub story_id: i64, + pub parent_id: Option<i64>, + pub path: String, + pub depth: i64, pub text: String, - pub title: String, - pub url: Option<String>, + pub score: i64, + pub created_at: String, + pub created_by: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Vote { + pub id: i64, + pub user_id: String, + pub target_type: i64, // 1=story, 2=comment + pub target_id: i64, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfile { + pub id: i64, + pub user_id: String, + pub username: String, + pub about: Option<String>, + pub karma: i64, + pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -22,8 +57,9 @@ pub struct Category { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NewsItemCategory { - pub category_id: i64, +#[allow(dead_code)] +pub struct StoryCategory { pub id: i64, - pub news_item_id: i64, + pub news_item_id: i64, // Still references old column name in DB + pub category_id: i64, } diff --git a/static/style.css b/static/style.css @@ -1,8 +1,726 @@ -@charset "UTF-8";/*! - * Pico CSS ✨ v2.1.1 (https://picocss.com) - * Copyright 2019-2025 - Licensed under MIT - */:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:root{--pico-font-size:106.25%}}@media (min-width:768px){:root{--pico-font-size:112.5%}}@media (min-width:1024px){:root{--pico-font-size:118.75%}}@media (min-width:1280px){:root{--pico-font-size:125%}}@media (min-width:1536px){:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h3,h4,h6{--pico-font-weight:700}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}:where(select),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=search] [role=button],[role=search] [type=button],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]){color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is( -[type=button],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is( -[type=button],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is( -[type=button],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is( -[type=button],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}}[type=checkbox],[type=radio],[type=range]{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.overflow-auto{overflow:auto}strong{font-weight:bolder}p,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h3,h4,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h6{--pico-color:var(--pico-h6-color)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where( ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:focus,[type=button]:focus,[type=file]::file-selector-button:focus,button:focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button]{margin-bottom:var(--pico-spacing)}:is(button,[type=button],[role=button]).secondary,[type=file]::file-selector-button{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=button],[role=button]).secondary:focus,[type=file]::file-selector-button:focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=button],[role=button]).outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=button],[role=button]).outline.secondary{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:where(button,[type=button],[role=button])[disabled]{opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}:where( img,svg){vertical-align:middle}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}input,select{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}input:not([type=checkbox],[type=radio]),select{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}input:not([type=submit],[type=button],[type=reset])[disabled],select[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}input::-webkit-input-placeholder,input::placeholder,select:invalid{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}:where(input,select,fieldset)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}label>:where(input,select){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=time]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=time]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=search] [role=button],[role=search] [type=button],[role=search] button{width:auto}@supports selector(:has(*)){[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[disabled]{cursor:not-allowed}a,button,input,label,select,summary{-ms-touch-action:manipulation}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} -\ No newline at end of file +/* HN/Lobsters Clone Styles */ + +:root { + --color-bg: #f6f6ef; + --color-header: #ff6600; + --color-header-text: #000; + --color-link: #000; + --color-link-visited: #828282; + --color-meta: #828282; + --color-score: #ff6600; + --color-voted: #ff6600; + --color-border: #ddd; + --color-text: #000; + --color-error: #ff0000; + --font-main: Verdana, Geneva, sans-serif; + --font-size: 10pt; + --max-width: 85%; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #1a1a1a; + --color-header: #ff6600; + --color-header-text: #fff; + --color-link: #c9c9c9; + --color-link-visited: #828282; + --color-meta: #828282; + --color-border: #333; + --color-text: #c9c9c9; + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-main); + font-size: var(--font-size); + background-color: var(--color-bg); + color: var(--color-text); + line-height: 1.4; +} + +a { + color: var(--color-link); + text-decoration: none; +} + +a:visited { + color: var(--color-link-visited); +} + +a:hover { + text-decoration: underline; +} + +/* Header */ +.site-header { + background-color: var(--color-header); + padding: 2px; + margin-bottom: 10px; +} + +.nav-main { + max-width: var(--max-width); + margin: 0 auto; + display: flex; + align-items: center; + gap: 10px; + padding: 2px 0; +} + +.logo { + font-weight: bold; + color: var(--color-header-text); + padding: 0 4px; + border: 1px solid var(--color-header-text); + margin-right: 5px; +} + +.logo:hover { + text-decoration: none; +} + +.nav-links { + display: flex; + gap: 8px; +} + +.nav-link { + color: var(--color-header-text); +} + +.nav-separator { + color: var(--color-header-text); +} + +.nav-right { + margin-left: auto; +} + +/* Container */ +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 5px; +} + +/* Feed */ +.feed-nav { + margin-bottom: 10px; + padding: 5px 0; + border-bottom: 1px solid var(--color-border); +} + +.sort-links { + margin-right: 15px; +} + +.sort-link { + margin-right: 10px; +} + +.sort-link.active { + font-weight: bold; +} + +.tag-links { + display: inline; +} + +.tag-link { + margin-right: 8px; + font-size: 9pt; +} + +/* Story List */ +.story-list { + list-style: none; + padding: 0; + margin: 0; +} + +.story-item { + display: flex; + padding: 5px 0; + align-items: flex-start; +} + +.story-vote { + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.vote-btn { + background: none; + border: none; + color: var(--color-meta); + cursor: pointer; + font-size: 10pt; + padding: 0; + line-height: 1; +} + +.vote-btn:hover { + color: var(--color-voted); +} + +.vote-btn.voted { + color: var(--color-voted); +} + +.vote-placeholder { + color: var(--color-meta); + opacity: 0.3; +} + +.comment-vote { + font-size: 9pt; +} + +.story-content { + flex: 1; + min-width: 0; +} + +.story-title { + margin-bottom: 2px; +} + +.story-rank { + color: var(--color-meta); + margin-right: 5px; +} + +.story-link { + font-size: 10pt; +} + +.story-domain { + font-size: 8pt; + color: var(--color-meta); + margin-left: 5px; +} + +.story-domain a { + color: var(--color-meta); +} + +.story-meta { + font-size: 8pt; + color: var(--color-meta); +} + +.story-meta a { + color: var(--color-meta); +} + +.story-score { + color: var(--color-meta); +} + +.story-author { + color: var(--color-meta); +} + +.story-time { + color: var(--color-meta); +} + +.story-comments { + color: var(--color-meta); +} + +.story-tag { + color: var(--color-meta); + background: rgba(0,0,0,0.05); + padding: 1px 4px; + border-radius: 2px; + font-size: 8pt; +} + +.more-link { + padding: 10px 0 20px 25px; +} + +.more-link a { + color: var(--color-meta); +} + +/* Story Page */ +.story-page { + padding: 10px 0; +} + +.story-full { + margin-bottom: 20px; +} + +.story-header { + display: flex; + align-items: flex-start; + margin-bottom: 10px; +} + +.story-title-full { + font-size: 13pt; + font-weight: normal; + margin-bottom: 5px; +} + +.story-title-full a { + color: var(--color-link); +} + +.story-text { + margin: 15px 0; + padding: 10px; + background: rgba(0,0,0,0.02); + border-radius: 3px; + line-height: 1.5; + white-space: pre-wrap; +} + +/* Comment Form */ +.comment-form { + margin: 20px 0; + padding: 10px; + background: rgba(0,0,0,0.02); + border-radius: 3px; +} + +.comment-form textarea { + width: 100%; + font-family: var(--font-main); + font-size: 10pt; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 3px; + margin-bottom: 10px; + resize: vertical; +} + +.comment-form button { + background: var(--color-header); + color: #fff; + border: none; + padding: 6px 12px; + border-radius: 3px; + cursor: pointer; + font-size: 10pt; +} + +.comment-form button:hover { + opacity: 0.9; +} + +.login-prompt { + color: var(--color-meta); + margin: 10px 0; +} + +/* Comments */ +.comments { + margin-top: 20px; +} + +.comment { + margin: 10px 0; + padding: 5px 0; +} + +.comment-header { + font-size: 8pt; + color: var(--color-meta); + margin-bottom: 5px; +} + +.comment-author { + color: var(--color-meta); + font-weight: bold; +} + +.comment-score { + margin: 0 5px; +} + +.comment-time { + margin-left: 5px; +} + +.comment-text { + font-size: 9pt; + line-height: 1.4; + margin: 5px 0; + white-space: pre-wrap; +} + +.comment-actions { + font-size: 8pt; + margin-top: 5px; +} + +.comment-reply { + color: var(--color-meta); + text-decoration: underline; +} + +.comment-children { + border-left: 1px solid var(--color-border); + margin-left: 0; + padding-left: 10px; +} + +/* Submit Page */ +.submit-page { + max-width: 600px; + padding: 20px 0; +} + +.submit-page h2 { + font-size: 13pt; + margin-bottom: 20px; +} + +.submit-form { + background: rgba(0,0,0,0.02); + padding: 15px; + border-radius: 3px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + font-size: 9pt; + color: var(--color-meta); + margin-bottom: 5px; +} + +.form-group input, +.form-group textarea { + width: 100%; + font-family: var(--font-main); + font-size: 10pt; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 3px; + background: var(--color-bg); + color: var(--color-text); +} + +.form-group textarea { + resize: vertical; +} + +.form-separator { + text-align: center; + color: var(--color-meta); + margin: 15px 0; +} + +.form-actions { + margin-top: 15px; +} + +.form-actions button { + background: var(--color-header); + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 3px; + cursor: pointer; + font-size: 10pt; +} + +.form-actions button:hover { + opacity: 0.9; +} + +.submit-info { + margin-top: 15px; + font-size: 9pt; + color: var(--color-meta); +} + +/* Reply Page */ +.reply-page { + max-width: 600px; + padding: 20px 0; +} + +.reply-page h2 { + font-size: 13pt; + margin-bottom: 15px; +} + +.parent-comment { + background: rgba(0,0,0,0.02); + padding: 10px; + border-radius: 3px; + margin-bottom: 15px; +} + +.parent-header { + font-size: 9pt; + color: var(--color-meta); + margin-bottom: 5px; +} + +.parent-text { + font-size: 9pt; + color: var(--color-text); + border-left: 2px solid var(--color-border); + padding-left: 10px; + margin: 10px 0; +} + +.reply-form textarea { + width: 100%; + font-family: var(--font-main); + font-size: 10pt; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 3px; + margin-bottom: 10px; + resize: vertical; +} + +.cancel-link { + margin-left: 10px; + color: var(--color-meta); +} + +/* User Page */ +.user-page { + padding: 20px 0; +} + +.user-header { + margin-bottom: 20px; +} + +.user-header h2 { + font-size: 13pt; + margin-bottom: 5px; +} + +.user-stats { + font-size: 9pt; + color: var(--color-meta); +} + +.user-karma { + margin-right: 10px; +} + +.user-about { + margin-top: 10px; + font-size: 9pt; + line-height: 1.4; +} + +.user-tabs { + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border); +} + +.user-tab { + margin-right: 15px; + color: var(--color-meta); +} + +.user-tab.active { + color: var(--color-link); + font-weight: bold; +} + +.user-comment { + padding: 10px 0; + border-bottom: 1px solid var(--color-border); +} + +.user-comment-meta { + font-size: 8pt; + color: var(--color-meta); + margin-bottom: 5px; +} + +.user-comment-text { + font-size: 9pt; + line-height: 1.4; +} + +/* Tag Page */ +.tag-page h2 { + font-size: 13pt; + margin-bottom: 15px; +} + +/* Search Page */ +.search-page { + max-width: 600px; + padding: 20px 0; +} + +.search-page h2 { + font-size: 13pt; + margin-bottom: 15px; +} + +.search-form { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.search-form input { + flex: 1; + font-family: var(--font-main); + font-size: 10pt; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 3px; + background: var(--color-bg); + color: var(--color-text); +} + +.search-form button { + background: var(--color-header); + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 3px; + cursor: pointer; + font-size: 10pt; +} + +.search-results-list { + list-style: none; +} + +.search-result { + padding: 10px 0; + border-bottom: 1px solid var(--color-border); +} + +.search-result-title { + font-size: 10pt; + display: block; + margin-bottom: 5px; +} + +.search-result-meta { + font-size: 8pt; + color: var(--color-meta); + margin-bottom: 5px; +} + +.search-result-meta span { + margin-right: 10px; +} + +.search-result-text { + font-size: 9pt; + color: var(--color-meta); +} + +/* Auth Pages */ +.auth-page { + max-width: 400px; + margin: 0 auto; + padding: 40px 20px; +} + +.auth-page h2 { + font-size: 13pt; + margin-bottom: 20px; + text-align: center; +} + +.auth-form { + background: rgba(0,0,0,0.02); + padding: 20px; + border-radius: 3px; +} + +.auth-form .form-group small { + display: block; + font-size: 8pt; + color: var(--color-meta); + margin-top: 3px; +} + +.auth-link { + text-align: center; + margin-top: 15px; + font-size: 9pt; + color: var(--color-meta); +} + +/* Settings Page */ +.settings-page { + max-width: 600px; + padding: 20px 0; +} + +/* Error Messages */ +.error-message { + background: rgba(255, 0, 0, 0.1); + border: 1px solid var(--color-error); + color: var(--color-error); + padding: 10px; + border-radius: 3px; + margin-bottom: 15px; + font-size: 9pt; +} + +.empty-message { + color: var(--color-meta); + font-style: italic; + padding: 20px 0; +} + +/* Footer */ +.site-footer { + max-width: var(--max-width); + margin: 20px auto; + padding: 10px 0; + border-top: 1px solid var(--color-border); + text-align: center; + font-size: 9pt; +} + +.site-footer a { + color: var(--color-meta); +} + +/* Responsive */ +@media (max-width: 768px) { + :root { + --max-width: 100%; + } + + .nav-main { + flex-wrap: wrap; + padding: 5px; + } + + .nav-links { + order: 3; + width: 100%; + margin-top: 5px; + } + + .story-item { + padding: 8px 5px; + } +} diff --git a/static/style.css.gz b/static/style.css.gz Binary files differ. diff --git a/templates/application.html b/templates/application.html @@ -2,15 +2,32 @@ <html lang="en"> <head> <meta charset="utf-8" /> - <title>My Chat</title> + <title>News</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script> - <script src="https://cdn.jsdelivr.net/npm/htmx-ext-preload@2.1.0"></script> <link rel="stylesheet" href="/static/style.css" /> </head> - <body hx-ext="preload"> + <body> + <header class="site-header"> + <nav class="nav-main"> + <a href="/" class="logo">News</a> + <div class="nav-links"> + <a href="/" class="nav-link">hot</a> + <a href="/new" class="nav-link">new</a> + <a href="/top" class="nav-link">top</a> + <span class="nav-separator">|</span> + <a href="/submit" class="nav-link">submit</a> + </div> + <div class="nav-right"> + <a href="/search" class="nav-link">search</a> + </div> + </nav> + </header> <main class="container"> <div id="content">{{ content|safe }}</div> </main> + <footer class="site-footer"> + <a href="https://github.com/silasbrack/simple-web-app">Source</a> + </footer> </body> </html> diff --git a/templates/feed.html b/templates/feed.html @@ -0,0 +1,32 @@ +<div class="feed"> + <div class="feed-nav"> + <span class="sort-links"> + <a href="/" class="sort-link{% if sort == "hot" %} active{% endif %}">hot</a> + <a href="/new" class="sort-link{% if sort == "new" %} active{% endif %}">new</a> + <a href="/top" class="sort-link{% if sort == "top" %} active{% endif %}">top</a> + </span> + {% if !tags.is_empty() %} + <span class="tag-links"> + {% for tag in tags %} + {% match tag.name %} + {% when Some with (name) %} + <a href="/tag/{{ name }}" class="tag-link">{{ name }}</a> + {% when None %} + {% endmatch %} + {% endfor %} + </span> + {% endif %} + </div> + + <ol class="story-list" start="{{ page * 30 + 1 }}"> + {% for story in stories %} + {% include "story_row.html" %} + {% endfor %} + </ol> + + {% if !reached_end %} + <div class="more-link"> + <a href="{% if sort == "hot" %}/{% else %}/{{ sort }}{% endif %}?page={{ page + 1 }}{% match tag_id %}{% when Some with (id) %}&tag={{ id }}{% when None %}{% endmatch %}">More</a> + </div> + {% endif %} +</div> diff --git a/templates/index.html b/templates/index.html @@ -1,177 +0,0 @@ -<div id="login"> - <!-- <article> --> - <nav> - <ul> - <li> - <a href="/" class="secondary" - ><img src="https://kite.kagi.com/svg/kagi_news_compact_dark.svg" - /></a> - </li> - </ul> - <ul> - <li> - <strong - style="cursor: pointer" - data-tooltip="Choose another date" - data-placement="bottom" - >Tuesday, September 30</strong - > - </li> - </ul> - <ul> - <li> - <a - id="search-btn" - style="cursor: pointer" - data-tooltip="Search" - hx-trigger="click, keyup[ctrlKey&&shiftKey&&key=='O']" - hx-get="/search" - hx-target="#modal-placeholder" - hx-swap="innerHTML" - preload - class="secondary" - ><svg - height="20pt" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 640 640" - > - <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--> - <path - d="M480 272C480 317.9 465.1 360.3 440 394.7L566.6 521.4C579.1 533.9 579.1 554.2 566.6 566.7C554.1 579.2 533.8 579.2 521.3 566.7L394.7 440C360.3 465.1 317.9 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272zM272 416C351.5 416 416 351.5 416 272C416 192.5 351.5 128 272 128C192.5 128 128 192.5 128 272C128 351.5 192.5 416 272 416z" - /></svg - ></a> - </li> - <li> - <a id="font-size-btn" data-tooltip="Change font size" class="secondary" - ><svg - height="20pt" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 640 640" - > - <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--> - <path - d="M349.1 114.7C343.9 103.3 332.5 96 320 96C307.5 96 296.1 103.3 290.9 114.7L123.5 480L112 480C94.3 480 80 494.3 80 512C80 529.7 94.3 544 112 544L200 544C217.7 544 232 529.7 232 512C232 494.3 217.7 480 200 480L193.9 480L215.9 432L424.2 432L446.2 480L440.1 480C422.4 480 408.1 494.3 408.1 512C408.1 529.7 422.4 544 440.1 544L528.1 544C545.8 544 560.1 529.7 560.1 512C560.1 494.3 545.8 480 528.1 480L516.6 480L349.2 114.7zM394.8 368L245.2 368L320 204.8L394.8 368z" - /></svg - ></a> - </li> - <li> - <a - id="settings-btn" - href="/settings" - data-tooltip="Settings" - preload - class="secondary" - ><svg - height="20pt" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 640 640" - > - <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--> - <path - d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z" - /></svg - ></a> - </li> - </ul> - </nav> - - <div class="overflow-auto" style="text-wrap: nowrap"> - <table> - <tr> - {% for category in categories %} - <th> - <div - hx-get="/?category_id={{ category.id }}" - hx-target="#news-cards" - preload - style="cursor: pointer" - > - {% match category.name %} - {% when Some with (name) %} - {{ name }} - {% when None %} - {% endmatch %} - </div> - </th> - {% endfor %} - </tr> - </table> - </div> - - <div id="modal-placeholder"></div> - <div id="news-cards-with-buttons"> - <div id="news-cards">{% include "news_cards.html" %}</div> - <nav> - <ul></ul> - <ul> - <li>{% include "load_more.html" %}</li> - <li><button>Mark all as read</button></li> - </ul> - <ul></ul> - </nav> - </div> - <!-- </article> --> - <footer> - <nav> - <ul></ul> - <ul> - <li> - <a href="https://github.com/silasbrack/simple-web-app" - ><svg - height="24pt" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 640 640" - > - <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--> - <path - d="M237.9 461.4C237.9 463.4 235.6 465 232.7 465C229.4 465.3 227.1 463.7 227.1 461.4C227.1 459.4 229.4 457.8 232.3 457.8C235.3 457.5 237.9 459.1 237.9 461.4zM206.8 456.9C206.1 458.9 208.1 461.2 211.1 461.8C213.7 462.8 216.7 461.8 217.3 459.8C217.9 457.8 216 455.5 213 454.6C210.4 453.9 207.5 454.9 206.8 456.9zM251 455.2C248.1 455.9 246.1 457.8 246.4 460.1C246.7 462.1 249.3 463.4 252.3 462.7C255.2 462 257.2 460.1 256.9 458.1C256.6 456.2 253.9 454.9 251 455.2zM316.8 72C178.1 72 72 177.3 72 316C72 426.9 141.8 521.8 241.5 555.2C254.3 557.5 258.8 549.6 258.8 543.1C258.8 536.9 258.5 502.7 258.5 481.7C258.5 481.7 188.5 496.7 173.8 451.9C173.8 451.9 162.4 422.8 146 415.3C146 415.3 123.1 399.6 147.6 399.9C147.6 399.9 172.5 401.9 186.2 425.7C208.1 464.3 244.8 453.2 259.1 446.6C261.4 430.6 267.9 419.5 275.1 412.9C219.2 406.7 162.8 398.6 162.8 302.4C162.8 274.9 170.4 261.1 186.4 243.5C183.8 237 175.3 210.2 189 175.6C209.9 169.1 258 202.6 258 202.6C278 197 299.5 194.1 320.8 194.1C342.1 194.1 363.6 197 383.6 202.6C383.6 202.6 431.7 169 452.6 175.6C466.3 210.3 457.8 237 455.2 243.5C471.2 261.2 481 275 481 302.4C481 398.9 422.1 406.6 366.2 412.9C375.4 420.8 383.2 435.8 383.2 459.3C383.2 493 382.9 534.7 382.9 542.9C382.9 549.4 387.5 557.3 400.2 555C500.2 521.8 568 426.9 568 316C568 177.3 455.5 72 316.8 72zM169.2 416.9C167.9 417.9 168.2 420.2 169.9 422.1C171.5 423.7 173.8 424.4 175.1 423.1C176.4 422.1 176.1 419.8 174.4 417.9C172.8 416.3 170.5 415.6 169.2 416.9zM158.4 408.8C157.7 410.1 158.7 411.7 160.7 412.7C162.3 413.7 164.3 413.4 165 412C165.7 410.7 164.7 409.1 162.7 408.1C160.7 407.5 159.1 407.8 158.4 408.8zM190.8 444.4C189.2 445.7 189.8 448.7 192.1 450.6C194.4 452.9 197.3 453.2 198.6 451.6C199.9 450.3 199.3 447.3 197.3 445.4C195.1 443.1 192.1 442.8 190.8 444.4zM179.4 429.7C177.8 430.7 177.8 433.3 179.4 435.6C181 437.9 183.7 438.9 185 437.9C186.6 436.6 186.6 434 185 431.7C183.6 429.4 181 428.4 179.4 429.7z" - /> - </svg> - Contribute</a - > - </li> - - <li> - <a href="" - ><svg - height="20pt" - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="1.5" - stroke="currentColor" - class="size-6" - > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" - /> - </svg> - About</a - > - </li> - - <li> - <a href="" - ><svg - height="20pt" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="currentColor" - class="size-6" - > - <path - fill-rule="evenodd" - d="M3.75 4.5a.75.75 0 0 1 .75-.75h.75c8.284 0 15 6.716 15 15v.75a.75.75 0 0 1-.75.75h-.75a.75.75 0 0 1-.75-.75v-.75C18 11.708 12.292 6 5.25 6H4.5a.75.75 0 0 1-.75-.75V4.5Zm0 6.75a.75.75 0 0 1 .75-.75h.75a8.25 8.25 0 0 1 8.25 8.25v.75a.75.75 0 0 1-.75.75H12a.75.75 0 0 1-.75-.75v-.75a6 6 0 0 0-6-6H4.5a.75.75 0 0 1-.75-.75v-.75Zm0 7.5a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z" - clip-rule="evenodd" - /> - </svg> - RSS Feed</a - > - </li> - </ul> - <ul></ul> - </nav> - </footer> -</div> diff --git a/templates/load_more.html b/templates/load_more.html @@ -1,12 +0,0 @@ -<button - id="load-more-btn" - {% if reached_end %}disabled{% endif %} - {% if oob %}hx-swap-oob="true"{% endif %} - hx-target="#news-cards" - hx-swap="beforeend" - hx-get="/?page={{ page + 1 }}&amp;current_time={{ current_time }}{% if let Some(cat_id) = category_id %}&amp;category_id={{ cat_id }}{% endif %}" - preload - class="secondary" -> - Load more -</button> diff --git a/templates/login.html b/templates/login.html @@ -1,27 +1,27 @@ -<article> - <header> - <h2>Login</h2> - </header> +<div class="auth-page"> + <h2>Login</h2> - {% if let Some(err) = error %} - <p role="alert" style="color: var(--pico-del-color);">{{ err }}</p> - {% endif %} + {% match error %} + {% when Some with (err) %} + <div class="error-message">{{ err }}</div> + {% when None %} + {% endmatch %} - <form method="post" action="/login"> - <label> - Email - <input type="email" name="email" required autocomplete="email" /> - </label> + <form method="post" action="/login" class="auth-form"> + <div class="form-group"> + <label for="email">Email</label> + <input type="email" id="email" name="email" required autocomplete="email" /> + </div> - <label> - Password - <input type="password" name="password" required autocomplete="current-password" /> - </label> + <div class="form-group"> + <label for="password">Password</label> + <input type="password" id="password" name="password" required autocomplete="current-password" /> + </div> - <button type="submit">Login</button> + <div class="form-actions"> + <button type="submit">Login</button> + </div> </form> - <footer> - <p>Don't have an account? <a href="/register">Register</a></p> - </footer> -</article> + <p class="auth-link">Don't have an account? <a href="/register">Register</a></p> +</div> diff --git a/templates/news_card.html b/templates/news_card.html @@ -1,30 +0,0 @@ -<header> - <nav> - <ul> - {% for category in new.categories %} - <li> - <div - style="cursor: pointer" - hx-get="/?category_id={{ category.id }}" - hx-target="#news-cards" - preload - > - <small>{% match category.name %}{% when Some with (name) %}{{ name }}{% when None %}{% endmatch %}</small> - </div> - </li> - {% endfor %} - </ul> - </nav> -</header> -<details> - <summary> - <strong>{{ new.title }}</strong> - </summary> - <p><small>{{ new.text }}</small></p> -</details> -<footer> - <small> - <em> {{ new.time_since_published }} </em> - </small> -</footer> -<hr /> diff --git a/templates/news_cards.html b/templates/news_cards.html @@ -1 +0,0 @@ -{% for new in news %}{% include "news_card.html" %}{% endfor %} diff --git a/templates/oob_swap.html b/templates/oob_swap.html @@ -1 +0,0 @@ -{% include "news_cards.html" %}{% include "load_more.html" %} diff --git a/templates/register.html b/templates/register.html @@ -1,27 +1,33 @@ -<article> - <header> - <h2>Register</h2> - </header> +<div class="auth-page"> + <h2>Register</h2> - {% if let Some(err) = error %} - <p role="alert" style="color: var(--pico-del-color);">{{ err }}</p> - {% endif %} + {% match error %} + {% when Some with (err) %} + <div class="error-message">{{ err }}</div> + {% when None %} + {% endmatch %} - <form method="post" action="/register"> - <label> - Email - <input type="email" name="email" required autocomplete="email" /> - </label> + <form method="post" action="/register" class="auth-form"> + <div class="form-group"> + <label for="username">Username</label> + <input type="text" id="username" name="username" value="{{ username }}" required minlength="2" maxlength="30" pattern="[a-zA-Z0-9_]+" autocomplete="username" /> + <small>2-30 characters, letters, numbers, and underscores only</small> + </div> - <label> - Password - <input type="password" name="password" required minlength="8" autocomplete="new-password" /> - </label> + <div class="form-group"> + <label for="email">Email</label> + <input type="email" id="email" name="email" value="{{ email }}" required autocomplete="email" /> + </div> - <button type="submit">Register</button> + <div class="form-group"> + <label for="password">Password</label> + <input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" /> + </div> + + <div class="form-actions"> + <button type="submit">Register</button> + </div> </form> - <footer> - <p>Already have an account? <a href="/login">Login</a></p> - </footer> -</article> + <p class="auth-link">Already have an account? <a href="/login">Login</a></p> +</div> diff --git a/templates/reply_form.html b/templates/reply_form.html @@ -0,0 +1,32 @@ +<div class="reply-page"> + <h2>Reply</h2> + + <div class="parent-comment"> + <div class="parent-header"> + {% match parent_author %} + {% when Some with (author) %} + <a href="/user/{{ author }}">{{ author }}</a> wrote: + {% when None %} + anonymous wrote: + {% endmatch %} + </div> + <blockquote class="parent-text">{{ parent_text }}</blockquote> + </div> + + {% match error %} + {% when Some with (err) %} + <div class="error-message">{{ err }}</div> + {% when None %} + {% endmatch %} + + <form action="/comment/{{ comment_id }}/reply" method="post" class="reply-form"> + <input type="hidden" name="story_id" value="{{ story_id }}" /> + <div class="form-group"> + <textarea name="text" rows="6" required placeholder="Write your reply..."></textarea> + </div> + <div class="form-actions"> + <button type="submit">Reply</button> + <a href="/story/{{ story_id }}" class="cancel-link">Cancel</a> + </div> + </form> +</div> diff --git a/templates/search.html b/templates/search.html @@ -1,39 +1,8 @@ -<div - id="search-modal" - class="modal-is-open modal-is-opening" - hx-on:closeModal="this.classList.remove('modal-is-opening'); setTimeout(() => { this.classList.add('modal-is-closing'); this.addEventListener('animationend', () => this.remove(), {once: true}); }, 10);" -> - <dialog hx-on:click="if (event.target === this) htmx.trigger(this.closest('#search-modal'), 'closeModal')" open> - <article> - <h3> - Search News - <span class="htmx-indicator"> - <img src="/img/bars.svg" /> Searching... - </span> - </h3> - <input - class="form-control" - type="search" - name="search" - placeholder="Begin typing to search in news" - hx-post="/search" - hx-trigger="input changed delay:500ms, keyup[key=='Enter'], load" - hx-target="#search-results" - hx-indicator=".htmx-indicator" - /> - - <div class="overflow-auto"> - <table class="table"> - <tbody id="search-results"></tbody> - </table> - </div> - - <footer> - <button hx-on:click="htmx.trigger(this.closest('#search-modal'), 'closeModal')" class="secondary"> - Cancel - </button> - <button>Confirm</button> - </footer> - </article> - </dialog> +<div class="search-page"> + <h2>Search</h2> + <form class="search-form" hx-post="/search" hx-target="#search-results" hx-swap="innerHTML" hx-trigger="submit, input changed delay:500ms"> + <input type="search" name="search" placeholder="Search stories..." autofocus /> + <button type="submit">Search</button> + </form> + <div id="search-results"></div> </div> diff --git a/templates/search_results.html b/templates/search_results.html @@ -1,10 +1,18 @@ -{% for new in news %} -<tr> - <td> - <details> - <summary>{{ new.title }}</summary> - <p><small>{{ new.text }}</small></p> - </details> - </td> -</tr> -{% endfor %} +{% if results.is_empty() %} +<p class="empty-message">No results found.</p> +{% else %} +<ul class="search-results-list"> + {% for result in results %} + <li class="search-result"> + <a href="/story/{{ result.id }}" class="search-result-title">{{ result.title }}</a> + <div class="search-result-meta"> + <span>{{ result.score }} points</span> + <span>{{ result.comment_count }} comments</span> + </div> + {% if !result.text.is_empty() %} + <p class="search-result-text">{{ result.text|truncate(150) }}</p> + {% endif %} + </li> + {% endfor %} +</ul> +{% endif %} diff --git a/templates/story.html b/templates/story.html @@ -0,0 +1,108 @@ +<div class="story-page"> + <article class="story-full"> + <div class="story-header"> + <div class="story-vote"> + <span id="vote-story-{{ story.id }}"> + {% if is_logged_in %} + <button + class="vote-btn{% if story.user_voted %} voted{% endif %}" + hx-post="/vote/story/{{ story.id }}" + hx-target="#vote-story-{{ story.id }}" + hx-swap="innerHTML" + >&#9650;</button> + {% else %} + <span class="vote-placeholder">&#9650;</span> + {% endif %} + </span> + </div> + <div class="story-content"> + <h1 class="story-title-full"> + {% match story.url %} + {% when Some with (url) %} + <a href="{{ url }}" rel="nofollow">{{ story.title }}</a> + {% match story.domain %} + {% when Some with (domain) %} + <span class="story-domain">(<a href="/search?q=site:{{ domain }}">{{ domain }}</a>)</span> + {% when None %} + {% endmatch %} + {% when None %} + {{ story.title }} + {% endmatch %} + </h1> + <div class="story-meta"> + <span class="story-score" id="score-story-{{ story.id }}">{{ story.score }} point{% if story.score != 1 %}s{% endif %}</span> + by + {% match story.author_username %} + {% when Some with (username) %} + <a href="/user/{{ username }}" class="story-author">{{ username }}</a> + {% when None %} + <span class="story-author">anonymous</span> + {% endmatch %} + <span class="story-time">{{ story.time_ago }}</span> + | + <span class="story-comments">{{ story.comment_count }} comment{% if story.comment_count != 1 %}s{% endif %}</span> + {% if !story.tags.is_empty() %} + | + {% for tag in story.tags %} + {% match tag.name %} + {% when Some with (name) %} + <a href="/tag/{{ name }}" class="story-tag">{{ name }}</a> + {% when None %} + {% endmatch %} + {% endfor %} + {% endif %} + </div> + </div> + </div> + + {% if !story.text.is_empty() %} + <div class="story-text"> + {{ story.text }} + </div> + {% endif %} + </article> + + {% if is_logged_in %} + <form class="comment-form" action="/story/{{ story.id }}/comment" method="post"> + <textarea name="text" placeholder="Add a comment..." rows="4" required></textarea> + <button type="submit">Add Comment</button> + </form> + {% else %} + <p class="login-prompt"><a href="/login">Log in</a> to comment.</p> + {% endif %} + + <section class="comments"> + {% for comment in comments %} + <div class="comment" id="comment-{{ comment.id }}" style="margin-left: {{ comment.indent_px }}px;"> + <div class="comment-header"> + <span id="vote-comment-{{ comment.id }}"> + {% if is_logged_in %} + <button + class="vote-btn comment-vote{% if comment.user_voted %} voted{% endif %}" + hx-post="/vote/comment/{{ comment.id }}" + hx-target="#vote-comment-{{ comment.id }}" + hx-swap="innerHTML" + >&#9650;</button> + {% endif %} + </span> + {% match comment.author_username %} + {% when Some with (username) %} + <a href="/user/{{ username }}" class="comment-author">{{ username }}</a> + {% when None %} + <span class="comment-author">anonymous</span> + {% endmatch %} + <span class="comment-score" id="score-comment-{{ comment.id }}">{{ comment.score }} point{% if comment.score != 1 %}s{% endif %}</span> + <span class="comment-time">{{ comment.time_ago }}</span> + </div> + <div class="comment-text"> + {{ comment.text }} + </div> + <div class="comment-actions"> + {% if is_logged_in %} + <a href="/comment/{{ comment.id }}/reply" class="comment-reply">reply</a> + {% endif %} + </div> + </div> + {% endfor %} + </section> +</div> diff --git a/templates/story_row.html b/templates/story_row.html @@ -0,0 +1,55 @@ +<li class="story-item" id="story-{{ story.id }}"> + <div class="story-vote"> + <span id="vote-story-{{ story.id }}"> + {% if is_logged_in %} + <button + class="vote-btn{% if story.user_voted %} voted{% endif %}" + hx-post="/vote/story/{{ story.id }}" + hx-target="#vote-story-{{ story.id }}" + hx-swap="innerHTML" + >&#9650;</button> + {% else %} + <span class="vote-placeholder">&#9650;</span> + {% endif %} + </span> + </div> + <div class="story-content"> + <div class="story-title"> + <span class="story-rank">{{ story.rank }}.</span> + {% match story.url %} + {% when Some with (url) %} + <a href="{{ url }}" class="story-link" rel="nofollow">{{ story.title }}</a> + {% match story.domain %} + {% when Some with (domain) %} + <span class="story-domain">(<a href="/search?q=site:{{ domain }}">{{ domain }}</a>)</span> + {% when None %} + {% endmatch %} + {% when None %} + <a href="/story/{{ story.id }}" class="story-link">{{ story.title }}</a> + {% endmatch %} + </div> + <div class="story-meta"> + <span class="story-score" id="score-story-{{ story.id }}">{{ story.score }} point{% if story.score != 1 %}s{% endif %}</span> + by + {% match story.author_username %} + {% when Some with (username) %} + <a href="/user/{{ username }}" class="story-author">{{ username }}</a> + {% when None %} + <span class="story-author">anonymous</span> + {% endmatch %} + <span class="story-time">{{ story.time_ago }}</span> + | + <a href="/story/{{ story.id }}" class="story-comments">{{ story.comment_count }} comment{% if story.comment_count != 1 %}s{% endif %}</a> + {% if !story.tags.is_empty() %} + | + {% for tag in story.tags %} + {% match tag.name %} + {% when Some with (name) %} + <a href="/tag/{{ name }}" class="story-tag">{{ name }}</a> + {% when None %} + {% endmatch %} + {% endfor %} + {% endif %} + </div> + </div> +</li> diff --git a/templates/submit.html b/templates/submit.html @@ -0,0 +1,39 @@ +<div class="submit-page"> + <h2>Submit</h2> + + {% match error %} + {% when Some with (err) %} + <div class="error-message">{{ err }}</div> + {% when None %} + {% endmatch %} + + <form action="/submit" method="post" class="submit-form"> + <div class="form-group"> + <label for="title">Title</label> + <input type="text" id="title" name="title" value="{{ title }}" required maxlength="200" /> + </div> + + <div class="form-group"> + <label for="url">URL</label> + <input type="url" id="url" name="url" value="{{ url }}" placeholder="https://" /> + </div> + + <div class="form-separator"> + <span>or</span> + </div> + + <div class="form-group"> + <label for="text">Text</label> + <textarea id="text" name="text" rows="6" placeholder="Leave URL blank to submit a text post">{{ text }}</textarea> + </div> + + <div class="form-actions"> + <button type="submit">Submit</button> + </div> + </form> + + <p class="submit-info"> + Leave URL blank to submit a question or discussion. + The text field is optional for link submissions. + </p> +</div> diff --git a/templates/tag.html b/templates/tag.html @@ -0,0 +1,19 @@ +<div class="tag-page"> + <h2>Stories tagged "{{ tag_name }}"</h2> + + {% if stories.is_empty() %} + <p class="empty-message">No stories with this tag yet.</p> + {% else %} + <ol class="story-list" start="{{ page * 30 + 1 }}"> + {% for story in stories %} + {% include "story_row.html" %} + {% endfor %} + </ol> + + {% if !reached_end %} + <div class="more-link"> + <a href="/tag/{{ tag_name }}?page={{ page + 1 }}">More</a> + </div> + {% endif %} + {% endif %} +</div> diff --git a/templates/user.html b/templates/user.html @@ -0,0 +1,49 @@ +<div class="user-page"> + <header class="user-header"> + <h2>{{ profile.username }}</h2> + <div class="user-stats"> + <span class="user-karma">{{ profile.karma }} karma</span> + <span class="user-joined">joined {{ profile.created_at }}</span> + </div> + {% match profile.about %} + {% when Some with (about) %} + <div class="user-about">{{ about }}</div> + {% when None %} + {% endmatch %} + </header> + + <nav class="user-tabs"> + <a href="/user/{{ profile.username }}?tab=submissions" class="user-tab{% if tab == "submissions" %} active{% endif %}">submissions</a> + <a href="/user/{{ profile.username }}?tab=comments" class="user-tab{% if tab == "comments" %} active{% endif %}">comments</a> + </nav> + + {% if tab == "submissions" %} + <section class="user-submissions"> + {% if submissions.is_empty() %} + <p class="empty-message">No submissions yet.</p> + {% else %} + <ol class="story-list"> + {% for story in submissions %} + {% include "story_row.html" %} + {% endfor %} + </ol> + {% endif %} + </section> + {% else if tab == "comments" %} + <section class="user-comments"> + {% if comments.is_empty() %} + <p class="empty-message">No comments yet.</p> + {% else %} + {% for comment in comments %} + <div class="user-comment"> + <div class="user-comment-meta"> + <a href="/story/{{ comment.story_id }}">on story #{{ comment.story_id }}</a> + <span class="comment-time">{{ comment.time_ago }}</span> + </div> + <div class="user-comment-text">{{ comment.text }}</div> + </div> + {% endfor %} + {% endif %} + </section> + {% endif %} +</div> diff --git a/templates/vote_button.html b/templates/vote_button.html @@ -0,0 +1,6 @@ +<button + class="vote-btn{% if voted %} voted{% endif %}" + hx-post="/vote/{{ target_type }}/{{ target_id }}" + hx-target="#vote-{{ target_type }}-{{ target_id }}" + hx-swap="innerHTML" +>&#9650;</button> diff --git a/traildepot/config.textproto b/traildepot/config.textproto @@ -10,8 +10,8 @@ auth { } jobs {} record_apis: [{ - name: "news_item" - table_name: "news_item" + name: "story" + table_name: "story" acl_world: [READ] acl_authenticated: [READ, CREATE, UPDATE, DELETE] create_access_rule: "_REQ_.created_by = _USER_.id" @@ -23,8 +23,30 @@ record_apis: [{ acl_world: [READ] acl_authenticated: [READ, CREATE] }, { - name: "news_item_category" - table_name: "news_item_category" + name: "story_category" + table_name: "story_category" acl_world: [READ] acl_authenticated: [READ, CREATE, DELETE] -}] -\ No newline at end of file +}, { + name: "comment" + table_name: "comment" + acl_world: [READ] + acl_authenticated: [READ, CREATE, UPDATE, DELETE] + create_access_rule: "_REQ_.created_by = _USER_.id" + update_access_rule: "_ROW_.created_by = _USER_.id" + delete_access_rule: "_ROW_.created_by = _USER_.id" +}, { + name: "vote" + table_name: "vote" + acl_world: [] + acl_authenticated: [READ, CREATE, DELETE] + create_access_rule: "_REQ_.user_id = _USER_.id" + delete_access_rule: "_ROW_.user_id = _USER_.id" +}, { + name: "user_profile" + table_name: "user_profile" + acl_world: [READ] + acl_authenticated: [READ, CREATE, UPDATE] + create_access_rule: "_REQ_.user_id = _USER_.id" + update_access_rule: "_ROW_.user_id = _USER_.id" +}] diff --git a/traildepot/migrations/main/U101__hn_clone_schema.sql b/traildepot/migrations/main/U101__hn_clone_schema.sql @@ -0,0 +1,133 @@ +-- HN/Lobsters Clone Schema Migration +-- Refactor news aggregator to community-driven link aggregator + +-- Rename news_item to story and add scoring columns +ALTER TABLE news_item RENAME TO story; +ALTER TABLE story ADD COLUMN score INTEGER NOT NULL DEFAULT 1; +ALTER TABLE story ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE story ADD COLUMN domain TEXT; + +-- Update FTS triggers to reference story table +DROP TRIGGER IF EXISTS news_item_ai; +DROP TRIGGER IF EXISTS news_item_au; +DROP TRIGGER IF EXISTS news_item_ad; + +CREATE TRIGGER story_ai AFTER INSERT ON story BEGIN + INSERT INTO news_item_fts(rowid, title, text) + VALUES (new.id, new.title, new.text); +END; + +CREATE TRIGGER story_au AFTER UPDATE ON story BEGIN + UPDATE news_item_fts SET title=new.title, text=new.text + WHERE rowid=old.id; +END; + +CREATE TRIGGER story_ad AFTER DELETE ON story BEGIN + DELETE FROM news_item_fts WHERE rowid=old.id; +END; + +-- Comments with materialized path for threading +CREATE TABLE comment ( + id INTEGER PRIMARY KEY NOT NULL, + story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES comment(id) ON DELETE CASCADE, + path TEXT NOT NULL, -- "1.5.12" = hierarchy + depth INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by BLOB NOT NULL REFERENCES _user(id) +) STRICT; + +CREATE INDEX idx_comment_story_id ON comment(story_id); +CREATE INDEX idx_comment_parent_id ON comment(parent_id); +CREATE INDEX idx_comment_path ON comment(path); + +-- Votes (stories + comments) +CREATE TABLE vote ( + id INTEGER PRIMARY KEY NOT NULL, + user_id BLOB NOT NULL REFERENCES _user(id), + target_type INTEGER NOT NULL, -- 1=story, 2=comment + target_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, target_type, target_id) +) STRICT; + +CREATE INDEX idx_vote_user ON vote(user_id); +CREATE INDEX idx_vote_target ON vote(target_type, target_id); + +-- User profiles +CREATE TABLE user_profile ( + id INTEGER PRIMARY KEY NOT NULL, + user_id BLOB NOT NULL UNIQUE REFERENCES _user(id), + username TEXT NOT NULL UNIQUE, + about TEXT, + karma INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX idx_user_profile_username ON user_profile(username); + +-- Rename junction table +ALTER TABLE news_item_category RENAME TO story_category; + +-- Drop old view and create new one +DROP VIEW IF EXISTS news_with_categories; + +CREATE VIEW story_with_categories AS +SELECT + s.*, + GROUP_CONCAT(c.name) as category_names +FROM story s +LEFT JOIN story_category sc ON sc.news_item_id = s.id +LEFT JOIN category c ON c.id = sc.category_id +GROUP BY s.id; + +-- Triggers for auto-updating comment_count on story +CREATE TRIGGER comment_insert_count AFTER INSERT ON comment +BEGIN + UPDATE story SET comment_count = comment_count + 1 WHERE id = new.story_id; +END; + +CREATE TRIGGER comment_delete_count AFTER DELETE ON comment +BEGIN + UPDATE story SET comment_count = comment_count - 1 WHERE id = old.story_id; +END; + +-- Triggers for auto-updating scores on vote insert/delete for stories +CREATE TRIGGER vote_insert_story AFTER INSERT ON vote +WHEN new.target_type = 1 +BEGIN + UPDATE story SET score = score + 1 WHERE id = new.target_id; + -- Update karma for story author + UPDATE user_profile SET karma = karma + 1 + WHERE user_id = (SELECT created_by FROM story WHERE id = new.target_id); +END; + +CREATE TRIGGER vote_delete_story AFTER DELETE ON vote +WHEN old.target_type = 1 +BEGIN + UPDATE story SET score = score - 1 WHERE id = old.target_id; + -- Update karma for story author + UPDATE user_profile SET karma = karma - 1 + WHERE user_id = (SELECT created_by FROM story WHERE id = old.target_id); +END; + +-- Triggers for auto-updating scores on vote insert/delete for comments +CREATE TRIGGER vote_insert_comment AFTER INSERT ON vote +WHEN new.target_type = 2 +BEGIN + UPDATE comment SET score = score + 1 WHERE id = new.target_id; + -- Update karma for comment author + UPDATE user_profile SET karma = karma + 1 + WHERE user_id = (SELECT created_by FROM comment WHERE id = new.target_id); +END; + +CREATE TRIGGER vote_delete_comment AFTER DELETE ON vote +WHEN old.target_type = 2 +BEGIN + UPDATE comment SET score = score - 1 WHERE id = old.target_id; + -- Update karma for comment author + UPDATE user_profile SET karma = karma - 1 + WHERE user_id = (SELECT created_by FROM comment WHERE id = old.target_id); +END;