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:
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>