story.rs (14602B)
1 use std::convert::Infallible; 2 3 use askama::Template; 4 use axum::{ 5 extract::{Path, State}, 6 response::{Html, IntoResponse, Redirect, Response, sse::{Event, Sse}}, 7 Form, 8 }; 9 use axum_extra::extract::cookie::CookieJar; 10 use serde::Deserialize; 11 12 use crate::database::{CommentWithMeta, StoryWithMeta}; 13 use crate::database::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; 14 use crate::error::AppError; 15 use crate::state::AppState; 16 use crate::templates::{ApplicationTemplate, CommentsTemplate, EditTemplate, StoryTemplate, SubmitTemplate}; 17 18 async fn fetch_enriched_comments( 19 state: &AppState, 20 user_id: Option<i64>, 21 story_id: i64, 22 ) -> Result<Vec<CommentWithMeta>, AppError> { 23 let db = state.db(); 24 let comments = db.get_comments_for_story(story_id).await?; 25 26 let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect(); 27 let voted_comment_ids = if let Some(uid) = user_id { 28 if !comment_ids.is_empty() { 29 db.get_user_votes(uid, TARGET_TYPE_COMMENT, &comment_ids) 30 .await 31 .unwrap_or_default() 32 } else { 33 vec![] 34 } 35 } else { 36 vec![] 37 }; 38 39 // Batch-fetch comment author usernames 40 let unique_user_ids: Vec<i64> = { 41 let mut ids: Vec<i64> = comments.iter() 42 .filter_map(|c| c.created_by.parse::<i64>().ok()) 43 .collect(); 44 ids.sort_unstable(); 45 ids.dedup(); 46 ids 47 }; 48 let author_map = db.enrich_comment_authors(&unique_user_ids).await.unwrap_or_default(); 49 50 let mut enriched = Vec::with_capacity(comments.len()); 51 for comment in comments { 52 let uid: i64 = comment.created_by.parse().unwrap_or(0); 53 let comment_author = author_map.get(&uid).cloned(); 54 let comment_time_ago = database::time_ago(comment.created_at); 55 let comment_voted = voted_comment_ids.contains(&comment.id); 56 57 enriched.push(CommentWithMeta::from_comment( 58 comment, 59 comment_author, 60 comment_time_ago, 61 comment_voted, 62 )); 63 } 64 65 Ok(enriched) 66 } 67 68 pub async fn show_story( 69 State(state): State<AppState>, 70 jar: CookieJar, 71 Path(id): Path<i64>, 72 ) -> Result<Html<String>, AppError> { 73 let user_id = state.get_user_id(&jar).await; 74 75 let db = state.db(); 76 let story = db.get_story(id).await? 77 .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; 78 79 let tags = db.get_categories_for_story(id).await.unwrap_or_default(); 80 81 let author_username = if let Some(ref created_by) = story.created_by { 82 if let Ok(uid) = created_by.parse::<i64>() { 83 db.get_user_by_id(uid) 84 .await 85 .ok() 86 .flatten() 87 .map(|u| u.username) 88 } else { 89 None 90 } 91 } else { 92 None 93 }; 94 95 let story_voted = if let Some(uid) = user_id { 96 db.get_user_votes(uid, TARGET_TYPE_STORY, &[id]) 97 .await 98 .map(|v| v.contains(&id)) 99 .unwrap_or(false) 100 } else { 101 false 102 }; 103 104 let time_ago = database::time_ago(story.published); 105 let story_with_meta = StoryWithMeta::from_story(story, tags, author_username, time_ago, story_voted, 0); 106 107 let enriched_comments = fetch_enriched_comments(&state, user_id, id).await?; 108 109 let is_logged_in = user_id.is_some(); 110 let current_user_id = user_id.map(|id| id.to_string()); 111 112 let story_template = StoryTemplate { 113 story: story_with_meta, 114 comments: enriched_comments, 115 is_logged_in, 116 current_user_id, 117 }; 118 119 let app = ApplicationTemplate { 120 content: story_template.render()?, 121 }; 122 Ok(Html(app.render()?)) 123 } 124 125 pub async fn show_submit( 126 State(state): State<AppState>, 127 jar: CookieJar, 128 ) -> Result<Html<String>, AppError> { 129 let user_id = state.get_user_id(&jar).await; 130 if user_id.is_none() { 131 return Ok(Html( 132 r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), 133 )); 134 } 135 136 let all_tags = state.db().get_categories(50).await.unwrap_or_default(); 137 138 let submit = SubmitTemplate { 139 error: None, 140 title: String::new(), 141 url: String::new(), 142 text: String::new(), 143 all_tags, 144 selected_tags: vec![], 145 }; 146 147 let app = ApplicationTemplate { 148 content: submit.render()?, 149 }; 150 Ok(Html(app.render()?)) 151 } 152 153 #[derive(Debug, Deserialize)] 154 pub struct SubmitForm { 155 pub title: String, 156 pub url: Option<String>, 157 pub text: Option<String>, 158 #[serde(default)] 159 pub tags: Vec<i64>, 160 } 161 162 pub async fn submit_story( 163 State(state): State<AppState>, 164 jar: CookieJar, 165 Form(form): Form<SubmitForm>, 166 ) -> Result<Redirect, Html<String>> { 167 let uid = state.get_user_id(&jar).await.ok_or_else(|| { 168 Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) 169 })?; 170 171 let all_tags = state.db().get_categories(50).await.unwrap_or_default(); 172 173 // Validate: must have either URL or text 174 let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); 175 let text = form.text.as_ref().filter(|t| !t.trim().is_empty()); 176 177 if url.is_none() && text.is_none() { 178 return Err(render_submit_error( 179 &form, 180 &all_tags, 181 "Please provide either a URL or text", 182 )); 183 } 184 185 if form.title.trim().is_empty() { 186 return Err(render_submit_error(&form, &all_tags, "Title is required")); 187 } 188 189 // Validate URL if provided 190 if let Some(u) = url { 191 // Simple URL validation 192 if !u.starts_with("http://") && !u.starts_with("https://") { 193 return Err(render_submit_error(&form, &all_tags, "Invalid URL. Must start with http:// or https://")); 194 } 195 } 196 197 let domain = url.and_then(|u| database::extract_domain(u)); 198 let title = form.title.trim().to_string(); 199 let url_val = url.map(|s| s.to_string()); 200 let text_val = text.map(|s| s.to_string()).unwrap_or_default(); 201 let tag_ids = form.tags.clone(); 202 203 let story = state.db().create_story( 204 uid, 205 &title, 206 url_val.as_deref(), 207 &text_val, 208 domain.as_deref(), 209 "en", 210 ).await.map_err(|e| render_submit_error(&form, &all_tags, &format!("Failed to create story: {}", e)))?; 211 212 let story_id = story.id; 213 if !tag_ids.is_empty() { 214 for tag_id in tag_ids { 215 if let Err(e) = state.db().add_tag_to_story(story_id, tag_id).await { 216 eprintln!("Failed to add tag {} to story {}: {}", tag_id, story_id, e); 217 } 218 } 219 } 220 221 Ok(Redirect::to(&format!("/story/{}", story.id))) 222 } 223 224 fn render_submit_error(form: &SubmitForm, all_tags: &[database::Category], error: &str) -> Html<String> { 225 let submit = SubmitTemplate { 226 error: Some(error.to_string()), 227 title: form.title.clone(), 228 url: form.url.clone().unwrap_or_default(), 229 text: form.text.clone().unwrap_or_default(), 230 all_tags: all_tags.to_vec(), 231 selected_tags: form.tags.clone(), 232 }; 233 let app = ApplicationTemplate { 234 content: submit.render().unwrap_or_default(), 235 }; 236 Html(app.render().unwrap_or_default()) 237 } 238 239 // ============================================================================ 240 // Edit story 241 // ============================================================================ 242 243 pub async fn show_edit( 244 State(state): State<AppState>, 245 jar: CookieJar, 246 Path(id): Path<i64>, 247 ) -> Result<Html<String>, AppError> { 248 let user_id = state.get_user_id(&jar).await 249 .ok_or_else(|| AppError::Unauthorized("Login required".to_string()))?; 250 251 let db = state.db(); 252 let story = db.get_story(id).await? 253 .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; 254 255 // Check ownership 256 let story_owner: i64 = story.created_by.as_ref() 257 .and_then(|s| s.parse().ok()) 258 .unwrap_or(0); 259 if story_owner != user_id { 260 return Err(AppError::Unauthorized("You can only edit your own stories".to_string())); 261 } 262 263 let all_tags = db.get_categories(50).await.unwrap_or_default(); 264 let story_tags = db.get_categories_for_story(id).await.unwrap_or_default(); 265 266 let selected_tags: Vec<i64> = story_tags.iter().map(|t| t.id).collect(); 267 268 let edit = EditTemplate { 269 error: None, 270 story_id: id, 271 title: story.title, 272 url: story.url.unwrap_or_default(), 273 text: story.text, 274 all_tags, 275 selected_tags, 276 }; 277 278 let app = ApplicationTemplate { 279 content: edit.render()?, 280 }; 281 Ok(Html(app.render()?)) 282 } 283 284 #[derive(Debug, Deserialize)] 285 pub struct EditForm { 286 pub title: String, 287 pub url: Option<String>, 288 pub text: Option<String>, 289 #[serde(default)] 290 pub tags: Vec<i64>, 291 } 292 293 pub async fn edit_story( 294 State(state): State<AppState>, 295 jar: CookieJar, 296 Path(id): Path<i64>, 297 Form(form): Form<EditForm>, 298 ) -> Result<Redirect, Html<String>> { 299 let user_id = state.get_user_id(&jar).await.ok_or_else(|| { 300 Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) 301 })?; 302 303 // Check ownership 304 let story = state.db().get_story(id).await 305 .and_then(|opt| opt.ok_or_else(|| AppError::NotFound("Story not found".to_string()))) 306 .map_err(|e| render_edit_error(id, &form, &[], &format!("{}", e)))?; 307 308 let story_owner: i64 = story.created_by.as_ref() 309 .and_then(|s| s.parse().ok()) 310 .unwrap_or(0); 311 if story_owner != user_id { 312 return Err(render_edit_error(id, &form, &[], "You can only edit your own stories")); 313 } 314 315 if form.title.trim().is_empty() { 316 let all_tags = state.db().get_categories(50).await.unwrap_or_default(); 317 return Err(render_edit_error(id, &form, &all_tags, "Title is required")); 318 } 319 320 let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); 321 let text = form.text.as_ref().filter(|t| !t.trim().is_empty()); 322 let domain = url.and_then(|u| database::extract_domain(u)); 323 324 let title = form.title.trim().to_string(); 325 let url_val = url.map(|s| s.to_string()); 326 let text_val = text.map(|s| s.to_string()).unwrap_or_default(); 327 let tag_ids = form.tags.clone(); 328 329 let db = state.db(); 330 db.update_story(id, &title, url_val.as_deref(), &text_val, domain.as_deref()).await 331 .map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?; 332 333 db.remove_all_tags_from_story(id).await 334 .map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?; 335 for tag_id in tag_ids { 336 if let Err(e) = db.add_tag_to_story(id, tag_id).await { 337 eprintln!("Failed to add tag {} to story {}: {}", tag_id, id, e); 338 } 339 } 340 341 Ok(Redirect::to(&format!("/story/{}", id))) 342 } 343 344 fn render_comments_event( 345 comments: Vec<CommentWithMeta>, 346 is_logged_in: bool, 347 story_id: i64, 348 ) -> Option<Event> { 349 let template = CommentsTemplate { 350 comments, 351 is_logged_in, 352 }; 353 let inner_html = template.render().ok()?; 354 let html = format!( 355 "<section class=\"comments\" id=\"comments-section\" \ 356 data-init=\"@get('/story/{story_id}/comments-stream', \ 357 {{requestCancellation: 'cleanup'}})\">\ 358 {inner_html}</section>" 359 ); 360 let data: String = html 361 .lines() 362 .map(|line| format!("elements {line}")) 363 .collect::<Vec<_>>() 364 .join("\n"); 365 Some( 366 Event::default() 367 .event("datastar-patch-elements") 368 .data(data), 369 ) 370 } 371 372 /// Long-lived SSE endpoint that uses broadcast channel for comment notifications. 373 pub async fn comments_stream( 374 State(state): State<AppState>, 375 jar: CookieJar, 376 Path(story_id): Path<i64>, 377 ) -> Response { 378 let user_id = state.get_user_id(&jar).await; 379 let is_logged_in = user_id.is_some(); 380 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(10); 381 let mut broadcast_rx = state.comment_tx.subscribe(); 382 383 let state_clone = state.clone(); 384 tokio::spawn(async move { 385 // Send current comments immediately 386 match fetch_enriched_comments(&state_clone, user_id, story_id).await { 387 Ok(comments) => { 388 if let Some(event) = render_comments_event(comments, is_logged_in, story_id) { 389 if tx.send(Ok(event)).await.is_err() { 390 return; 391 } 392 } 393 } 394 Err(e) => { 395 eprintln!("Failed to fetch initial comments for SSE: {}", e); 396 return; 397 } 398 } 399 400 // Wait for broadcast notifications 401 loop { 402 match broadcast_rx.recv().await { 403 Ok(changed_story_id) if changed_story_id == story_id => { 404 match fetch_enriched_comments(&state_clone, user_id, story_id).await { 405 Ok(comments) => { 406 if let Some(event) = render_comments_event(comments, is_logged_in, story_id) { 407 if tx.send(Ok(event)).await.is_err() { 408 break; 409 } 410 } 411 } 412 Err(e) => { 413 eprintln!("Failed to fetch comments for SSE: {}", e); 414 } 415 } 416 } 417 Ok(_) => continue, 418 Err(tokio::sync::broadcast::error::RecvError::Closed) => break, 419 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, 420 } 421 } 422 }); 423 424 let stream = tokio_stream::wrappers::ReceiverStream::new(rx); 425 let sse = Sse::new(stream).keep_alive( 426 axum::response::sse::KeepAlive::new() 427 .interval(std::time::Duration::from_secs(30)), 428 ); 429 let mut response = sse.into_response(); 430 response 431 .headers_mut() 432 .insert("X-Accel-Buffering", "no".parse().unwrap()); 433 response 434 } 435 436 fn render_edit_error(story_id: i64, form: &EditForm, all_tags: &[database::Category], error: &str) -> Html<String> { 437 let edit = EditTemplate { 438 error: Some(error.to_string()), 439 story_id, 440 title: form.title.clone(), 441 url: form.url.clone().unwrap_or_default(), 442 text: form.text.clone().unwrap_or_default(), 443 all_tags: all_tags.to_vec(), 444 selected_tags: form.tags.clone(), 445 }; 446 let app = ApplicationTemplate { 447 content: edit.render().unwrap_or_default(), 448 }; 449 Html(app.render().unwrap_or_default()) 450 }