Record login for users
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT p.post_id, p.author_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
|
"query": "\n SELECT p.post_id, p.author_id, u.username AS author,\n p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "published_at",
|
"name": "published_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "last_modified",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -46,8 +51,9 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0463e392b69c5a82a64b4fa47fcf8f0abc63fa8ee77bdf60b4f6fdaca8b77e5a"
|
"hash": "836bd296bffff9a2ec14e43ea6aa64a468aaf0914bd95297431320621b42e396"
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n UPDATE posts\n SET title = $1, content = $2 WHERE post_id = $3\n ",
|
"query": "\n UPDATE posts\n SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Text",
|
"Text",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Timestamptz",
|
||||||
"Uuid"
|
"Uuid"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "790175eabe78857d16f1890ef4a1cd71f3a9c5f3e98a7c00553196f7a48d3bc3"
|
"hash": "aef1e780d14be61aa66ae8771309751741068694b291499ee1371de693c6a654"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT p.author_id, u.username as author, p.post_id, p.title, p.content, p.published_at\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
|
"query": "\n SELECT p.author_id, u.username as author,\n p.post_id, p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "published_at",
|
"name": "published_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "last_modified",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -45,8 +50,9 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "35ef2d2ed8b1477aebf642fe0f7e7dfc51157611de2da3ed45aed1fcbdf247b0"
|
"hash": "c545267390019d45c5b4b32caf6c46928ffc7bdac46828cf7f1104ef67f42391"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT p.post_id, p.author_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
|
"query": "\n SELECT p.post_id, p.author_id, u.username AS author,\n p.title, p.content, p.published_at, last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "published_at",
|
"name": "published_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "last_modified",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -45,8 +50,9 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "3b9af6d6aeed0d4550e7cc2d13a7923cfa3658c3bc4ab7b881c2147218db3d82"
|
"hash": "ccffe61c27508d32cf43556a8bffa465f24fec8416a4884ead4eafd324feea72"
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
2
migrations/20251009173005_add_last_modified_to_posts.sql
Normal file
2
migrations/20251009173005_add_last_modified_to_posts.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN last_modified TIMESTAMPTZ;
|
||||||
5
migrations/20251009180347_create_user_logins_table.sql
Normal file
5
migrations/20251009180347_create_user_logins_table.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE user_logins (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||||
|
login_time TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@ pub struct PostEntry {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub published_at: DateTime<Utc>,
|
pub published_at: DateTime<Utc>,
|
||||||
|
pub last_modified: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PostEntry {
|
impl PostEntry {
|
||||||
@@ -15,6 +16,10 @@ impl PostEntry {
|
|||||||
self.published_at.format("%B %d, %Y").to_string()
|
self.published_at.format("%B %d, %Y").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pub fn last_modified(&self) -> String {
|
||||||
|
// if let Some(last_modified) = self.last_modi
|
||||||
|
// }
|
||||||
|
|
||||||
pub fn to_html(&self) -> anyhow::Result<String> {
|
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(content) => Ok(content),
|
Ok(content) => Ok(content),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use axum::{http::StatusCode, response::Redirect};
|
use axum::{http::StatusCode, response::Redirect};
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct LoginFormData {
|
pub struct LoginFormData {
|
||||||
@@ -50,6 +52,9 @@ pub async fn post_login(
|
|||||||
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
||||||
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
|
let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
|
||||||
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
|
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
|
||||||
|
record_login(&connection_pool, &user_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to register new login event.")?;
|
||||||
|
|
||||||
session.renew().await.context("Failed to renew session.")?;
|
session.renew().await.context("Failed to renew session.")?;
|
||||||
session
|
session
|
||||||
@@ -69,3 +74,11 @@ pub async fn post_login(
|
|||||||
headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
|
headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
|
||||||
Ok((StatusCode::OK, headers).into_response())
|
Ok((StatusCode::OK, headers).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Recording new login event", skip_all, fields(user_id = %user_id))]
|
||||||
|
async fn record_login(connection_pool: &PgPool, user_id: &Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("INSERT INTO user_logins (user_id) VALUES ($1)", user_id)
|
||||||
|
.execute(connection_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
use chrono::Utc;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
@@ -77,7 +78,8 @@ async fn get_posts(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.post_id, p.author_id, u.username AS author, p.title, p.content, p.published_at
|
SELECT p.post_id, p.author_id, u.username AS author,
|
||||||
|
p.title, p.content, p.published_at, p.last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN users u ON p.author_id = u.user_id
|
LEFT JOIN users u ON p.author_id = u.user_id
|
||||||
ORDER BY p.published_at DESC
|
ORDER BY p.published_at DESC
|
||||||
@@ -99,7 +101,8 @@ pub async fn get_posts_page(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.post_id, p.author_id, u.username AS author, p.title, p.content, p.published_at
|
SELECT p.post_id, p.author_id, u.username AS author,
|
||||||
|
p.title, p.content, p.published_at, p.last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN users u ON p.author_id = u.user_id
|
LEFT JOIN users u ON p.author_id = u.user_id
|
||||||
ORDER BY p.published_at DESC
|
ORDER BY p.published_at DESC
|
||||||
@@ -151,10 +154,11 @@ pub async fn update_post(
|
|||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET title = $1, content = $2 WHERE post_id = $3
|
SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4
|
||||||
",
|
",
|
||||||
form.title,
|
form.title,
|
||||||
form.content,
|
form.content,
|
||||||
|
Utc::now(),
|
||||||
post_id
|
post_id
|
||||||
)
|
)
|
||||||
.execute(&connection_pool)
|
.execute(&connection_pool)
|
||||||
@@ -257,7 +261,8 @@ async fn get_post_data(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.post_id, p.author_id, u.username AS author, p.title, p.content, p.published_at
|
SELECT p.post_id, p.author_id, u.username AS author,
|
||||||
|
p.title, p.content, p.published_at, last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN users u ON p.author_id = u.user_id
|
LEFT JOIN users u ON p.author_id = u.user_id
|
||||||
WHERE p.post_id = $1
|
WHERE p.post_id = $1
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ pub async fn update_user(
|
|||||||
return Ok(template.into_response());
|
return Ok(template.into_response());
|
||||||
}
|
}
|
||||||
let updated_full_name = form.full_name.trim();
|
let updated_full_name = form.full_name.trim();
|
||||||
let bio = form.bio.trim();
|
let bio = {
|
||||||
|
let bio = form.bio.trim();
|
||||||
|
if bio.is_empty() { None } else { Some(bio) }
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
UPDATE users
|
UPDATE users
|
||||||
@@ -255,13 +259,32 @@ pub async fn user_profile(
|
|||||||
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
||||||
.await
|
.await
|
||||||
.context("Could not fetch user posts.")?;
|
.context("Could not fetch user posts.")?;
|
||||||
let session_username = session
|
let session_user_id = session
|
||||||
.get_username()
|
.get_user_id()
|
||||||
.await
|
.await
|
||||||
.context("Could not fetch session username.")?;
|
.context("Could not fetch session username.")?;
|
||||||
|
let profile_user_id =
|
||||||
|
sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_one(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch profile user id.")?
|
||||||
|
.user_id;
|
||||||
|
let last_seen = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT login_time FROM user_logins
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY login_time DESC
|
||||||
|
",
|
||||||
|
profile_user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch last user login")?
|
||||||
|
.map(|r| r.login_time);
|
||||||
let template = HtmlTemplate(UserTemplate {
|
let template = HtmlTemplate(UserTemplate {
|
||||||
user,
|
user,
|
||||||
session_username,
|
session_user_id,
|
||||||
|
last_seen,
|
||||||
posts,
|
posts,
|
||||||
});
|
});
|
||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
@@ -299,7 +322,8 @@ async fn fetch_user_posts(
|
|||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
PostEntry,
|
PostEntry,
|
||||||
r#"
|
r#"
|
||||||
SELECT p.author_id, u.username as author, p.post_id, p.title, p.content, p.published_at
|
SELECT p.author_id, u.username as author,
|
||||||
|
p.post_id, p.title, p.content, p.published_at, p.last_modified
|
||||||
FROM posts p
|
FROM posts p
|
||||||
INNER JOIN users u ON p.author_id = u.user_id
|
INNER JOIN users u ON p.author_id = u.user_id
|
||||||
WHERE p.author_id = $1
|
WHERE p.author_id = $1
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct HtmlTemplate<T>(pub T);
|
pub struct HtmlTemplate<T>(pub T);
|
||||||
@@ -25,8 +26,9 @@ where
|
|||||||
#[template(path = "user/profile.html")]
|
#[template(path = "user/profile.html")]
|
||||||
pub struct UserTemplate {
|
pub struct UserTemplate {
|
||||||
pub user: UserEntry,
|
pub user: UserEntry,
|
||||||
pub session_username: Option<String>,
|
pub session_user_id: Option<Uuid>,
|
||||||
pub posts: Vec<PostEntry>,
|
pub posts: Vec<PostEntry>,
|
||||||
|
pub last_seen: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
{% 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 space-y-4">
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
<h1 class="text-4xl font-bold text-gray-900 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">
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if let Some(modified) = post.last_modified %}
|
||||||
|
<span class="text-sm italic text-gray-500">Last modified on {{ modified.format("%B %d, %Y") }}, at {{ modified.format("%H:%M") }}</span>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
{% if session_user_id.as_ref() == Some(post.author_id) %}
|
{% if session_user_id.as_ref() == Some(post.author_id) %}
|
||||||
<div id="edit-form"
|
<div id="edit-form"
|
||||||
|
|||||||
@@ -1,55 +1,63 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ user.username }}{% endblock %}
|
{% block title %}{{ user.username }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-4xl mx-auto p-4 sm:p-6">
|
<div class="max-w-4xl mx-auto p-4 sm:p-6">
|
||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8 mb-6">
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 sm:p-8 space-y-6">
|
||||||
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg">
|
<div class="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold shadow-md">
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-1 text-center sm:text-left space-y-2">
|
||||||
<div class="flex-1 text-center sm:text-left">
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
<h1 class="text-3xl font-semibold text-gray-900 tracking-tight">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username)
|
{{ user.full_name.as_deref().unwrap_or(user.username) }}
|
||||||
}}</h1>
|
</h1>
|
||||||
{% if user.is_admin() %}
|
{% if user.is_admin() %}
|
||||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24">
|
viewBox="0 0 24 24">
|
||||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if session_user_id.as_ref() == Some(user.user_id) %}
|
||||||
|
<a href="/users/edit"
|
||||||
|
class="inline-flex items-center text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
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
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-gray-500 text-lg">@{{ user.username }}</p>
|
||||||
|
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
|
||||||
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{{ user.formatted_date() }}
|
||||||
|
</div>
|
||||||
|
{% if let Some(last_seen) = last_seen %}
|
||||||
|
<p class="text-sm italic text-gray-400">
|
||||||
|
Last seen on {{ last_seen.format("%h %d, %Y") }} at {{ last_seen.format("%H:%M") }}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if session_username.as_deref() == Some(user.username) %}
|
|
||||||
<a href="/users/edit"
|
|
||||||
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">
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<p class="text-gray-500 text-lg mb-3">@{{ user.username }}</p>
|
|
||||||
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
|
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
{{ user.formatted_date() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% if user.bio.is_some() %}
|
||||||
|
<div class="border-t border-gray-100 pt-6">
|
||||||
|
<p class="text-gray-700 leading-relaxed whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if user.bio.is_some() %}
|
<!-- Activity -->
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
<div class="mt-8">{% include "activity.html" %}</div>
|
||||||
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% include "activity.html" %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user