about page

This commit is contained in:
Priec
2026-05-16 21:46:02 +02:00
parent 78c2430d21
commit 4938314889
16 changed files with 348 additions and 6 deletions

View File

@@ -1,9 +1,10 @@
# Welcome to Loco :train:
# Tenis Rajec — tenisrajec.sk
[Loco](https://loco.rs) is a web and API framework running on Rust.
Booking site for the tennis courts in Rajec. Visitors browse the weekly court
calendar and an *About* page; the single admin manages courts, bookings and the
About-page content.
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
Built with [Loco](https://loco.rs), a web framework running on Rust.
## Quick Start

View File

@@ -1,4 +1,4 @@
brand = Tennis Court Booking
brand = Tenis Rajec
nav-calendar = Calendar
nav-admin = Admin login
admin-title = Admin
@@ -63,3 +63,12 @@ hour-from = From
hour-to = Until
repeat-weeks = Repeat for (weeks)
repeat-hint = 1 books a single week. A higher number repeats the same hours every following week.
nav-about = About
about-title = About us
about-edit = Edit page
back-to-about = Back to About
about-heading = Heading
about-body = Description
about-address = Address
about-phone = Phone
about-email = Email

View File

@@ -1,4 +1,4 @@
brand = Rezervácia tenisového kurtu
brand = Tenis Rajec
nav-calendar = Kalendár
nav-admin = Prihlásenie admina
admin-title = Admin
@@ -63,3 +63,12 @@ hour-from = Od
hour-to = Do
repeat-weeks = Opakovať (počet týždňov)
repeat-hint = 1 rezervuje jeden týždeň. Vyššie číslo opakuje rovnaké hodiny každý ďalší týždeň.
nav-about = O nás
about-title = O nás
about-edit = Upraviť stránku
back-to-about = Späť na stránku O nás
about-heading = Nadpis
about-body = Popis
about-address = Adresa
about-phone = Telefón
about-email = E-mail

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ t(key="about-title", lang=lang) }}{% endblock title %}
{% block content %}
<div class="mx-auto max-w-2xl">
<div class="mb-4 flex items-center justify-between gap-2">
<h1 class="text-2xl font-bold">
{% if title %}{{ title }}{% else %}{{ t(key="about-title", lang=lang) }}{% endif %}
</h1>
{% if logged_in | default(value=false) %}
<a href="/admin/about" class="btn btn-ghost btn-sm">{{ t(key="about-edit", lang=lang) }}</a>
{% endif %}
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body gap-4">
{% if body %}
<p class="whitespace-pre-line leading-relaxed">{{ body }}</p>
{% endif %}
{% if address or phone or email %}
<dl class="divide-y divide-base-300 border-t border-base-300 pt-1">
{% if address %}
<div class="flex justify-between gap-4 py-2">
<dt class="text-sm opacity-70">{{ t(key="about-address", lang=lang) }}</dt>
<dd class="whitespace-pre-line text-right text-sm font-medium">{{ address }}</dd>
</div>
{% endif %}
{% if phone %}
<div class="flex justify-between gap-4 py-2">
<dt class="text-sm opacity-70">{{ t(key="about-phone", lang=lang) }}</dt>
<dd class="text-right text-sm font-medium">
<a href="tel:{{ phone }}" class="link link-hover">{{ phone }}</a>
</dd>
</div>
{% endif %}
{% if email %}
<div class="flex justify-between gap-4 py-2">
<dt class="text-sm opacity-70">{{ t(key="about-email", lang=lang) }}</dt>
<dd class="text-right text-sm font-medium">
<a href="mailto:{{ email }}" class="link link-hover">{{ email }}</a>
</dd>
</div>
{% endif %}
</dl>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}{{ t(key="about-edit", lang=lang) }}{% endblock title %}
{% block content %}
<div class="mx-auto max-w-2xl">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ t(key="about-edit", lang=lang) }}</h1>
<a href="/about" class="btn btn-ghost btn-sm">« {{ t(key="back-to-about", lang=lang) }}</a>
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
<form method="post" action="/admin/about" class="space-y-2">
<div class="form-control">
<label class="label"><span class="label-text">{{ t(key="about-heading", lang=lang) }}</span></label>
<input name="title" value="{{ title }}" class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text">{{ t(key="about-body", lang=lang) }}</span></label>
<textarea name="body" rows="8" class="textarea textarea-bordered w-full">{{ body }}</textarea>
</div>
<div class="form-control">
<label class="label"><span class="label-text">{{ t(key="about-address", lang=lang) }}</span></label>
<textarea name="address" rows="2" class="textarea textarea-bordered w-full">{{ address }}</textarea>
</div>
<div class="form-control">
<label class="label"><span class="label-text">{{ t(key="about-phone", lang=lang) }}</span></label>
<input name="phone" value="{{ phone }}" class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text">{{ t(key="about-email", lang=lang) }}</span></label>
<input name="email" type="email" value="{{ email }}" class="input input-bordered w-full">
</div>
<div class="flex items-center gap-2 pt-2">
<button class="btn btn-neutral">{{ t(key="save", lang=lang) }}</button>
<a href="/about" class="btn btn-ghost">{{ t(key="cancel", lang=lang) }}</a>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@@ -93,6 +93,7 @@
<!-- Page links — inline on desktop, tucked into a menu on mobile. -->
<div class="hidden items-center gap-1 md:flex">
<a href="/" class="btn btn-ghost btn-sm">{{ t(key="nav-calendar", lang=lang) }}</a>
<a href="/about" class="btn btn-ghost btn-sm">{{ t(key="nav-about", lang=lang) }}</a>
{% if logged_in | default(value=false) %}
<a href="/admin" class="btn btn-ghost btn-sm">{{ t(key="admin-title", lang=lang) }}</a>
<a href="/admin/courts" class="btn btn-ghost btn-sm">{{ t(key="manage-courts", lang=lang) }}</a>
@@ -115,6 +116,7 @@
<div tabindex="0"
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<a href="/" class="btn btn-ghost btn-sm justify-start">{{ t(key="nav-calendar", lang=lang) }}</a>
<a href="/about" class="btn btn-ghost btn-sm justify-start">{{ t(key="nav-about", lang=lang) }}</a>
{% if logged_in | default(value=false) %}
<a href="/admin" class="btn btn-ghost btn-sm justify-start">{{ t(key="admin-title", lang=lang) }}</a>
<a href="/admin/courts" class="btn btn-ghost btn-sm justify-start">{{ t(key="manage-courts", lang=lang) }}</a>

View File

@@ -6,6 +6,7 @@ mod m20220101_000001_users;
mod m20260515_162423_courts;
mod m20260515_170417_bookings;
mod m20260516_111747_add_title_to_bookings;
mod m20260516_120000_about;
pub struct Migrator;
#[async_trait::async_trait]
@@ -16,6 +17,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260515_162423_courts::Migration),
Box::new(m20260515_170417_bookings::Migration),
Box::new(m20260516_111747_add_title_to_bookings::Migration),
Box::new(m20260516_120000_about::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,29 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "abouts",
&[
("id", ColType::PkAuto),
("title", ColType::StringNull),
("body", ColType::TextNull),
("address", ColType::TextNull),
("phone", ColType::StringNull),
("email", ColType::StringNull),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "abouts").await
}
}

View File

@@ -53,6 +53,8 @@ impl Hooks for App {
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::calendar::routes())
.add_route(controllers::about::routes())
.add_route(controllers::about::admin_routes())
.add_route(controllers::admin::routes())
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {

View File

@@ -0,0 +1,141 @@
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unused_async)]
//! Public "About" page and its single-row, admin-editable content.
//!
//! The whole page is one database row. Visitors see it at `/about`; the admin
//! edits the same row at `/admin/about`. The row is created lazily from a
//! Slovak default the first time the page is opened, so the admin always has
//! concrete text to edit and the public page is never blank.
use axum::response::Redirect;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use serde::Deserialize;
use crate::controllers::admin::{current_admin, AdminAuth};
use crate::controllers::calendar::current_lang;
use crate::models::_entities::about;
/// The Slovak placeholder content inserted the first time the About page is
/// opened. The admin is expected to replace it with the real club details.
fn default_about() -> about::ActiveModel {
about::ActiveModel {
title: Set(Some("Tenis Rajec".to_string())),
body: Set(Some(
"Vitajte na stránke tenisových kurtov v meste Rajec.\n\n\
Ponúkame kvalitné tenisové kurty pre verejnosť aj členov. \
Voľné termíny nájdete v kalendári na tejto stránke.\n\n\
Tešíme sa na vašu návštevu!"
.to_string(),
)),
address: Set(Some("Tenisové kurty Rajec\n013 01 Rajec".to_string())),
phone: Set(Some(String::new())),
email: Set(Some(String::new())),
..Default::default()
}
}
/// Loads the single About row, creating it from [`default_about`] when the
/// table is still empty. There is only ever one row.
pub async fn load_about(ctx: &AppContext) -> Result<about::Model> {
if let Some(row) = about::Entity::find()
.order_by_asc(about::Column::Id)
.one(&ctx.db)
.await?
{
return Ok(row);
}
Ok(default_about().insert(&ctx.db).await?)
}
/// Public, read-only About page.
#[debug_handler]
pub async fn index(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
jar: CookieJar,
) -> Result<Response> {
let lang = current_lang(&jar);
// An admin visitor keeps the admin nav links and gets the "Edit" button.
let logged_in = current_admin(&ctx, &jar).await.is_some();
let about = load_about(&ctx).await?;
format::render().view(
&v,
"about.html",
data!({
"lang": lang,
"logged_in": logged_in,
"title": about.title.unwrap_or_default(),
"body": about.body.unwrap_or_default(),
"address": about.address.unwrap_or_default(),
"phone": about.phone.unwrap_or_default(),
"email": about.email.unwrap_or_default(),
}),
)
}
/// Admin form for editing the About page.
#[debug_handler]
pub async fn edit_form(
_auth: AdminAuth,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
jar: CookieJar,
) -> Result<Response> {
let lang = current_lang(&jar);
let about = load_about(&ctx).await?;
format::render().view(
&v,
"admin/about_form.html",
data!({
"lang": lang,
"is_admin": true,
"logged_in": true,
"title": about.title.unwrap_or_default(),
"body": about.body.unwrap_or_default(),
"address": about.address.unwrap_or_default(),
"phone": about.phone.unwrap_or_default(),
"email": about.email.unwrap_or_default(),
}),
)
}
#[derive(Debug, Deserialize)]
pub struct AboutForm {
pub title: String,
pub body: String,
pub address: String,
pub phone: String,
pub email: String,
}
/// Saves the admin's edits back onto the single About row.
#[debug_handler]
pub async fn edit_submit(
_auth: AdminAuth,
State(ctx): State<AppContext>,
Form(form): Form<AboutForm>,
) -> Result<Response> {
let mut active = load_about(&ctx).await?.into_active_model();
active.title = Set(Some(form.title));
active.body = Set(Some(form.body));
active.address = Set(Some(form.address));
active.phone = Set(Some(form.phone));
active.email = Set(Some(form.email));
active.update(&ctx.db).await?;
Ok(Redirect::to("/about").into_response())
}
/// Public route: the About page.
pub fn routes() -> Routes {
Routes::new().add("/about", get(index))
}
/// Admin routes: the About-page editor.
pub fn admin_routes() -> Routes {
Routes::new()
.prefix("admin")
.add("/about", get(edit_form))
.add("/about", post(edit_submit))
}

View File

@@ -1,3 +1,4 @@
pub mod about;
pub mod admin;
pub mod auth;
pub mod calendar;

View File

@@ -0,0 +1,21 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "abouts")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub title: Option<String>,
pub body: Option<String>,
pub address: Option<String>,
pub phone: Option<String>,
pub email: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -2,6 +2,7 @@
pub mod prelude;
pub mod about;
pub mod bookings;
pub mod courts;
pub mod users;

View File

@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
pub use super::about::Entity as About;
pub use super::bookings::Entity as Bookings;
pub use super::courts::Entity as Courts;
pub use super::users::Entity as Users;

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::about::{ActiveModel, Model, Entity};
pub type About = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -2,3 +2,4 @@ pub mod _entities;
pub mod users;
pub mod courts;
pub mod bookings;
pub mod about;