about page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
51
ht_booking/assets/views/about.html
Normal file
51
ht_booking/assets/views/about.html
Normal 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 %}
|
||||
43
ht_booking/assets/views/admin/about_form.html
Normal file
43
ht_booking/assets/views/admin/about_form.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
29
ht_booking/migration/src/m20260516_120000_about.rs
Normal file
29
ht_booking/migration/src/m20260516_120000_about.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
141
ht_booking/src/controllers/about.rs
Normal file
141
ht_booking/src/controllers/about.rs
Normal 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))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod about;
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
|
||||
21
ht_booking/src/models/_entities/about.rs
Normal file
21
ht_booking/src/models/_entities/about.rs
Normal 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 {}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod about;
|
||||
pub mod bookings;
|
||||
pub mod courts;
|
||||
pub mod users;
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
ht_booking/src/models/about.rs
Normal file
28
ht_booking/src/models/about.rs
Normal 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 {}
|
||||
@@ -2,3 +2,4 @@ pub mod _entities;
|
||||
pub mod users;
|
||||
pub mod courts;
|
||||
pub mod bookings;
|
||||
pub mod about;
|
||||
|
||||
Reference in New Issue
Block a user