Compare commits

..

2 Commits

Author SHA1 Message Date
Alphonse Paix
1313b43612 Update workflow
Some checks failed
Rust / Test (push) Failing after 1m39s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Failing after 27s
Rust / Code coverage (push) Failing after 1m52s
2025-10-01 01:36:44 +02:00
Alphonse Paix
402c560354 User profile and admin privileges 2025-10-01 01:17:59 +02:00
23 changed files with 475 additions and 43 deletions

3
.cargo/config.toml Normal file
View File

@@ -0,0 +1,3 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

View File

@@ -37,6 +37,10 @@ jobs:
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install mold linker
run: |
sudo apt-get update
sudo apt-get install -y mold
- name: Install the Rust toolchain - name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
@@ -100,6 +104,10 @@ jobs:
- 16379:6379 - 16379:6379
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install mold linker
run: |
sudo apt-get update
sudo apt-get install -y mold
- name: Install the Rust toolchain - name: Install the Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:

View File

@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id, password_hash, role as \"role: Role\"\n FROM users\n WHERE username = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "role: Role",
"type_info": {
"Custom": {
"name": "user_role",
"kind": {
"Enum": [
"admin",
"writer"
]
}
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1"
}

View File

@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58"
}

View File

@@ -0,0 +1,62 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id, username, full_name, role as \"role: Role\", member_since, bio\n FROM users\n WHERE username = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "role: Role",
"type_info": {
"Custom": {
"name": "user_role",
"kind": {
"Enum": [
"admin",
"writer"
]
}
}
}
},
{
"ordinal": 4,
"name": "member_since",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "bio",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false,
true
]
},
"hash": "e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271"
}

View File

@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT 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 ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "post_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "published_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75"
}

View File

@@ -1,6 +1,6 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef
WORKDIR /app WORKDIR /app
RUN apt update && apt install -y nodejs npm && rm -rf /var/lib/apt/lists/* RUN apt update && apt install -y nodejs npm clang mold && rm -rf /var/lib/apt/lists/*
FROM chef AS planner FROM chef AS planner
COPY . . COPY . .

View File

@@ -70,6 +70,8 @@
--text-xs--line-height: calc(1 / 0.75); --text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875); --text-sm--line-height: calc(1.25 / 0.875);
--text-base: 1rem;
--text-base--line-height: calc(1.5 / 1);
--text-lg: 1.125rem; --text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125); --text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem; --text-xl: 1.25rem;
@@ -288,6 +290,9 @@
max-width: 96rem; max-width: 96rem;
} }
} }
.-mx-8 {
margin-inline: calc(var(--spacing) * -8);
}
.mx-2 { .mx-2 {
margin-inline: calc(var(--spacing) * 2); margin-inline: calc(var(--spacing) * 2);
} }
@@ -727,6 +732,9 @@
.mt-4 { .mt-4 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
} }
.mt-6 {
margin-top: calc(var(--spacing) * 6);
}
.mt-8 { .mt-8 {
margin-top: calc(var(--spacing) * 8); margin-top: calc(var(--spacing) * 8);
} }
@@ -736,9 +744,15 @@
.mr-1 { .mr-1 {
margin-right: calc(var(--spacing) * 1); margin-right: calc(var(--spacing) * 1);
} }
.mr-1\.5 {
margin-right: calc(var(--spacing) * 1.5);
}
.mr-2 { .mr-2 {
margin-right: calc(var(--spacing) * 2); margin-right: calc(var(--spacing) * 2);
} }
.mb-1 {
margin-bottom: calc(var(--spacing) * 1);
}
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
@@ -989,6 +1003,20 @@
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
} }
} }
.divide-y {
:where(& > :not(:last-child)) {
--tw-divide-y-reverse: 0;
border-bottom-style: var(--tw-border-style);
border-top-style: var(--tw-border-style);
border-top-width: calc(1px * var(--tw-divide-y-reverse));
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
}
}
.divide-gray-200 {
:where(& > :not(:last-child)) {
border-color: var(--color-gray-200);
}
}
.rounded-full { .rounded-full {
border-radius: calc(infinity * 1px); border-radius: calc(infinity * 1px);
} }
@@ -1188,6 +1216,9 @@
.px-6 { .px-6 {
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
.px-8 {
padding-inline: calc(var(--spacing) * 8);
}
.py-0 { .py-0 {
padding-block: calc(var(--spacing) * 0); padding-block: calc(var(--spacing) * 0);
} }
@@ -1203,6 +1234,9 @@
.py-3 { .py-3 {
padding-block: calc(var(--spacing) * 3); padding-block: calc(var(--spacing) * 3);
} }
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
.py-6 { .py-6 {
padding-block: calc(var(--spacing) * 6); padding-block: calc(var(--spacing) * 6);
} }
@@ -1239,6 +1273,10 @@
font-size: var(--text-4xl); font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height)); line-height: var(--tw-leading, var(--text-4xl--line-height));
} }
.text-base {
font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height));
}
.text-lg { .text-lg {
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height)); line-height: var(--tw-leading, var(--text-lg--line-height));
@@ -1274,6 +1312,9 @@
.break-all { .break-all {
word-break: break-all; word-break: break-all;
} }
.whitespace-pre-line {
white-space: pre-line;
}
.text-amber-600 { .text-amber-600 {
color: var(--color-amber-600); color: var(--color-amber-600);
} }
@@ -1584,6 +1625,13 @@
} }
} }
} }
.hover\:underline {
&:hover {
@media (hover: hover) {
text-decoration-line: underline;
}
}
}
.hover\:shadow-lg { .hover\:shadow-lg {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1655,6 +1703,11 @@
outline-style: none; outline-style: none;
} }
} }
.sm\:mx-0 {
@media (width >= 40rem) {
margin-inline: calc(var(--spacing) * 0);
}
}
.sm\:mt-0 { .sm\:mt-0 {
@media (width >= 40rem) { @media (width >= 40rem) {
margin-top: calc(var(--spacing) * 0); margin-top: calc(var(--spacing) * 0);
@@ -1695,6 +1748,11 @@
justify-content: space-between; justify-content: space-between;
} }
} }
.sm\:justify-start {
@media (width >= 40rem) {
justify-content: flex-start;
}
}
.sm\:p-6 { .sm\:p-6 {
@media (width >= 40rem) { @media (width >= 40rem) {
padding: calc(var(--spacing) * 6); padding: calc(var(--spacing) * 6);
@@ -1705,6 +1763,11 @@
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
} }
.sm\:text-left {
@media (width >= 40rem) {
text-align: left;
}
}
.md\:mt-0 { .md\:mt-0 {
@media (width >= 48rem) { @media (width >= 48rem) {
margin-top: calc(var(--spacing) * 0); margin-top: calc(var(--spacing) * 0);
@@ -1725,6 +1788,11 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.md\:grid-cols-3 {
@media (width >= 48rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.md\:flex-row { .md\:flex-row {
@media (width >= 48rem) { @media (width >= 48rem) {
flex-direction: row; flex-direction: row;
@@ -2425,6 +2493,11 @@
inherits: false; inherits: false;
initial-value: 0; initial-value: 0;
} }
@property --tw-divide-y-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style { @property --tw-border-style {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -2628,6 +2701,7 @@
--tw-skew-y: initial; --tw-skew-y: initial;
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
--tw-divide-y-reverse: 0;
--tw-border-style: solid; --tw-border-style: solid;
--tw-gradient-position: initial; --tw-gradient-position: initial;
--tw-gradient-from: #0000; --tw-gradient-from: #0000;

View File

@@ -0,0 +1,4 @@
ALTER TABLE users
ADD COLUMN full_name TEXT,
ADD COLUMN bio TEXT,
ADD COLUMN member_since TIMESTAMPTZ NOT NULL DEFAULT NOW();

View File

@@ -1,3 +1,5 @@
use std::fmt::Display;
use crate::telemetry::spawn_blocking_with_tracing; use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context; use anyhow::Context;
use argon2::{ use argon2::{
@@ -138,6 +140,15 @@ pub enum Role {
Writer, Writer,
} }
impl Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::Admin => write!(f, "admin"),
Role::Writer => write!(f, "writer"),
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
pub user_id: Uuid, pub user_id: Uuid,

View File

@@ -2,8 +2,10 @@ mod new_subscriber;
mod post; mod post;
mod subscriber_email; mod subscriber_email;
mod subscribers; mod subscribers;
mod user;
pub use new_subscriber::NewSubscriber; pub use new_subscriber::NewSubscriber;
pub use post::PostEntry; pub use post::PostEntry;
pub use subscriber_email::SubscriberEmail; pub use subscriber_email::SubscriberEmail;
pub use subscribers::SubscriberEntry; pub use subscribers::SubscriberEntry;
pub use user::UserEntry;

View File

@@ -3,14 +3,13 @@ use uuid::Uuid;
pub struct PostEntry { pub struct PostEntry {
pub post_id: Uuid, pub post_id: Uuid,
pub author: Option<String>, pub author: String,
pub title: String, pub title: String,
pub content: String, pub content: String,
pub published_at: DateTime<Utc>, pub published_at: DateTime<Utc>,
} }
impl PostEntry { impl PostEntry {
#[allow(dead_code)]
pub fn formatted_date(&self) -> String { pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string() self.published_at.format("%B %d, %Y").to_string()
} }

23
src/domain/user.rs Normal file
View File

@@ -0,0 +1,23 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::authentication::Role;
pub struct UserEntry {
pub user_id: Uuid,
pub username: String,
pub role: Role,
pub full_name: Option<String>,
pub bio: Option<String>,
pub member_since: DateTime<Utc>,
}
impl UserEntry {
pub fn formatted_date(&self) -> String {
self.member_since.format("%B %d, %Y").to_string()
}
pub fn is_admin(&self) -> bool {
matches!(self.role, Role::Admin)
}
}

View File

@@ -6,6 +6,7 @@ mod posts;
mod subscriptions; mod subscriptions;
mod subscriptions_confirm; mod subscriptions_confirm;
mod unsubscribe; mod unsubscribe;
mod users;
pub use admin::*; pub use admin::*;
use askama::Template; use askama::Template;
@@ -24,6 +25,7 @@ use serde::de::DeserializeOwned;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
pub use unsubscribe::*; pub use unsubscribe::*;
pub use users::*;
use crate::{ use crate::{
authentication::AuthError, authentication::AuthError,

82
src/routes/users.rs Normal file
View File

@@ -0,0 +1,82 @@
use crate::{
authentication::Role,
domain::{PostEntry, UserEntry},
routes::{AppError, not_found_html},
startup::AppState,
templates::{HtmlTemplate, UserTemplate},
};
use anyhow::Context;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct ProfilePath {
username: String,
}
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
pub async fn user_profile(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(ProfilePath { username }): Path<ProfilePath>,
) -> Result<Response, AppError> {
match fetch_user_data(&connection_pool, &username)
.await
.context("Failed to fetch user data.")?
{
Some(user) => {
let posts = fetch_user_posts(&connection_pool, &user.user_id)
.await
.context("Could not fetch user posts.")?;
let template = HtmlTemplate(UserTemplate { user, posts });
Ok(template.into_response())
}
None => {
tracing::error!(username = %username, "user not found");
Ok(not_found_html().into_response())
}
}
}
#[tracing::instrument(name = "Fetching user profile", skip_all)]
async fn fetch_user_data(
connection_pool: &PgPool,
username: &str,
) -> Result<Option<UserEntry>, sqlx::Error> {
sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, full_name, role as "role: Role", member_since, bio
FROM users
WHERE username = $1
"#,
username
)
.fetch_optional(connection_pool)
.await
}
#[tracing::instrument(name = "Fetching user posts", skip_all)]
async fn fetch_user_posts(
connection_pool: &PgPool,
user_id: &Uuid,
) -> Result<Vec<PostEntry>, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at
FROM posts p
INNER JOIN users u ON p.author_id = u.user_id
WHERE p.author_id = $1
ORDER BY p.published_at DESC
"#,
user_id
)
.fetch_all(connection_pool)
.await
}

View File

@@ -108,6 +108,7 @@ pub fn app(
.route("/posts", get(list_posts)) .route("/posts", get(list_posts))
.route("/posts/load_more", get(load_more)) .route("/posts/load_more", get(load_more))
.route("/posts/{post_id}", get(see_post)) .route("/posts/{post_id}", get(see_post))
.route("/users/{username}", get(user_profile))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.nest("/admin", auth_routes) .nest("/admin", auth_routes)
.nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets", ServeDir::new("assets"))

View File

@@ -21,7 +21,7 @@ where
) )
.with( .with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.pretty() .compact()
.with_writer(sink) .with_writer(sink)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE), .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
) )

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
domain::{PostEntry, SubscriberEntry}, domain::{PostEntry, SubscriberEntry, UserEntry},
routes::{AppError, DashboardStats}, routes::{AppError, DashboardStats},
}; };
use askama::Template; use askama::Template;
@@ -21,6 +21,13 @@ where
} }
} }
#[derive(Template)]
#[template(path = "user/profile.html")]
pub struct UserTemplate {
pub user: UserEntry,
pub posts: Vec<PostEntry>,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "message.html")] #[template(path = "message.html")]
pub struct MessageTemplate { pub struct MessageTemplate {

View File

@@ -5,7 +5,9 @@
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600 items-start"> <p class="mt-2 text-gray-600 items-start">
<span>Connected as <span class="font-bold">{{ user.username }}</span></span> <span>Connected as
<a href="/users/{{ user.username }}"
class="hover:text-blue-600 hover:underline font-bold">{{ user.username }}</a></span>
{% if user.is_admin() %} {% if user.is_admin() %}
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"> <span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
admin admin

View File

@@ -1,9 +1,10 @@
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200"> <article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
<a href="/posts/{{ post.post_id }}" <div class="block p-6 hover:bg-gray-50 transition-colors duration-200">
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2> <a href="/posts/{{ post.post_id }}">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
</a>
<div class="flex items-center text-sm text-gray-500"> <div class="flex items-center text-sm text-gray-500">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1" <svg class="w-4 h-4 mr-1"
@@ -24,18 +25,19 @@
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span> <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline">{{ post.author }}</a>
</div> </div>
</div> </div>
</div> </div>
<div class="flex-shrink-0 ml-4"> <a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors" <svg class="w-5 h-5 text-gray-400 hover:text-blue-600 transition-colors"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </a>
</div> </div>
</a> </div>
</article> </article>

View File

@@ -16,7 +16,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
</div> </div>
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span> <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400" <svg class="w-4 h-4 mr-1 text-gray-400"

View File

@@ -0,0 +1,44 @@
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Activity</h2>
{% if posts.is_empty() %}
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>No posts yet</p>
</div>
{% else %}
<div class="divide-y divide-gray-200">
{% for post in posts %}
<a href="/posts/{{ post.post_id }}"
class="block py-4 hover:bg-gray-50 -mx-8 px-8 transition-colors group">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">{{ post.title }}</h3>
<div class="flex items-center 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>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 ml-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}{{ user.username }}{% endblock %}
{% block content %}
<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="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<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">
{{ user.username }}
</div>
</div>
<div class="flex-1 text-center sm:text-left">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username) }}</h1>
{% if user.is_admin() %}
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
fill="currentColor"
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" />
</svg>
{% endif %}
</div>
<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="mt-6 pt-6 border-t border-gray-200">
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
</div>
{% endif %}
</div>
{% include "activity.html" %}
</div>
{% endblock %}