diff --git a/.sqlx/query-790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3.json b/.sqlx/query-790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3.json new file mode 100644 index 0000000..da5c28e --- /dev/null +++ b/.sqlx/query-790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE posts\n SET title = $1, content = $2 WHERE post_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3" +} diff --git a/.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json b/.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json new file mode 100644 index 0000000..64f4275 --- /dev/null +++ b/.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT author_id FROM posts WHERE post_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "author_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b" +} diff --git a/assets/css/main.css b/assets/css/main.css index 90d44e5..cca625b 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -34,14 +34,12 @@ --color-green-800: oklch(44.8% 0.119 151.328); --color-blue-50: oklch(97% 0.014 254.604); --color-blue-100: oklch(93.2% 0.032 255.585); - --color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-300: oklch(80.9% 0.105 251.813); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); --color-blue-800: oklch(42.4% 0.199 265.638); --color-blue-900: oklch(37.9% 0.146 265.522); - --color-indigo-50: oklch(96.2% 0.018 272.314); --color-indigo-500: oklch(58.5% 0.233 277.117); --color-indigo-600: oklch(51.1% 0.262 276.966); --color-indigo-700: oklch(45.7% 0.24 277.023); @@ -294,9 +292,6 @@ max-width: 96rem; } } - .mx-2 { - margin-inline: calc(var(--spacing) * 2); - } .mx-auto { margin-inline: auto; } @@ -321,9 +316,6 @@ .mt-8 { margin-top: calc(var(--spacing) * 8); } - .mt-12 { - margin-top: calc(var(--spacing) * 12); - } .mt-auto { margin-top: auto; } @@ -641,10 +633,6 @@ border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } - .border-b-2 { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 2px; - } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; @@ -810,6 +798,9 @@ .py-1 { padding-block: calc(var(--spacing) * 1); } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } .py-2 { padding-block: calc(var(--spacing) * 2); } @@ -837,9 +828,6 @@ .pb-4 { padding-bottom: calc(var(--spacing) * 4); } - .pb-8 { - padding-bottom: calc(var(--spacing) * 8); - } .text-center { text-align: center; } @@ -1295,6 +1283,11 @@ margin-inline: calc(var(--spacing) * 0); } } + .sm\:mt-0 { + @media (width >= 40rem) { + margin-top: calc(var(--spacing) * 0); + } + } .sm\:inline { @media (width >= 40rem) { display: inline; @@ -1394,11 +1387,6 @@ line-height: var(--tw-leading, var(--text-4xl--line-height)); } } - .lg\:col-span-2 { - @media (width >= 64rem) { - grid-column: span 2 / span 2; - } - } .lg\:ml-0 { @media (width >= 64rem) { margin-left: calc(var(--spacing) * 0); @@ -1892,7 +1880,7 @@ margin-bottom: calc(var(--spacing) * 3); border-bottom-style: var(--tw-border-style); border-bottom-width: 2px; - border-color: var(--color-gray-100); + border-color: var(--color-gray-200); padding-bottom: calc(var(--spacing) * 2); } .prose-compact h2 { @@ -1900,7 +1888,7 @@ margin-bottom: calc(var(--spacing) * 3); border-bottom-style: var(--tw-border-style); border-bottom-width: 2px; - border-color: var(--color-gray-100); + border-color: var(--color-gray-200); padding-bottom: calc(var(--spacing) * 2); --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); diff --git a/src/domain/post.rs b/src/domain/post.rs index c881695..b483b26 100644 --- a/src/domain/post.rs +++ b/src/domain/post.rs @@ -14,13 +14,9 @@ impl PostEntry { self.published_at.format("%B %d, %Y").to_string() } - pub fn to_html(self) -> Result { + pub fn to_html(&self) -> anyhow::Result { match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) { - Ok(mut content) => { - content = content.replace("", r#"
"#); - content = content.replace("
", r#""#); - Ok(Self { content, ..self }) - } + Ok(content) => Ok(content), Err(e) => anyhow::bail!(e), } } diff --git a/src/routes/posts.rs b/src/routes/posts.rs index a6a2e79..c8f48c5 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -1,10 +1,11 @@ -use crate::routes::{COMMENTS_PER_PAGE, get_max_page}; -use crate::templates::PostsPageDashboardTemplate; +use crate::authentication::AuthenticatedUser; +use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page}; +use crate::session_state::TypedSession; +use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate}; use crate::{ domain::PostEntry, routes::{ - AppError, Path, Query, get_comments_count_for_post, get_comments_page_for_post, - not_found_html, + AppError, Path, get_comments_count_for_post, get_comments_page_for_post, not_found_html, }, startup::AppState, templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate}, @@ -12,6 +13,7 @@ use crate::{ use anyhow::Context; use askama::Template; use axum::{ + Extension, Form, extract::State, response::{Html, IntoResponse, Redirect, Response}, }; @@ -118,17 +120,65 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result, + Extension(AuthenticatedUser { user_id, .. }): Extension, + Path(post_id): Path, + Form(form): Form, +) -> Result { + let record = sqlx::query!("SELECT author_id FROM posts WHERE post_id = $1", post_id) + .fetch_optional(&connection_pool) + .await + .context("Could not fetch post author.")?; + match record { + None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()), + Some(record) if record.author_id == user_id => { + sqlx::query!( + " + UPDATE posts + SET title = $1, content = $2 WHERE post_id = $3 + ", + form.title, + form.content, + post_id + ) + .execute(&connection_pool) + .await + .context("Could not update post")?; + Ok(HtmlTemplate(MessageTemplate::success( + "Your changes have been saved.".into(), + )) + .into_response()) + } + _ => Ok(HtmlTemplate(ErrorTemplate::Forbidden).into_response()), + } +} + +#[derive(serde::Deserialize)] +pub struct OriginQueryParam { origin: Option, } -#[tracing::instrument(name = "Fetching post from database", skip(connection_pool, origin))] +#[tracing::instrument( + name = "Fetching post from database", + skip(connection_pool, origin, session) +)] + pub async fn see_post( + session: TypedSession, State(AppState { connection_pool, .. }): State, Path(post_id): Path, - Query(PostParams { origin }): Query, + Query(OriginQueryParam { origin }): Query, ) -> Result { if let Some(origin) = origin { mark_email_as_opened(&connection_pool, origin).await?; @@ -140,7 +190,7 @@ pub async fn see_post( .context(format!("Failed to fetch post #{}", post_id)) .map_err(AppError::unexpected_page)? { - let post = post + let post_html = post .to_html() .context("Could not render markdown with extension.")?; let current_page = 1; @@ -152,13 +202,19 @@ pub async fn see_post( .await .context("Failed to fetch latest comments")?; let idempotency_key = Uuid::new_v4().to_string(); + let session_username = session + .get_username() + .await + .context("Could not check for session username")?; let template = HtmlTemplate(PostTemplate { post, + post_html, comments, idempotency_key, current_page, max_page, comments_count, + session_username, }); Ok(template.into_response()) } else { diff --git a/src/routes/users.rs b/src/routes/users.rs index e900990..c37bf15 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -19,21 +19,12 @@ use secrecy::{ExposeSecret, SecretString}; use sqlx::PgPool; use uuid::Uuid; -pub async fn get_user_edit( - Path(username): Path, - Extension(AuthenticatedUser { - user_id, - username: session_username, - .. - }): Extension, +pub async fn user_edit_form( + Extension(AuthenticatedUser { user_id, .. }): Extension, State(AppState { connection_pool, .. }): State, ) -> Result { - if username != session_username { - let template = HtmlTemplate(ErrorTemplate::Forbidden); - return Ok(template.into_response()); - } let user = sqlx::query_as!( UserEntry, r#" @@ -52,25 +43,26 @@ pub async fn get_user_edit( #[derive(serde::Deserialize)] pub struct EditProfileForm { + user_id: Uuid, username: String, full_name: String, bio: String, } -pub async fn put_user_edit( +#[tracing::instrument(name = "Updating user profile", skip_all, fields(user_id = %form.user_id))] +pub async fn update_user( State(AppState { connection_pool, .. }): State, session: TypedSession, Extension(AuthenticatedUser { - user_id, + user_id: session_user_id, username: session_username, .. }): Extension, - Path(username): Path, Form(form): Form, ) -> Result { - if username != session_username { + if form.user_id != session_user_id { let template = HtmlTemplate(ErrorTemplate::Forbidden); return Ok(template.into_response()); } @@ -101,7 +93,7 @@ pub async fn put_user_edit( updated_username, updated_full_name, bio, - user_id + form.user_id ) .execute(&connection_pool) .await diff --git a/src/startup.rs b/src/startup.rs index b2e171a..2a988ad 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -7,7 +7,7 @@ use axum::{ http::Request, middleware, response::{IntoResponse, Response}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, }; use reqwest::{StatusCode, header}; use sqlx::{PgPool, postgres::PgPoolOptions}; @@ -99,11 +99,9 @@ pub fn app( .route("/password", post(change_password)) .route("/newsletters", post(publish_newsletter)) .route("/posts", post(create_post)) + .route("/posts/{post_id}", put(update_post)) .route("/logout", get(logout)) - .route( - "/users/{username}/edit", - get(get_user_edit).put(put_user_edit), - ) + .route("/users/edit", get(user_edit_form).put(update_user)) .nest("/admin", admin_routes) .layer(middleware::from_fn(require_auth)); Router::new() diff --git a/src/templates.rs b/src/templates.rs index ffed083..8765f5d 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -121,11 +121,13 @@ pub struct PostListTemplate { #[template(path = "posts/page.html")] pub struct PostTemplate { pub post: PostEntry, + pub post_html: String, pub idempotency_key: String, pub comments: Vec, pub current_page: i64, pub max_page: i64, pub comments_count: i64, + pub session_username: Option, } #[derive(Template)] diff --git a/templates/input.css b/templates/input.css index ec055e4..d0996b8 100644 --- a/templates/input.css +++ b/templates/input.css @@ -1,150 +1,151 @@ @import "tailwindcss"; + @plugin "@tailwindcss/typography"; @layer utilities { - .htmx-indicator { - @apply hidden; - } + .htmx-indicator { + @apply hidden; + } - .htmx-request .htmx-indicator { - @apply inline-flex items-center ml-2; - } + .htmx-request .htmx-indicator { + @apply inline-flex items-center ml-2; + } - #load-more .htmx-indicator { - @apply block; - } + #load-more .htmx-indicator { + @apply block; + } - .htmx-request .continue-text { - @apply hidden; - } + .htmx-request .continue-text { + @apply hidden; + } } @layer components { - .prose-compact { - @apply prose prose-slate max-w-none; + .prose-compact { + @apply prose prose-slate max-w-none; - --tw-prose-body: theme(colors.gray.700); - --tw-prose-headings: theme(colors.gray.900); - --tw-prose-links: theme(colors.blue.600); - --tw-prose-code: theme(colors.gray.800); - } + --tw-prose-body: theme(colors.gray.700); + --tw-prose-headings: theme(colors.gray.900); + --tw-prose-links: theme(colors.blue.600); + --tw-prose-code: theme(colors.gray.800); + } - .prose-compact p { - @apply mb-2 mt-0; - } + .prose-compact p { + @apply mb-2 mt-0; + } - .prose-compact h1 { - @apply pb-2 mb-3 border-b-2 border-gray-100; - } + .prose-compact h1 { + @apply pb-2 mb-3 border-b-2 border-gray-200; + } - .prose-compact h2 { - @apply mt-4 pb-2 mb-3 border-b-2 border-gray-100 font-semibold; - } + .prose-compact h2 { + @apply mt-4 pb-2 mb-3 border-b-2 border-gray-200 font-semibold; + } - .prose-compact h3 { - @apply mt-3 mb-1; - } + .prose-compact h3 { + @apply mt-3 mb-1; + } - .prose-compact h4, - .prose-compact h5, - .prose-compact h6 { - @apply mt-2 mb-1; - } + .prose-compact h4, + .prose-compact h5, + .prose-compact h6 { + @apply mt-2 mb-1; + } - .prose-compact ul, - .prose-compact ol { - @apply my-2 space-y-0; - } + .prose-compact ul, + .prose-compact ol { + @apply my-2 space-y-0; + } - .prose-compact li { - @apply my-0; - } + .prose-compact li { + @apply my-0; + } - .prose-compact blockquote { - @apply my-3 py-2; - } + .prose-compact blockquote { + @apply my-3 py-2; + } - .prose-compact img { - @apply m-0 align-top; - } + .prose-compact img { + @apply m-0 align-top; + } - .prose-compact a:has(img) { - @apply no-underline border-0 inline-block align-top; - } + .prose-compact a:has(img) { + @apply no-underline border-0 inline-block align-top; + } - .prose-compact a img { - @apply inline-block align-top; - } + .prose-compact a img { + @apply inline-block align-top; + } - .prose-compact :not(pre) > code { - @apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono font-normal; - } + .prose-compact :not(pre) > code { + @apply bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono font-normal; + } - .prose-compact :not(pre) > code::before, - .prose-compact :not(pre) > code::after { - content: none !important; - } + .prose-compact :not(pre) > code::before, + .prose-compact :not(pre) > code::after { + content: none !important; + } - .prose-compact pre { - @apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200; - overflow-x: auto; - max-width: 100%; - width: 0; - min-width: 100%; - } + .prose-compact pre { + @apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200; + overflow-x: auto; + max-width: 100%; + width: 0; + min-width: 100%; + } - .prose-compact pre code { - @apply bg-transparent text-gray-800 p-0 rounded-none; - } + .prose-compact pre code { + @apply bg-transparent text-gray-800 p-0 rounded-none; + } - .prose-compact pre code::before, - .prose-compact pre code::after { - content: none !important; - } + .prose-compact pre code::before, + .prose-compact pre code::after { + content: none !important; + } - .prose-compact table { - border-collapse: collapse; - border-spacing: 0; - @apply my-6; - font-size: 14px; - line-height: 1.45; - display: block; - overflow-x: auto; - width: 100%; - min-width: 100%; - max-width: 100%; - } + .prose-compact table { + border-collapse: collapse; + border-spacing: 0; + @apply my-6; + font-size: 14px; + line-height: 1.45; + display: block; + overflow-x: auto; + width: 100%; + min-width: 100%; + max-width: 100%; + } - .prose-compact table thead, - .prose-compact table tbody { - display: table; - width: 100%; - table-layout: fixed; - } + .prose-compact table thead, + .prose-compact table tbody { + display: table; + width: 100%; + table-layout: fixed; + } - .prose-compact table tr { - display: table-row; - background-color: #fff; - border-top: 1px solid #c6cbd1; - } + .prose-compact table tr { + display: table-row; + background-color: #fff; + border-top: 1px solid #c6cbd1; + } - .prose-compact table tr:nth-child(2n) { - background-color: #f6f8fa; - } + .prose-compact table tr:nth-child(2n) { + background-color: #f6f8fa; + } - .prose-compact table th, - .prose-compact table td { - display: table-cell; - padding: 6px 13px; - border: 1px solid #dfe2e5; - white-space: normal; - word-wrap: break-word; - overflow-wrap: break-word; - min-width: 0; - } + .prose-compact table th, + .prose-compact table td { + display: table-cell; + padding: 6px 13px; + border: 1px solid #dfe2e5; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; + } - .prose-compact table th { - font-weight: 600; - background-color: #f6f8fa; - } + .prose-compact table th { + font-weight: 600; + background-color: #f6f8fa; + } } diff --git a/templates/posts/page.html b/templates/posts/page.html index 8797506..5f53514 100644 --- a/templates/posts/page.html +++ b/templates/posts/page.html @@ -1,10 +1,10 @@ {% extends "base.html" %} -{% block title %}{{ post.title }}{% endblock %} +{% block title %}Edit: {{ post.title }}{% endblock %} {% block content %}
-

{{ post.title }}

+

{{ post.title }}

@@ -33,9 +33,70 @@
+ {% if session_username.as_deref() == Some(post.author) %} +
+ +
+ {% endif %}
-
{{ post.content | safe }}
+ + {% if session_username.as_deref() == Some(post.author) %} + + {% endif %} +
{{ post_html | safe }}
{% include "posts/comments/list.html" %}
@@ -47,4 +108,4 @@
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/user/edit/update_profile.html b/templates/user/edit/update_profile.html index 34b1db3..978c57c 100644 --- a/templates/user/edit/update_profile.html +++ b/templates/user/edit/update_profile.html @@ -1,10 +1,11 @@

Profile Information

-
+
{% if session_username.as_deref() == Some(user.username) %} -