simple-web-app

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

commit 55c3aa1b5600a54909135ea1a91077d72041b626
parent 84d53239a72d7afd3c77e7ce931b3b308f3f3cab
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun,  7 Jun 2026 17:50:40 +0200

feat: add story editing and tag selection

- Add edit page for story authors (ownership check)
- Add tag checkboxes to submit and edit forms
- Show "edit" link on story page for the author
- Add update_story and tag management functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/handlers/story.rs | 167++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/routes.rs | 2++
Msrc/templates.rs | 15+++++++++++++++
Msrc/trailbase.rs | 34+++++++++++++++++++++++++++++++---
Mstatic/style.css | 19+++++++++++++++++++
Mstatic/style.css.gz | 0
Atemplates/edit.html | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/story.html | 11+++++++++++
Mtemplates/submit.html | 19+++++++++++++++++++
9 files changed, 308 insertions(+), 13 deletions(-)

diff --git a/src/handlers/story.rs b/src/handlers/story.rs @@ -11,7 +11,7 @@ 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::templates::{ApplicationTemplate, EditTemplate, StoryTemplate, SubmitTemplate}; use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; pub async fn show_story( @@ -93,11 +93,15 @@ pub async fn show_story( } let is_logged_in = tokens.is_some(); + let current_user_id = tokens + .as_ref() + .and_then(|t| trailbase::extract_user_id_from_token(&t.auth_token)); let story_template = StoryTemplate { story: story_with_meta, comments: enriched_comments, is_logged_in, + current_user_id, }; let app = ApplicationTemplate { @@ -106,21 +110,27 @@ pub async fn show_story( 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 +pub async fn show_submit( + State(state): State<AppState>, + jar: CookieJar, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar); + if tokens.is_none() { return Ok(Html( r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), )); } + let client = trailbase::new_client(&state.trailbase_url)?; + let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); + let submit = SubmitTemplate { error: None, title: String::new(), url: String::new(), text: String::new(), + all_tags, + selected_tags: vec![], }; let app = ApplicationTemplate { @@ -134,6 +144,8 @@ pub struct SubmitForm { pub title: String, pub url: Option<String>, pub text: Option<String>, + #[serde(default)] + pub tags: Vec<i64>, } pub async fn submit_story( @@ -146,7 +158,9 @@ pub async fn submit_story( })?; let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens) - .map_err(|e| render_submit_error(&form, &format!("Client error: {}", e)))?; + .map_err(|e| render_submit_error(&form, &[], &format!("Client error: {}", e)))?; + + let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); // Validate: must have either URL or text let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); @@ -155,12 +169,13 @@ pub async fn submit_story( if url.is_none() && text.is_none() { return Err(render_submit_error( &form, + &all_tags, "Please provide either a URL or text", )); } if form.title.trim().is_empty() { - return Err(render_submit_error(&form, "Title is required")); + return Err(render_submit_error(&form, &all_tags, "Title is required")); } // Extract domain from URL @@ -178,20 +193,152 @@ pub async fn submit_story( let story = trailbase::create_story(&client, create_story) .await - .map_err(|e| render_submit_error(&form, &format!("Failed to create story: {}", e)))?; + .map_err(|e| render_submit_error(&form, &all_tags, &format!("Failed to create story: {}", e)))?; + + // Add tags + for tag_id in &form.tags { + let _ = trailbase::add_tag_to_story(&client, story.id, *tag_id).await; + } Ok(Redirect::to(&format!("/story/{}", story.id))) } -fn render_submit_error(form: &SubmitForm, error: &str) -> Html<String> { +fn render_submit_error(form: &SubmitForm, all_tags: &[trailbase::Category], 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(), + all_tags: all_tags.to_vec(), + selected_tags: form.tags.clone(), }; let app = ApplicationTemplate { content: submit.render().unwrap_or_default(), }; Html(app.render().unwrap_or_default()) } + +// ============================================================================ +// Edit story +// ============================================================================ + +pub async fn show_edit( + State(state): State<AppState>, + jar: CookieJar, + Path(id): Path<i64>, +) -> Result<Html<String>, AppError> { + let tokens = get_tokens_from_cookies(&jar) + .ok_or_else(|| AppError::Unauthorized("Login required".to_string()))?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; + + let story = trailbase::get_story(&client, id) + .await? + .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; + + // Check ownership + let user_id = trailbase::extract_user_id_from_token(&tokens.auth_token) + .ok_or_else(|| AppError::Unauthorized("Invalid token".to_string()))?; + if story.created_by.as_deref() != Some(&user_id) { + return Err(AppError::Unauthorized("You can only edit your own stories".to_string())); + } + + let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); + let story_tags = trailbase::get_categories_for_story(&client, id).await.unwrap_or_default(); + let selected_tags: Vec<i64> = story_tags.iter().map(|t| t.id).collect(); + + let edit = EditTemplate { + error: None, + story_id: id, + title: story.title, + url: story.url.unwrap_or_default(), + text: story.text, + all_tags, + selected_tags, + }; + + let app = ApplicationTemplate { + content: edit.render()?, + }; + Ok(Html(app.render()?)) +} + +#[derive(Debug, Deserialize)] +pub struct EditForm { + pub title: String, + pub url: Option<String>, + pub text: Option<String>, + #[serde(default)] + pub tags: Vec<i64>, +} + +pub async fn edit_story( + State(state): State<AppState>, + jar: CookieJar, + Path(id): Path<i64>, + Form(form): Form<EditForm>, +) -> 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_edit_error(id, &form, &[], &format!("Client error: {}", e)))?; + + // Check ownership + let user_id = trailbase::extract_user_id_from_token(&tokens.auth_token) + .ok_or_else(|| render_edit_error(id, &form, &[], "Invalid token"))?; + + let story = trailbase::get_story(&client, id) + .await + .map_err(|e| render_edit_error(id, &form, &[], &format!("{}", e)))? + .ok_or_else(|| render_edit_error(id, &form, &[], "Story not found"))?; + + if story.created_by.as_deref() != Some(&user_id) { + return Err(render_edit_error(id, &form, &[], "You can only edit your own stories")); + } + + if form.title.trim().is_empty() { + let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); + return Err(render_edit_error(id, &form, &all_tags, "Title is required")); + } + + let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); + let text = form.text.as_ref().filter(|t| !t.trim().is_empty()); + let domain = url.and_then(|u| trailbase::extract_domain(u)); + + let update = trailbase::UpdateStory { + url: url.map(|s| s.to_string()), + title: form.title.trim().to_string(), + text: text.map(|s| s.to_string()).unwrap_or_default(), + domain, + }; + + trailbase::update_story(&client, id, update) + .await + .map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?; + + // Update tags: remove all, then re-add selected + let _ = trailbase::remove_all_tags_from_story(&client, id).await; + for tag_id in &form.tags { + let _ = trailbase::add_tag_to_story(&client, id, *tag_id).await; + } + + Ok(Redirect::to(&format!("/story/{}", id))) +} + +fn render_edit_error(story_id: i64, form: &EditForm, all_tags: &[trailbase::Category], error: &str) -> Html<String> { + let edit = EditTemplate { + error: Some(error.to_string()), + story_id, + title: form.title.clone(), + url: form.url.clone().unwrap_or_default(), + text: form.text.clone().unwrap_or_default(), + all_tags: all_tags.to_vec(), + selected_tags: form.tags.clone(), + }; + let app = ApplicationTemplate { + content: edit.render().unwrap_or_default(), + }; + Html(app.render().unwrap_or_default()) +} diff --git a/src/routes.rs b/src/routes.rs @@ -54,6 +54,8 @@ pub fn create_router(state: AppState) -> Router { .route("/story/{id}", get(handlers::show_story)) .route("/submit", get(handlers::show_submit)) .route("/submit", post(handlers::submit_story)) + .route("/story/{id}/edit", get(handlers::show_edit)) + .route("/story/{id}/edit", post(handlers::edit_story)) // Comments .route("/story/{id}/comment", post(handlers::create_comment)) diff --git a/src/templates.rs b/src/templates.rs @@ -27,6 +27,7 @@ pub struct StoryTemplate { pub story: StoryWithMeta, pub comments: Vec<CommentWithMeta>, pub is_logged_in: bool, + pub current_user_id: Option<String>, } #[derive(Template)] @@ -36,6 +37,20 @@ pub struct SubmitTemplate { pub title: String, pub url: String, pub text: String, + pub all_tags: Vec<Category>, + pub selected_tags: Vec<i64>, +} + +#[derive(Template)] +#[template(path = "edit.html")] +pub struct EditTemplate { + pub error: Option<String>, + pub story_id: i64, + pub title: String, + pub url: String, + pub text: String, + pub all_tags: Vec<Category>, + pub selected_tags: Vec<i64>, } #[derive(Template)] diff --git a/src/trailbase.rs b/src/trailbase.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use trailbase_client::{Client, ClientOptions, CompareOp, Filter, ListArguments, Pagination, Tokens}; -pub use crate::trailbase_types::{Category, Story, Comment, Vote, UserProfile}; +pub use crate::trailbase_types::{Category, Story, Comment, Vote, UserProfile, StoryCategory}; // Target type constants for votes pub const TARGET_TYPE_STORY: i64 = 1; @@ -584,14 +584,12 @@ pub async fn get_categories_for_story( } /// 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, @@ -605,6 +603,36 @@ pub async fn add_tag_to_story( Ok(()) } +/// Update a story's title, url, text, and domain +#[derive(Debug, Serialize)] +pub struct UpdateStory { + pub url: Option<String>, + pub title: String, + pub text: String, + pub domain: Option<String>, +} + +pub async fn update_story(client: &Client, id: i64, update: UpdateStory) -> Result<(), ClientError> { + client.records("story").update(id.to_string(), &update).await?; + Ok(()) +} + +/// Remove all tags from a story +pub async fn remove_all_tags_from_story( + client: &Client, + story_id: i64, +) -> Result<(), ClientError> { + let args = ListArguments::new() + .with_filters(Filter::new("news_item_id", CompareOp::Equal, story_id.to_string())); + let response: trailbase_client::ListResponse<StoryCategory> = + client.records("story_category").list(args).await?; + for sc in response.records { + client.records("story_category").delete(&sc.id.to_string()).await?; + } + Ok(()) +} + + // ============================================================================ // Search functions // ============================================================================ diff --git a/static/style.css b/static/style.css @@ -450,6 +450,25 @@ a:hover { color: var(--color-meta); } +.tag-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag-checkbox { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 9pt; + color: var(--color-text); + cursor: pointer; +} + +.tag-checkbox input[type="checkbox"] { + width: auto; +} + /* Reply Page */ .reply-page { max-width: 600px; diff --git a/static/style.css.gz b/static/style.css.gz Binary files differ. diff --git a/templates/edit.html b/templates/edit.html @@ -0,0 +1,54 @@ +<div class="submit-page"> + <h2>Edit Story</h2> + + {% match error %} + {% when Some with (err) %} + <div class="error-message">{{ err }}</div> + {% when None %} + {% endmatch %} + + <form action="/story/{{ story_id }}/edit" 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">{{ text }}</textarea> + </div> + + {% if !all_tags.is_empty() %} + <div class="form-group"> + <label>Tags</label> + <div class="tag-checkboxes"> + {% for tag in all_tags %} + {% match tag.name %} + {% when Some with (name) %} + <label class="tag-checkbox"> + <input type="checkbox" name="tags" value="{{ tag.id }}" + {% if selected_tags.contains(tag.id) %} checked{% endif %} /> + {{ name }} + </label> + {% when None %} + {% endmatch %} + {% endfor %} + </div> + </div> + {% endif %} + + <div class="form-actions"> + <button type="submit">Save Changes</button> + <a href="/story/{{ story_id }}">Cancel</a> + </div> + </form> +</div> diff --git a/templates/story.html b/templates/story.html @@ -41,6 +41,17 @@ <span class="story-time">{{ story.time_ago }}</span> | <span class="story-comments">{{ story.comment_count }} comment{% if story.comment_count != 1 %}s{% endif %}</span> + {% match current_user_id %} + {% when Some with (uid) %} + {% match story.created_by %} + {% when Some with (author_id) %} + {% if uid == author_id %} + | <a href="/story/{{ story.id }}/edit">edit</a> + {% endif %} + {% when None %} + {% endmatch %} + {% when None %} + {% endmatch %} {% if !story.tags.is_empty() %} | {% for tag in story.tags %} diff --git a/templates/submit.html b/templates/submit.html @@ -27,6 +27,25 @@ <textarea id="text" name="text" rows="6" placeholder="Leave URL blank to submit a text post">{{ text }}</textarea> </div> + {% if !all_tags.is_empty() %} + <div class="form-group"> + <label>Tags</label> + <div class="tag-checkboxes"> + {% for tag in all_tags %} + {% match tag.name %} + {% when Some with (name) %} + <label class="tag-checkbox"> + <input type="checkbox" name="tags" value="{{ tag.id }}" + {% if selected_tags.contains(tag.id) %} checked{% endif %} /> + {{ name }} + </label> + {% when None %} + {% endmatch %} + {% endfor %} + </div> + </div> + {% endif %} + <div class="form-actions"> <button type="submit">Submit</button> </div>