This commit is contained in:
Priec
2026-05-17 15:16:51 +02:00
parent 27887bf664
commit 622afc310d
16 changed files with 617 additions and 2 deletions

View File

@@ -0,0 +1,19 @@
{% extends "admin/base.html" %}
{% block title %}Edit About{% endblock title %}
{% block content %}
<h1>Edit About</h1>
<form method="post" action="/admin/about">
<label>
Title
<input type="text" name="title" value="{{ page.title }}" required>
</label>
<label>
Content
<textarea name="content" rows="16" required>{{ page.content }}</textarea>
</label>
<button type="submit">Save</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin{% endblock title %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<header>
<nav>
<a href="/admin/dashboard">Dashboard</a>
<a href="/admin/blog/articles">Blog</a>
<a href="/admin/about">About</a>
<a href="/">View site</a>
<form method="post" action="/admin/logout">
<button type="submit">Logout</button>
</form>
</nav>
</header>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{% extends "admin/base.html" %}
{% block title %}Edit Article{% endblock title %}
{% block content %}
<h1>Edit Article</h1>
<form method="post" action="/admin/blog/articles/{{ article.id }}">
<label>
Title
<input type="text" name="title" value="{{ article.title }}" required>
</label>
<label>
Excerpt
<textarea name="excerpt" rows="4">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
</label>
<label>
Content
<textarea name="content" rows="18" required>{{ article.content }}</textarea>
</label>
<label>
Featured image id
<input type="text" name="featured_image_id" value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}">
</label>
<label>
<input type="checkbox" name="published" {% if article.published %}checked{% endif %}>
Published
</label>
<button type="submit">Save</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,36 @@
{% extends "admin/base.html" %}
{% block title %}Blog Articles{% endblock title %}
{% block content %}
<h1>Blog Articles</h1>
<p><a href="/admin/blog/articles/new">New article</a></p>
{% if articles | length > 0 %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>{{ article.title }}</td>
<td>{% if article.published %}Published{% else %}Draft{% endif %}</td>
<td>
<a href="/admin/blog/articles/{{ article.id }}/edit">Edit</a>
<form method="post" action="/admin/blog/articles/{{ article.id }}/delete">
<button type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No articles yet.</p>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,31 @@
{% extends "admin/base.html" %}
{% block title %}New Article{% endblock title %}
{% block content %}
<h1>New Article</h1>
<form method="post" action="/admin/blog/articles">
<label>
Title
<input type="text" name="title" required>
</label>
<label>
Excerpt
<textarea name="excerpt" rows="4"></textarea>
</label>
<label>
Content
<textarea name="content" rows="18" required></textarea>
</label>
<label>
Featured image id
<input type="text" name="featured_image_id">
</label>
<label>
<input type="checkbox" name="published">
Published
</label>
<button type="submit">Create</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,13 @@
{% extends "admin/base.html" %}
{% block title %}Admin{% endblock title %}
{% block content %}
<h1>Admin</h1>
<p>Logged in as {{ admin.email }}</p>
<ul>
<li><a href="/admin/blog/articles">Manage blog articles</a></li>
<li><a href="/admin/about">Edit about page</a></li>
</ul>
{% endblock content %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Admin login{% endblock title %}
{% block content %}
<h1>Admin login</h1>
{% if error %}
<p>{{ error }}</p>
{% endif %}
<form method="post" action="/admin/login">
<label>
Email
<input type="email" name="email" required>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<button type="submit">Login</button>
</form>
{% endblock content %}

24
assets/views/base.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Universal Web{% endblock title %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/admin/login">Admin</a>
</nav>
</header>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Blog{% endblock title %}
{% block content %}
<h1>Blog</h1>
{% if articles | length > 0 %}
<ul>
{% for article in articles %}
<li>
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No published posts yet.</p>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock title %}
{% block content %}
<article>
<h1>{{ article.title }}</h1>
<p>Views: {{ article.view_count }}</p>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
<div>{{ article.content | linebreaksbr | safe }}</div>
</article>
{% endblock content %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Home{% endblock title %}
{% block content %}
<h1>Home</h1>
<section>
<h2>Latest posts</h2>
{% if articles | length > 0 %}
<ul>
{% for article in articles %}
<li>
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No published posts yet.</p>
{% endif %}
</section>
{% endblock content %}

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ page.title }}{% endblock title %}
{% block content %}
<article>
<h1>{{ page.title }}</h1>
<div>{{ page.content | linebreaksbr | safe }}</div>
</article>
{% endblock content %}

View File

@@ -55,6 +55,7 @@ impl Hooks for App {
.add_route(controllers::admin::routes()) .add_route(controllers::admin::routes())
.add_route(controllers::blog::routes()) .add_route(controllers::blog::routes())
.add_route(controllers::pages::routes()) .add_route(controllers::pages::routes())
.add_route(controllers::frontend::routes())
} }
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?; queue.register(DownloadWorker::build(ctx)).await?;

View File

@@ -34,7 +34,7 @@ fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
} }
fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> { pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, token.to_string())) Cookie::build((AUTH_COOKIE, token.to_string()))
.path("/") .path("/")
.http_only(true) .http_only(true)
@@ -43,7 +43,7 @@ fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
.build() .build()
} }
fn clear_auth_cookie() -> Cookie<'static> { pub(crate) fn clear_auth_cookie() -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, "")) Cookie::build((AUTH_COOKIE, ""))
.path("/") .path("/")
.http_only(true) .http_only(true)

344
src/controllers/frontend.rs Normal file
View File

@@ -0,0 +1,344 @@
use crate::{
controllers::{admin, auth as auth_controller},
models::{
_entities::{blog_articles, site_pages},
users::{self, LoginParams},
},
};
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct ArticleForm {
title: String,
content: String,
excerpt: Option<String>,
published: Option<String>,
featured_image_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AboutForm {
title: String,
content: String,
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
fn is_checked(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|value| value == "on" || value == "true")
}
fn normalize_empty(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
})
}
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn home(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.limit(5)
.all(&ctx.db)
.await?;
format::view(&v, "home/index.html", json!({ "articles": articles }))
}
#[debug_handler]
async fn about(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? }))
}
#[debug_handler]
async fn blog_index(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?;
format::view(&v, "blog/index.html", json!({ "articles": articles }))
}
#[debug_handler]
async fn blog_show(
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::view(&v, "blog/show.html", json!({ "article": article }))
}
#[debug_handler]
async fn admin_login_page(ViewEngine(v): ViewEngine<TeraView>) -> Result<Response> {
format::view(&v, "admin/login.html", json!({ "error": null }))
}
#[debug_handler]
async fn admin_login(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
};
if !user.verify_password(&params.password) || !admin::is_admin(&ctx, &user) {
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn admin_logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
#[debug_handler]
async fn admin_home(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/index.html", json!({ "admin": admin_user }))
}
#[debug_handler]
async fn admin_about(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/about.html", json!({ "page": about_page(&ctx).await? }))
}
#[debug_handler]
async fn admin_about_update(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<AboutForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut page = about_page(&ctx).await?.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?;
format::redirect("/admin/about")
}
#[debug_handler]
async fn admin_articles(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?;
format::view(&v, "admin/blog/index.html", json!({ "articles": articles }))
}
#[debug_handler]
async fn admin_article_new(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/blog/new.html", json!({}))
}
#[debug_handler]
async fn admin_article_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = is_checked(&params.published);
blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(normalize_empty(params.excerpt)),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(normalize_empty(params.featured_image_id)),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_edit(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/blog/edit.html",
json!({ "article": article_by_id(&ctx, id).await? }),
)
}
#[debug_handler]
async fn admin_article_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = is_checked(&params.published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(normalize_empty(params.excerpt));
article.published = Set(published);
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
article.update(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(home))
.add("/about", get(about))
.add("/blog", get(blog_index))
.add("/blog/{slug}", get(blog_show))
.add("/admin/login", get(admin_login_page))
.add("/admin/login", post(admin_login))
.add("/admin/logout", post(admin_logout))
.add("/admin", get(admin_login_page))
.add("/admin/dashboard", get(admin_home))
.add("/admin/about", get(admin_about))
.add("/admin/about", post(admin_about_update))
.add("/admin/blog/articles", get(admin_articles))
.add("/admin/blog/articles/new", get(admin_article_new))
.add("/admin/blog/articles", post(admin_article_create))
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
.add("/admin/blog/articles/{id}", post(admin_article_update))
.add("/admin/blog/articles/{id}/delete", post(admin_article_delete))
}

View File

@@ -1,4 +1,5 @@
pub mod admin; pub mod admin;
pub mod auth; pub mod auth;
pub mod blog; pub mod blog;
pub mod frontend;
pub mod pages; pub mod pages;