//! Live slug availability validation endpoints (HTMX partials). use axum::{ extract::State, response::{Html, IntoResponse}, }; use serde::Deserialize; use crate::{ auth::AuthUser, db::{self, ProjectId, Slug}, templates::{SaveStatusTemplate, SlugStatusTemplate}, AppState, }; /// Generate up to 3 available slug suggestions based on a taken slug. /// Strips any trailing `-N` suffix to find the root, then tries `{root}-2` /// through `{root}-10`, returning the first 3 that are not already taken. async fn suggest_slugs( db: &sqlx::PgPool, user_id: db::UserId, base: &str, ) -> Vec { // Strip any trailing -N suffix to get the root let root = base .rfind('-') .and_then(|pos| { if base[pos + 1..].chars().all(|c| c.is_ascii_digit()) { Some(&base[..pos]) } else { None } }) .unwrap_or(base); let mut suggestions = Vec::new(); for n in 2..=10 { let candidate = format!("{}-{}", root, n); if let Ok(slug) = Slug::new(&candidate) { let taken = db::projects::get_project_by_user_and_slug(db, user_id, &slug) .await .map(|p| p.is_some()) .unwrap_or(true); if !taken { suggestions.push(candidate); if suggestions.len() >= 3 { break; } } } } suggestions } #[derive(Debug, Deserialize)] pub struct SlugForm { pub slug: String, } #[derive(Debug, Deserialize)] pub struct BlogSlugForm { pub slug: String, pub project_id: String, } /// Check project slug availability for the current user. #[tracing::instrument(skip_all, name = "validate::project_slug")] pub async fn validate_project_slug( State(state): State, auth: AuthUser, axum::Form(form): axum::Form, ) -> impl IntoResponse { if form.slug.is_empty() { return Html(String::new()); } if form.slug.len() < 2 { return Html( SaveStatusTemplate { success: false, message: "Must be at least 2 characters".to_string(), } .render_string(), ); } let slug = match Slug::new(&form.slug) { Ok(s) => s, Err(_) => { return Html( SaveStatusTemplate { success: false, message: "Letters, numbers, and hyphens only".to_string(), } .render_string(), ); } }; let is_taken = db::projects::get_project_by_user_and_slug(&state.db, auth.0.id, &slug) .await .map(|p| p.is_some()) .unwrap_or(false); if is_taken { let suggestions = suggest_slugs(&state.db, auth.0.id, &form.slug).await; Html( SlugStatusTemplate { available: false, suggestions, } .render_string(), ) } else { Html( SlugStatusTemplate { available: true, suggestions: Vec::new(), } .render_string(), ) } } /// Check collection slug availability for the current user. #[tracing::instrument(skip_all, name = "validate::collection_slug")] pub async fn validate_collection_slug( State(state): State, auth: AuthUser, axum::Form(form): axum::Form, ) -> impl IntoResponse { if form.slug.is_empty() { return Html(String::new()); } if form.slug.len() < 2 { return Html( SaveStatusTemplate { success: false, message: "Must be at least 2 characters".to_string(), } .render_string(), ); } let slug = match Slug::new(&form.slug) { Ok(s) => s, Err(_) => { return Html( SaveStatusTemplate { success: false, message: "Letters, numbers, and hyphens only".to_string(), } .render_string(), ); } }; let is_taken = db::collections::get_collection_by_user_and_slug(&state.db, auth.0.id, &slug) .await .map(|c| c.is_some()) .unwrap_or(false); Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()) } /// Check blog post slug availability within a project. #[tracing::instrument(skip_all, name = "validate::blog_slug")] pub async fn validate_blog_slug( State(state): State, auth: AuthUser, axum::Form(form): axum::Form, ) -> impl IntoResponse { if form.slug.is_empty() { return Html(String::new()); } if form.slug.len() < 2 { return Html( SaveStatusTemplate { success: false, message: "Must be at least 2 characters".to_string(), } .render_string(), ); } let slug = match Slug::new(&form.slug) { Ok(s) => s, Err(_) => { return Html( SaveStatusTemplate { success: false, message: "Letters, numbers, and hyphens only".to_string(), } .render_string(), ); } }; let project_id = match form.project_id.parse::() { Ok(id) => id, Err(_) => return Html(String::new()), }; // Verify the user owns this project let project = match db::projects::get_project_by_id(&state.db, project_id).await { Ok(Some(p)) if p.user_id == auth.0.id => p, _ => return Html(String::new()), }; let is_taken = db::blog_posts::blog_post_slug_exists(&state.db, project.id, &slug) .await .unwrap_or(false); Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()) }