Edit posts
Use fix routes for user profile edit handles to make it easier when user decides to change his username
This commit is contained in:
16
.sqlx/query-790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3.json
generated
Normal file
16
.sqlx/query-790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
22
.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json
generated
Normal file
22
.sqlx/query-84fcada696e1be5db55ef276e120ffef9adf7f5a4f5c4d5975b85e008e15620b.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -34,14 +34,12 @@
|
|||||||
--color-green-800: oklch(44.8% 0.119 151.328);
|
--color-green-800: oklch(44.8% 0.119 151.328);
|
||||||
--color-blue-50: oklch(97% 0.014 254.604);
|
--color-blue-50: oklch(97% 0.014 254.604);
|
||||||
--color-blue-100: oklch(93.2% 0.032 255.585);
|
--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-300: oklch(80.9% 0.105 251.813);
|
||||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||||
--color-blue-600: oklch(54.6% 0.245 262.881);
|
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||||
--color-blue-800: oklch(42.4% 0.199 265.638);
|
--color-blue-800: oklch(42.4% 0.199 265.638);
|
||||||
--color-blue-900: oklch(37.9% 0.146 265.522);
|
--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-500: oklch(58.5% 0.233 277.117);
|
||||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||||
--color-indigo-700: oklch(45.7% 0.24 277.023);
|
--color-indigo-700: oklch(45.7% 0.24 277.023);
|
||||||
@@ -294,9 +292,6 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mx-2 {
|
|
||||||
margin-inline: calc(var(--spacing) * 2);
|
|
||||||
}
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -321,9 +316,6 @@
|
|||||||
.mt-8 {
|
.mt-8 {
|
||||||
margin-top: calc(var(--spacing) * 8);
|
margin-top: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
.mt-12 {
|
|
||||||
margin-top: calc(var(--spacing) * 12);
|
|
||||||
}
|
|
||||||
.mt-auto {
|
.mt-auto {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
@@ -641,10 +633,6 @@
|
|||||||
border-bottom-style: var(--tw-border-style);
|
border-bottom-style: var(--tw-border-style);
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
}
|
}
|
||||||
.border-b-2 {
|
|
||||||
border-bottom-style: var(--tw-border-style);
|
|
||||||
border-bottom-width: 2px;
|
|
||||||
}
|
|
||||||
.border-l-4 {
|
.border-l-4 {
|
||||||
border-left-style: var(--tw-border-style);
|
border-left-style: var(--tw-border-style);
|
||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
@@ -810,6 +798,9 @@
|
|||||||
.py-1 {
|
.py-1 {
|
||||||
padding-block: calc(var(--spacing) * 1);
|
padding-block: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.py-1\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
.py-2 {
|
.py-2 {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -837,9 +828,6 @@
|
|||||||
.pb-4 {
|
.pb-4 {
|
||||||
padding-bottom: calc(var(--spacing) * 4);
|
padding-bottom: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
.pb-8 {
|
|
||||||
padding-bottom: calc(var(--spacing) * 8);
|
|
||||||
}
|
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1295,6 +1283,11 @@
|
|||||||
margin-inline: calc(var(--spacing) * 0);
|
margin-inline: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:mt-0 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
margin-top: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:inline {
|
.sm\:inline {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
display: inline;
|
display: inline;
|
||||||
@@ -1394,11 +1387,6 @@
|
|||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
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 {
|
.lg\:ml-0 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
margin-left: calc(var(--spacing) * 0);
|
margin-left: calc(var(--spacing) * 0);
|
||||||
@@ -1892,7 +1880,7 @@
|
|||||||
margin-bottom: calc(var(--spacing) * 3);
|
margin-bottom: calc(var(--spacing) * 3);
|
||||||
border-bottom-style: var(--tw-border-style);
|
border-bottom-style: var(--tw-border-style);
|
||||||
border-bottom-width: 2px;
|
border-bottom-width: 2px;
|
||||||
border-color: var(--color-gray-100);
|
border-color: var(--color-gray-200);
|
||||||
padding-bottom: calc(var(--spacing) * 2);
|
padding-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
.prose-compact h2 {
|
.prose-compact h2 {
|
||||||
@@ -1900,7 +1888,7 @@
|
|||||||
margin-bottom: calc(var(--spacing) * 3);
|
margin-bottom: calc(var(--spacing) * 3);
|
||||||
border-bottom-style: var(--tw-border-style);
|
border-bottom-style: var(--tw-border-style);
|
||||||
border-bottom-width: 2px;
|
border-bottom-width: 2px;
|
||||||
border-color: var(--color-gray-100);
|
border-color: var(--color-gray-200);
|
||||||
padding-bottom: calc(var(--spacing) * 2);
|
padding-bottom: calc(var(--spacing) * 2);
|
||||||
--tw-font-weight: var(--font-weight-semibold);
|
--tw-font-weight: var(--font-weight-semibold);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ impl PostEntry {
|
|||||||
self.published_at.format("%B %d, %Y").to_string()
|
self.published_at.format("%B %d, %Y").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_html(self) -> Result<Self, anyhow::Error> {
|
pub fn to_html(&self) -> anyhow::Result<String> {
|
||||||
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
|
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
|
||||||
Ok(mut content) => {
|
Ok(content) => Ok(content),
|
||||||
content = content.replace("<table>", r#"<div class="table-wrapper"><table>"#);
|
|
||||||
content = content.replace("</table>", r#"</table></div>"#);
|
|
||||||
Ok(Self { content, ..self })
|
|
||||||
}
|
|
||||||
Err(e) => anyhow::bail!(e),
|
Err(e) => anyhow::bail!(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::routes::{COMMENTS_PER_PAGE, get_max_page};
|
use crate::authentication::AuthenticatedUser;
|
||||||
use crate::templates::PostsPageDashboardTemplate;
|
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page};
|
||||||
|
use crate::session_state::TypedSession;
|
||||||
|
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::PostEntry,
|
domain::PostEntry,
|
||||||
routes::{
|
routes::{
|
||||||
AppError, Path, Query, get_comments_count_for_post, get_comments_page_for_post,
|
AppError, Path, get_comments_count_for_post, get_comments_page_for_post, not_found_html,
|
||||||
not_found_html,
|
|
||||||
},
|
},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
|
||||||
@@ -12,6 +13,7 @@ use crate::{
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Extension, Form,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
@@ -118,17 +120,65 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct PostParams {
|
pub struct EditPostForm {
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Editing post", skip_all, fields(post_id = %post_id))]
|
||||||
|
pub async fn update_post(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
|
Path(post_id): Path<Uuid>,
|
||||||
|
Form(form): Form<EditPostForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<Uuid>,
|
origin: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
pub async fn see_post(
|
||||||
|
session: TypedSession,
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Path(post_id): Path<Uuid>,
|
Path(post_id): Path<Uuid>,
|
||||||
Query(PostParams { origin }): Query<PostParams>,
|
Query(OriginQueryParam { origin }): Query<OriginQueryParam>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if let Some(origin) = origin {
|
if let Some(origin) = origin {
|
||||||
mark_email_as_opened(&connection_pool, origin).await?;
|
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))
|
.context(format!("Failed to fetch post #{}", post_id))
|
||||||
.map_err(AppError::unexpected_page)?
|
.map_err(AppError::unexpected_page)?
|
||||||
{
|
{
|
||||||
let post = post
|
let post_html = post
|
||||||
.to_html()
|
.to_html()
|
||||||
.context("Could not render markdown with extension.")?;
|
.context("Could not render markdown with extension.")?;
|
||||||
let current_page = 1;
|
let current_page = 1;
|
||||||
@@ -152,13 +202,19 @@ pub async fn see_post(
|
|||||||
.await
|
.await
|
||||||
.context("Failed to fetch latest comments")?;
|
.context("Failed to fetch latest comments")?;
|
||||||
let idempotency_key = Uuid::new_v4().to_string();
|
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 {
|
let template = HtmlTemplate(PostTemplate {
|
||||||
post,
|
post,
|
||||||
|
post_html,
|
||||||
comments,
|
comments,
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
current_page,
|
current_page,
|
||||||
max_page,
|
max_page,
|
||||||
comments_count,
|
comments_count,
|
||||||
|
session_username,
|
||||||
});
|
});
|
||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,21 +19,12 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn get_user_edit(
|
pub async fn user_edit_form(
|
||||||
Path(username): Path<String>,
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
Extension(AuthenticatedUser {
|
|
||||||
user_id,
|
|
||||||
username: session_username,
|
|
||||||
..
|
|
||||||
}): Extension<AuthenticatedUser>,
|
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if username != session_username {
|
|
||||||
let template = HtmlTemplate(ErrorTemplate::Forbidden);
|
|
||||||
return Ok(template.into_response());
|
|
||||||
}
|
|
||||||
let user = sqlx::query_as!(
|
let user = sqlx::query_as!(
|
||||||
UserEntry,
|
UserEntry,
|
||||||
r#"
|
r#"
|
||||||
@@ -52,25 +43,26 @@ pub async fn get_user_edit(
|
|||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct EditProfileForm {
|
pub struct EditProfileForm {
|
||||||
|
user_id: Uuid,
|
||||||
username: String,
|
username: String,
|
||||||
full_name: String,
|
full_name: String,
|
||||||
bio: 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 {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
session: TypedSession,
|
session: TypedSession,
|
||||||
Extension(AuthenticatedUser {
|
Extension(AuthenticatedUser {
|
||||||
user_id,
|
user_id: session_user_id,
|
||||||
username: session_username,
|
username: session_username,
|
||||||
..
|
..
|
||||||
}): Extension<AuthenticatedUser>,
|
}): Extension<AuthenticatedUser>,
|
||||||
Path(username): Path<String>,
|
|
||||||
Form(form): Form<EditProfileForm>,
|
Form(form): Form<EditProfileForm>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if username != session_username {
|
if form.user_id != session_user_id {
|
||||||
let template = HtmlTemplate(ErrorTemplate::Forbidden);
|
let template = HtmlTemplate(ErrorTemplate::Forbidden);
|
||||||
return Ok(template.into_response());
|
return Ok(template.into_response());
|
||||||
}
|
}
|
||||||
@@ -101,7 +93,7 @@ pub async fn put_user_edit(
|
|||||||
updated_username,
|
updated_username,
|
||||||
updated_full_name,
|
updated_full_name,
|
||||||
bio,
|
bio,
|
||||||
user_id
|
form.user_id
|
||||||
)
|
)
|
||||||
.execute(&connection_pool)
|
.execute(&connection_pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use axum::{
|
|||||||
http::Request,
|
http::Request,
|
||||||
middleware,
|
middleware,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use reqwest::{StatusCode, header};
|
use reqwest::{StatusCode, header};
|
||||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||||
@@ -99,11 +99,9 @@ pub fn app(
|
|||||||
.route("/password", post(change_password))
|
.route("/password", post(change_password))
|
||||||
.route("/newsletters", post(publish_newsletter))
|
.route("/newsletters", post(publish_newsletter))
|
||||||
.route("/posts", post(create_post))
|
.route("/posts", post(create_post))
|
||||||
|
.route("/posts/{post_id}", put(update_post))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
.route(
|
.route("/users/edit", get(user_edit_form).put(update_user))
|
||||||
"/users/{username}/edit",
|
|
||||||
get(get_user_edit).put(put_user_edit),
|
|
||||||
)
|
|
||||||
.nest("/admin", admin_routes)
|
.nest("/admin", admin_routes)
|
||||||
.layer(middleware::from_fn(require_auth));
|
.layer(middleware::from_fn(require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -121,11 +121,13 @@ pub struct PostListTemplate {
|
|||||||
#[template(path = "posts/page.html")]
|
#[template(path = "posts/page.html")]
|
||||||
pub struct PostTemplate {
|
pub struct PostTemplate {
|
||||||
pub post: PostEntry,
|
pub post: PostEntry,
|
||||||
|
pub post_html: String,
|
||||||
pub idempotency_key: String,
|
pub idempotency_key: String,
|
||||||
pub comments: Vec<CommentEntry>,
|
pub comments: Vec<CommentEntry>,
|
||||||
pub current_page: i64,
|
pub current_page: i64,
|
||||||
pub max_page: i64,
|
pub max_page: i64,
|
||||||
pub comments_count: i64,
|
pub comments_count: i64,
|
||||||
|
pub session_username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|||||||
@@ -1,150 +1,151 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.htmx-indicator {
|
.htmx-indicator {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.htmx-request .htmx-indicator {
|
.htmx-request .htmx-indicator {
|
||||||
@apply inline-flex items-center ml-2;
|
@apply inline-flex items-center ml-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#load-more .htmx-indicator {
|
#load-more .htmx-indicator {
|
||||||
@apply block;
|
@apply block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.htmx-request .continue-text {
|
.htmx-request .continue-text {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.prose-compact {
|
.prose-compact {
|
||||||
@apply prose prose-slate max-w-none;
|
@apply prose prose-slate max-w-none;
|
||||||
|
|
||||||
--tw-prose-body: theme(colors.gray.700);
|
--tw-prose-body: theme(colors.gray.700);
|
||||||
--tw-prose-headings: theme(colors.gray.900);
|
--tw-prose-headings: theme(colors.gray.900);
|
||||||
--tw-prose-links: theme(colors.blue.600);
|
--tw-prose-links: theme(colors.blue.600);
|
||||||
--tw-prose-code: theme(colors.gray.800);
|
--tw-prose-code: theme(colors.gray.800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact p {
|
.prose-compact p {
|
||||||
@apply mb-2 mt-0;
|
@apply mb-2 mt-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h1 {
|
.prose-compact h1 {
|
||||||
@apply pb-2 mb-3 border-b-2 border-gray-100;
|
@apply pb-2 mb-3 border-b-2 border-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h2 {
|
.prose-compact h2 {
|
||||||
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-100 font-semibold;
|
@apply mt-4 pb-2 mb-3 border-b-2 border-gray-200 font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h3 {
|
.prose-compact h3 {
|
||||||
@apply mt-3 mb-1;
|
@apply mt-3 mb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact h4,
|
.prose-compact h4,
|
||||||
.prose-compact h5,
|
.prose-compact h5,
|
||||||
.prose-compact h6 {
|
.prose-compact h6 {
|
||||||
@apply mt-2 mb-1;
|
@apply mt-2 mb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact ul,
|
.prose-compact ul,
|
||||||
.prose-compact ol {
|
.prose-compact ol {
|
||||||
@apply my-2 space-y-0;
|
@apply my-2 space-y-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact li {
|
.prose-compact li {
|
||||||
@apply my-0;
|
@apply my-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact blockquote {
|
.prose-compact blockquote {
|
||||||
@apply my-3 py-2;
|
@apply my-3 py-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact img {
|
.prose-compact img {
|
||||||
@apply m-0 align-top;
|
@apply m-0 align-top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact a:has(img) {
|
.prose-compact a:has(img) {
|
||||||
@apply no-underline border-0 inline-block align-top;
|
@apply no-underline border-0 inline-block align-top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact a img {
|
.prose-compact a img {
|
||||||
@apply inline-block align-top;
|
@apply inline-block align-top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact :not(pre) > code {
|
.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;
|
@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::before,
|
||||||
.prose-compact :not(pre) > code::after {
|
.prose-compact :not(pre) > code::after {
|
||||||
content: none !important;
|
content: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact pre {
|
.prose-compact pre {
|
||||||
@apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200;
|
@apply my-3 p-4 bg-gray-100 text-gray-800 rounded-sm overflow-x-auto border border-gray-200;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 0;
|
width: 0;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact pre code {
|
.prose-compact pre code {
|
||||||
@apply bg-transparent text-gray-800 p-0 rounded-none;
|
@apply bg-transparent text-gray-800 p-0 rounded-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact pre code::before,
|
.prose-compact pre code::before,
|
||||||
.prose-compact pre code::after {
|
.prose-compact pre code::after {
|
||||||
content: none !important;
|
content: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact table {
|
.prose-compact table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
@apply my-6;
|
@apply my-6;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
display: block;
|
display: block;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact table thead,
|
.prose-compact table thead,
|
||||||
.prose-compact table tbody {
|
.prose-compact table tbody {
|
||||||
display: table;
|
display: table;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact table tr {
|
.prose-compact table tr {
|
||||||
display: table-row;
|
display: table-row;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-top: 1px solid #c6cbd1;
|
border-top: 1px solid #c6cbd1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact table tr:nth-child(2n) {
|
.prose-compact table tr:nth-child(2n) {
|
||||||
background-color: #f6f8fa;
|
background-color: #f6f8fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact table th,
|
.prose-compact table th,
|
||||||
.prose-compact table td {
|
.prose-compact table td {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
padding: 6px 13px;
|
padding: 6px 13px;
|
||||||
border: 1px solid #dfe2e5;
|
border: 1px solid #dfe2e5;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact table th {
|
.prose-compact table th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background-color: #f6f8fa;
|
background-color: #f6f8fa;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ post.title }}{% endblock %}
|
{% block title %}Edit: {{ post.title }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<article>
|
<article>
|
||||||
<header class="mb-4">
|
<header class="mb-4">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -33,9 +33,70 @@
|
|||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if session_username.as_deref() == Some(post.author) %}
|
||||||
|
<div class="mt-4 sm:mt-0">
|
||||||
|
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="prose-compact">{{ post.content | safe }}</div>
|
|
||||||
|
{% if session_username.as_deref() == Some(post.author) %}
|
||||||
|
<div id="edit-form" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
|
||||||
|
<form hx-put="/posts/{{ post.post_id }}"
|
||||||
|
hx-target="#edit-messages"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||||
|
<input type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value="{{ post.title }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
|
||||||
|
<textarea id="content"
|
||||||
|
name="content"
|
||||||
|
rows="12"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="document.getElementById('edit-form').classList.add('hidden')"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="edit-messages" class="mt-6"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
|
||||||
</article>
|
</article>
|
||||||
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
|
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
|
||||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||||
@@ -47,4 +108,4 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile Information</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile Information</h2>
|
||||||
|
|
||||||
<form hx-put="/users/{{ user.username }}/edit"
|
<form hx-put="/users/edit"
|
||||||
hx-target="#edit-messages"
|
hx-target="#edit-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-6">
|
class="space-y-6">
|
||||||
|
<input type="hidden" name="user_id" value="{{ user.user_id }}" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
|
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
|
||||||
Save Changes
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="edit-messages"></div>
|
<div id="edit-messages"></div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if session_username.as_deref() == Some(user.username) %}
|
{% if session_username.as_deref() == Some(user.username) %}
|
||||||
<a href="/users/{{ user.username }}/edit"
|
<a href="/users/edit"
|
||||||
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-3">
|
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-3">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
|||||||
Reference in New Issue
Block a user