diff --git a/assets/views/admin/about.html b/assets/views/admin/about.html
new file mode 100644
index 0000000..8ec89b0
--- /dev/null
+++ b/assets/views/admin/about.html
@@ -0,0 +1,19 @@
+{% extends "admin/base.html" %}
+
+{% block title %}Edit About{% endblock title %}
+
+{% block content %}
+
Edit About
+
+
+{% endblock content %}
diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html
new file mode 100644
index 0000000..c6c0780
--- /dev/null
+++ b/assets/views/admin/base.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ {% block title %}Admin{% endblock title %}
+
+
+
+
+
+
+
+ {% block content %}{% endblock content %}
+
+
+
diff --git a/assets/views/admin/blog/edit.html b/assets/views/admin/blog/edit.html
new file mode 100644
index 0000000..4f7e874
--- /dev/null
+++ b/assets/views/admin/blog/edit.html
@@ -0,0 +1,31 @@
+{% extends "admin/base.html" %}
+
+{% block title %}Edit Article{% endblock title %}
+
+{% block content %}
+Edit Article
+
+
+{% endblock content %}
diff --git a/assets/views/admin/blog/index.html b/assets/views/admin/blog/index.html
new file mode 100644
index 0000000..76f2115
--- /dev/null
+++ b/assets/views/admin/blog/index.html
@@ -0,0 +1,36 @@
+{% extends "admin/base.html" %}
+
+{% block title %}Blog Articles{% endblock title %}
+
+{% block content %}
+Blog Articles
+New article
+
+{% if articles | length > 0 %}
+
+
+
+ | Title |
+ Status |
+ Actions |
+
+
+
+ {% for article in articles %}
+
+ | {{ article.title }} |
+ {% if article.published %}Published{% else %}Draft{% endif %} |
+
+ Edit
+
+ |
+
+ {% endfor %}
+
+
+{% else %}
+ No articles yet.
+{% endif %}
+{% endblock content %}
diff --git a/assets/views/admin/blog/new.html b/assets/views/admin/blog/new.html
new file mode 100644
index 0000000..be8ed18
--- /dev/null
+++ b/assets/views/admin/blog/new.html
@@ -0,0 +1,31 @@
+{% extends "admin/base.html" %}
+
+{% block title %}New Article{% endblock title %}
+
+{% block content %}
+New Article
+
+
+{% endblock content %}
diff --git a/assets/views/admin/index.html b/assets/views/admin/index.html
new file mode 100644
index 0000000..0c3b06e
--- /dev/null
+++ b/assets/views/admin/index.html
@@ -0,0 +1,13 @@
+{% extends "admin/base.html" %}
+
+{% block title %}Admin{% endblock title %}
+
+{% block content %}
+Admin
+Logged in as {{ admin.email }}
+
+
+{% endblock content %}
diff --git a/assets/views/admin/login.html b/assets/views/admin/login.html
new file mode 100644
index 0000000..0b3f313
--- /dev/null
+++ b/assets/views/admin/login.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block title %}Admin login{% endblock title %}
+
+{% block content %}
+Admin login
+
+{% if error %}
+ {{ error }}
+{% endif %}
+
+
+{% endblock content %}
diff --git a/assets/views/base.html b/assets/views/base.html
new file mode 100644
index 0000000..0b74ff9
--- /dev/null
+++ b/assets/views/base.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ {% block title %}Universal Web{% endblock title %}
+
+
+
+
+
+
+
+ {% block content %}{% endblock content %}
+
+
+
diff --git a/assets/views/blog/index.html b/assets/views/blog/index.html
new file mode 100644
index 0000000..476bda3
--- /dev/null
+++ b/assets/views/blog/index.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+
+{% block title %}Blog{% endblock title %}
+
+{% block content %}
+Blog
+
+{% if articles | length > 0 %}
+
+ {% for article in articles %}
+ -
+ {{ article.title }}
+ {% if article.excerpt %}
{{ article.excerpt }}
{% endif %}
+
+ {% endfor %}
+
+{% else %}
+ No published posts yet.
+{% endif %}
+{% endblock content %}
diff --git a/assets/views/blog/show.html b/assets/views/blog/show.html
new file mode 100644
index 0000000..9b01559
--- /dev/null
+++ b/assets/views/blog/show.html
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+
+{% block title %}{{ article.title }}{% endblock title %}
+
+{% block content %}
+
+ {{ article.title }}
+ Views: {{ article.view_count }}
+ {% if article.excerpt %}{{ article.excerpt }}
{% endif %}
+ {{ article.content | linebreaksbr | safe }}
+
+{% endblock content %}
diff --git a/assets/views/home/index.html b/assets/views/home/index.html
new file mode 100644
index 0000000..c70c63e
--- /dev/null
+++ b/assets/views/home/index.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block title %}Home{% endblock title %}
+
+{% block content %}
+Home
+
+
+ Latest posts
+ {% if articles | length > 0 %}
+
+ {% for article in articles %}
+ -
+ {{ article.title }}
+ {% if article.excerpt %}
{{ article.excerpt }}
{% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ No published posts yet.
+ {% endif %}
+
+{% endblock content %}
diff --git a/assets/views/pages/about.html b/assets/views/pages/about.html
new file mode 100644
index 0000000..3810531
--- /dev/null
+++ b/assets/views/pages/about.html
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page.title }}{% endblock title %}
+
+{% block content %}
+
+ {{ page.title }}
+ {{ page.content | linebreaksbr | safe }}
+
+{% endblock content %}
diff --git a/src/app.rs b/src/app.rs
index f2234e2..2ab714f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -55,6 +55,7 @@ impl Hooks for App {
.add_route(controllers::admin::routes())
.add_route(controllers::blog::routes())
.add_route(controllers::pages::routes())
+ .add_route(controllers::frontend::routes())
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;
diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs
index b413c17..0d20d8f 100644
--- a/src/controllers/auth.rs
+++ b/src/controllers/auth.rs
@@ -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))
}
-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()))
.path("/")
.http_only(true)
@@ -43,7 +43,7 @@ fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
.build()
}
-fn clear_auth_cookie() -> Cookie<'static> {
+pub(crate) fn clear_auth_cookie() -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, ""))
.path("/")
.http_only(true)
diff --git a/src/controllers/frontend.rs b/src/controllers/frontend.rs
new file mode 100644
index 0000000..6c0af91
--- /dev/null
+++ b/src/controllers/frontend.rs
@@ -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,
+ published: Option,
+ featured_image_id: Option,
+}
+
+#[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> {
+ published.then(|| Utc::now().into())
+}
+
+fn is_checked(value: &Option) -> bool {
+ value.as_deref().is_some_and(|value| value == "on" || value == "true")
+}
+
+fn normalize_empty(value: Option) -> Option {
+ 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::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::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .ok_or_else(|| Error::NotFound)
+}
+
+#[debug_handler]
+async fn home(
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+) -> Result {
+ 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,
+ State(ctx): State,
+) -> Result {
+ format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? }))
+}
+
+#[debug_handler]
+async fn blog_index(
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+) -> Result {
+ 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,
+ Path(slug): Path,
+ State(ctx): State,
+) -> Result {
+ 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) -> Result {
+ format::view(&v, "admin/login.html", json!({ "error": null }))
+}
+
+#[debug_handler]
+async fn admin_login(
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ Form(params): Form,
+) -> Result {
+ let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
+ return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
+ };
+
+ if !user.verify_password(¶ms.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 {
+ format::render()
+ .cookies(&[auth_controller::clear_auth_cookie()])?
+ .redirect("/admin/login")
+}
+
+#[debug_handler]
+async fn admin_home(
+ auth: auth::JWT,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+) -> Result {
+ 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,
+ State(ctx): State,
+) -> Result {
+ 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,
+ Form(params): Form,
+) -> Result {
+ 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,
+ State(ctx): State,
+) -> Result {
+ 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,
+ State(ctx): State,
+) -> Result {
+ 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,
+ Form(params): Form,
+) -> Result {
+ let admin_user = admin::current_admin(auth, &ctx).await?;
+ let published = is_checked(¶ms.published);
+
+ blog_articles::ActiveModel {
+ id: Set(Uuid::new_v4()),
+ title: Set(params.title.clone()),
+ slug: Set(slugify(¶ms.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,
+ Path(id): Path,
+ State(ctx): State,
+) -> Result {
+ 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,
+ State(ctx): State,
+ Form(params): Form,
+) -> Result {
+ admin::current_admin(auth, &ctx).await?;
+ let existing = article_by_id(&ctx, id).await?;
+ let was_published = existing.published;
+ let published = is_checked(¶ms.published);
+
+ let mut article = existing.into_active_model();
+ article.title = Set(params.title.clone());
+ article.slug = Set(slugify(¶ms.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,
+ State(ctx): State,
+) -> Result {
+ 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))
+}
diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs
index 12be272..9ec8f05 100644
--- a/src/controllers/mod.rs
+++ b/src/controllers/mod.rs
@@ -1,4 +1,5 @@
pub mod admin;
pub mod auth;
pub mod blog;
+pub mod frontend;
pub mod pages;