diff --git a/assets/templates/admin.html b/assets/templates/admin.html index bcad1ad..c57a8ec 100644 --- a/assets/templates/admin.html +++ b/assets/templates/admin.html @@ -1,6 +1,4 @@ {% extends "base.html" %} {% block head %} - - {% endblock %} diff --git a/assets/templates/admin_panel.html b/assets/templates/admin_panel.html index f32ff2d..0660df6 100644 --- a/assets/templates/admin_panel.html +++ b/assets/templates/admin_panel.html @@ -1,5 +1,17 @@ {% extends "admin.html" %} {% block content %} + + {% for article in news %} + + {{article.body}} + + {% endfor %} + {% endblock %} diff --git a/assets/templates/base.html b/assets/templates/base.html index 74bf449..40fb389 100644 --- a/assets/templates/base.html +++ b/assets/templates/base.html @@ -6,8 +6,18 @@ - + + {% block head %}{% endblock %} diff --git a/client/src/admin.js b/client/src/admin.js index 0283ebf..58ca0af 100644 --- a/client/src/admin.js +++ b/client/src/admin.js @@ -1 +1,3 @@ import "./admin/ow-admin"; +import "./admin/ow-articles"; +import "./admin/news-article"; diff --git a/client/src/admin/article-form.js b/client/src/admin/article-form.js index d4e74c5..012818a 100644 --- a/client/src/admin/article-form.js +++ b/client/src/admin/article-form.js @@ -1,49 +1,46 @@ import { Component, FORM_STYLE } from "../shared"; +import "../shared/rich-text-editor"; customElements.define('article-form', class extends Component { - #editor; - constructor() { super(` -
+
+ + +
+
+ +
- -
+ `); + + const bodyInput = this.shadowRoot.querySelector('[name="body"]'); + this.shadowRoot.querySelector('rich-text-editor').addEventListener('change', ev => { + ev.stopPropagation(); + bodyInput.value = ev.target.value; + }); } connectedCallback() { super.connectedCallback(); - const view = this.shadowRoot.querySelector('#body-view'); - view.innerHTML = ` -
-
-
- `; - const options = { - debug: 'info', - modules: { - toolbar: [ - [{ header: [1, 2, false] }], - ['bold', 'italic', 'underline'], - ['image', 'code-block'] - ] - }, - // scrollingContainer: view.querySelector('#scrolling-container'), - readOnly: false, - theme: 'snow' - }; - this.#editor = new Quill(view.querySelector('#editor'), options); } }); diff --git a/client/src/admin/news-article.js b/client/src/admin/news-article.js new file mode 100644 index 0000000..4828476 --- /dev/null +++ b/client/src/admin/news-article.js @@ -0,0 +1,94 @@ +import { Component } from "../shared"; + +customElements.define('news-article', class extends Component { + static get observedAttributes() { + return ["article-title", "status", "body", "created-at", "published-at"] + } + + constructor() { + super(` + +
+

+ + +

+
+
+ Created at: + +
+
+ Published at: + +
+
+
+ +
+
+ `); + } + + get article_title() { + return this.getAttribute('article-title'); + } + + set article_title(v) { + this.setAttribute('article-title', v); + this.shadowRoot.querySelector('#title').textContent = v; + } + + get status() { + return this.getAttribute('status'); + } + + set status(v) { + this.setAttribute('status', v); + this.shadowRoot.querySelector('#status').textContent = v; + } + + get created_at() { + return this.getAttribute('created-at'); + } + + set created_at(v) { + this.setAttribute('created-at', v); + this.shadowRoot.querySelector('#created_at').textContent = v; + } + + get published_at() { + return this.getAttribute('published-at'); + } + + set published_at(v) { + this.setAttribute('published-at', v); + this.shadowRoot.querySelector('#published_at').textContent = v; + } +}); diff --git a/client/src/admin/ow-admin.js b/client/src/admin/ow-admin.js index fd79faf..446c9ff 100644 --- a/client/src/admin/ow-admin.js +++ b/client/src/admin/ow-admin.js @@ -7,6 +7,7 @@ customElements.define('ow-admin', class extends Component { + `); } diff --git a/client/src/admin/ow-articles.js b/client/src/admin/ow-articles.js index c69fe9d..56ca84e 100644 --- a/client/src/admin/ow-articles.js +++ b/client/src/admin/ow-articles.js @@ -7,6 +7,7 @@ customElements.define('ow-articles', class extends Component { + `); } }); diff --git a/client/src/shared.js b/client/src/shared.js index 712d4f6..051f6ab 100644 --- a/client/src/shared.js +++ b/client/src/shared.js @@ -55,7 +55,7 @@ form > div { display: block; margin-bottom: 1rem; } -input, textarea { +input, textarea, select, option { font-size: 16px; border: none; diff --git a/client/src/shared/rich-text-editor.js b/client/src/shared/rich-text-editor.js new file mode 100644 index 0000000..57b894f --- /dev/null +++ b/client/src/shared/rich-text-editor.js @@ -0,0 +1,420 @@ +import { Component } from "../shared"; + +customElements.define('rich-text-editor', class extends Component { + #selection; + #range; + + constructor() { + super(` + +
+
+
+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + +
+
+ +
+
+ + + + + + + +
+
+
+
+
+
+
+ `); + + const header = this.shadowRoot.querySelector('#tools #header'); + header.addEventListener('click', () => { + this.#saveSelection(); + }); + header.addEventListener('change', ev => { + switch (ev.target.value) { + case 'normal': + return this.#removeWrapper(); + case 'h1': + return this.#wrapNode('H1'); + case 'h2': + return this.#wrapNode('H2'); + case 'h3': + return this.#wrapNode('H3'); + case 'h4': + return this.#wrapNode('H4'); + case 'h5': + return this.#wrapNode('H5'); + } + }); + this.shadowRoot.querySelector('#ordered-list').addEventListener('click', ev => { + ev.stopPropagation(); + this.#saveSelection(); + this.#wrapNode('ol', 'li'); + }); + { + let timeout = null; + this.shadowRoot.querySelector('#edit').addEventListener('keyup', (ev) => { + ev.stopPropagation(); + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + this.#emitChange(); + }, 1000 / 3); + }); + } + this.shadowRoot.querySelector('#unordered-list').addEventListener('click', ev => { + ev.stopPropagation(); + this.#saveSelection(); + this.#wrapNode('ul', 'li'); + }); + const imgBtn = this.shadowRoot.querySelector('#image'); + const imgInput = imgBtn.querySelector('input'); + imgInput.addEventListener('click', ev => { + ev.stopPropagation(); + }); + imgInput.addEventListener('change', ev => { + ev.stopPropagation(); + const file = imgInput.files[0]; + const reader = new FileReader(); + reader.onloadend = () => { + const selected = this.#selected; + if (!this.constructor.#isEditNode(selected)) return; + let el = selected; + if (el.nodeType === Node.TEXT_NODE) el = el.parentElement; + if (!el) return; + const img = new Image(); + img.src = reader.result || ''; + el.appendChild(img); + }; + reader.readAsDataURL(file); + }); + imgBtn.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + this.#saveSelection(); + imgInput.click(); + }); + { + const el = this.shadowRoot.querySelector("#align-justify"); + el.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + this.#saveSelection(); + this.#setStyle('textAlign', 'justify'); + }); + } + { + const el = this.shadowRoot.querySelector("#align-left"); + el.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + this.#saveSelection(); + this.#setStyle('textAlign', 'left'); + }); + } + { + const el = this.shadowRoot.querySelector("#align-center"); + el.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + this.#saveSelection(); + this.#setStyle('textAlign', 'center'); + }); + } + { + const el = this.shadowRoot.querySelector("#align-right"); + el.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + this.#saveSelection(); + this.#setStyle('textAlign', 'right'); + }); + } + { + const button = this.shadowRoot.querySelector("#text-color"); + const input = button.querySelector('input'); + input.addEventListener('click', ev => ev.stopPropagation()); + input.addEventListener('change', ev => { + ev.stopPropagation(); + this.#setStyle('color', input.value); + }); + button.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + input.click(); + this.#saveSelection(); + }); + } + { + const button = this.shadowRoot.querySelector("#background-color"); + const input = button.querySelector('input'); + input.addEventListener('click', ev => ev.stopPropagation()); + input.addEventListener('change', ev => { + ev.stopPropagation(); + this.#setStyle('backgroundColor', input.value); + }); + button.addEventListener('click', ev => { + ev.stopPropagation(); + ev.preventDefault(); + input.click(); + this.#saveSelection(); + }); + } + { + const el = this.shadowRoot.querySelector('#underscore'); + el.addEventListener('click', ev => { + ev.stopPropagation(); + this.#saveSelection(); + this.#setStyle("textDecoration", 'underline'); + }); + } + { + const el = this.shadowRoot.querySelector('#superscript'); + el.addEventListener('click', ev => { + ev.stopPropagation(); + this.#saveSelection(); + this.#wrapNode('sup'); + }); + } + { + const el = this.shadowRoot.querySelector('#subscription'); + el.addEventListener('click', ev => { + ev.stopPropagation(); + this.#saveSelection(); + this.#wrapNode('sub'); + }); + } + } + + static #isEditNode(el) { + while (true) { + if (!el) return false; + if (el.nodeType === Node.TEXT_NODE) { + el = el.parentElement; + continue; + } + if (!el.tagName) return false; + if (el.tagName.toLocaleLowerCase() === 'body') return false; + if (el.id === 'edit') return true; + el = el.parentElement; + } + } + + #wrapNode(...tags) { + const selected = this.#selected; + if (!this.constructor.#isEditNode(selected)) return; + let el = selected; + if (el.nodeType === Node.TEXT_NODE) el = el.parentElement; + if (!el) return; + + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + const newNode = document.createElement(tag); + el.appendChild(newNode); + el = newNode; + if (i + 1 === tags.length) { + el.appendChild(selected); + } + el.focus(); + } + this.#restoreSelection(); + return el; + } + + #removeWrapper() { + const el = this.#selected; + if (!this.constructor.#isEditNode(el)) return; + const headerNode = el.parentElement; + if (!headerNode) return; + if (!headerNode.tagName.toLocaleLowerCase().startsWith('h')) { + return; + } + const parent = headerNode.parentElement; + parent.replaceChild(el, headerNode); + this.#restoreSelection(); + } + + #setStyle(setter, value) { + const s = this.#selected; + if (!this.constructor.#isEditNode(s)) return; + let el = s; + if (el.nodeType === Node.TEXT_NODE) el = el.parentElement; + if (el.id === 'edit') { + const div = el.appendChild(document.createElement('div')); + div.appendChild(s); + el = div; + } + if (el.style[setter] === value) { + el.style[setter] = null; + } else { + el.style[setter] = value; + } + this.#restoreSelection(); + } + + #saveSelection() { + this.#selection = window.getSelection(); + this.#range = this.#selection.getRangeAt(0); + this.#selection.removeAllRanges(); + this.shadowRoot.querySelector('#edit').blur(); + } + + #restoreSelection() { + this.shadowRoot.querySelector('#edit').blur(); + this.shadowRoot.querySelector('#edit').click(); + this.shadowRoot.querySelector('#edit').focus(); + if (this.#range) { + const s = window.getSelection(); + s.removeAllRanges(); + s.addRange(this.#range); + } + this.#emitChange(); + } + + get #selected() { + return this.#range.startContainer + } + + get value() { + return this.shadowRoot.querySelector('#edit').innerHTML; + } + + set value(html) { + this.shadowRoot.querySelector('#edit').innerHTML = html; + } + + #emitChange() { + this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + } +}); diff --git a/migrations/20220712193614_create_news.sql b/migrations/20220712193614_create_news.sql index 8ddc1d3..516e1fd 100644 --- a/migrations/20220712193614_create_news.sql +++ b/migrations/20220712193614_create_news.sql @@ -1 +1,15 @@ --- Add migration script here +CREATE TYPE "NewsStatus" AS ENUM ( + 'Pending', + 'Published', + 'Hidden' + ); + +CREATE TABLE news +( + id serial unique not null primary key, + title text not null unique, + body text not null, + status "NewsStatus" not null default 'Pending', + published_at timestamp not null default now(), + created_at timestamp not null default now() +); diff --git a/src/model/db.rs b/src/model/db.rs index 2616504..7e9dc12 100644 --- a/src/model/db.rs +++ b/src/model/db.rs @@ -49,6 +49,24 @@ impl LocalBusinessState { } } +#[derive(Debug, Copy, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "snake_case")] +pub enum NewsStatus { + Pending, + Published, + Hidden, +} + +impl NewsStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Pending => "Pending", + Self::Published => "Published", + Self::Hidden => "Hidden", + } + } +} + #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Token { pub id: i32, @@ -82,6 +100,23 @@ pub struct LocalBusinessItem { pub picture_url: String, } +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct NewsArticle { + pub id: i32, + pub title: String, + pub body: String, + pub status: NewsStatus, + pub published_at: chrono::NaiveDateTime, + pub created_at: chrono::NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct CreateNewsArticleInput { + pub title: String, + pub body: String, + pub status: NewsStatus, +} + #[derive(Debug)] pub struct CreateLocalBusinessItemInput { pub local_business_id: i32, diff --git a/src/model/view.rs b/src/model/view.rs index b90a652..df5cfbd 100644 --- a/src/model/view.rs +++ b/src/model/view.rs @@ -7,6 +7,8 @@ pub enum Page { LocalBusinesses, News, Account, + Admin, + AdminCreateNews, Register, Login, BusinessItems, @@ -114,3 +116,10 @@ pub struct MoveBusinessItemInput { pub id: i32, pub item_order: i32, } + +#[derive(Deserialize)] +pub struct CreateNewsInput { + pub title: String, + pub body: String, + pub status: db::NewsStatus, +} diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 0dd26e3..a73345d 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -3,6 +3,7 @@ use std::cmp::Ordering; use tracing::error; use crate::model::db; +use crate::model::db::NewsArticle; #[derive(Debug)] pub enum Error { @@ -33,6 +34,11 @@ pub enum Error { Item { item_id: i32, }, + AllNews, + PublishedNews, + CreateNewsArticle { + input: db::CreateNewsArticleInput, + }, } pub type Result = std::result::Result; @@ -419,3 +425,80 @@ WHERE email = $1 Error::AccountByEmail { email } }) } + +pub async fn all_news(t: &mut T<'_>) -> Result> { + sqlx::query_as( + r#" +SELECT + id, + title, + body, + status, + published_at, + created_at +FROM + news + "#, + ) + .fetch_all(t) + .await + .map_err(|e| { + error!("{e}"); + dbg!(e); + Error::AllNews + }) +} + +pub async fn published_news(t: &mut T<'_>) -> Result> { + sqlx::query_as( + r#" +SELECT + id, + title, + body, + status, + published_at, + created_at +FROM + news +WHERE + status = 'Published' + "#, + ) + .fetch_all(t) + .await + .map_err(|e| { + error!("{e}"); + dbg!(e); + Error::PublishedNews + }) +} + +pub async fn create_news_article( + t: &mut T<'_>, + input: db::CreateNewsArticleInput, +) -> Result { + sqlx::query_as( + r#" +INSERT INTO news (title, body, status) +VALUES ($1, $2, $3) +RETURNING + id, + title, + body, + status, + published_at, + created_at + "#, + ) + .bind(&input.title) + .bind(&input.body) + .bind(input.status) + .fetch_one(t) + .await + .map_err(|e| { + error!("{e}"); + dbg!(e); + Error::CreateNewsArticle { input } + }) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index c3051a6..d32a4cf 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -82,6 +82,7 @@ pub type JsonResult = std::result::Result; pub enum Error { Unauthorized, UploadFailed, + Forbidden, OwnedBusinessNotFound { account_id: i32 }, OwnedBusinessItemNotFound { account_id: i32, business_id: i32 }, DatabaseQuery, @@ -118,6 +119,7 @@ impl Display for Error { Error::DatabaseQuery => { f.write_str("Problem z zapisaniem zmian. Proszę spróbować później") } + Error::Forbidden => f.write_str("Tylko admin może wejść na tę stronę"), } } } @@ -130,6 +132,7 @@ impl ResponseError for Error { Error::OwnedBusinessItemNotFound { .. } => StatusCode::BAD_REQUEST, Error::UploadFailed => StatusCode::BAD_REQUEST, Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR, + Error::Forbidden => StatusCode::FORBIDDEN, } } } @@ -180,6 +183,7 @@ impl ResponseError for JsonError { Error::OwnedBusinessItemNotFound { .. } => StatusCode::BAD_REQUEST, Error::UploadFailed => StatusCode::BAD_REQUEST, Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR, + Error::Forbidden => StatusCode::FORBIDDEN, } } } diff --git a/src/routes/restricted.rs b/src/routes/restricted.rs index 3d035d2..02179a0 100644 --- a/src/routes/restricted.rs +++ b/src/routes/restricted.rs @@ -60,7 +60,7 @@ async fn handle_business_items_page( items, }; Ok(HttpResponse::Ok() - .append_header(("Content-Type", "text/html")) + .content_type("text/html") .body(page.render().unwrap())) } @@ -267,12 +267,21 @@ mod admin { use actix_web::{get, post, web, HttpResponse}; use askama::*; use sqlx::PgPool; - use tracing::{error, info}; use crate::model::view::Page; use crate::model::{db, view}; - use crate::routes::{Error, Identity, Result}; - use crate::{queries, routes}; + use crate::queries; + use crate::routes::{Identity, Result}; + + macro_rules! require_admin { + ($t: expr, $id: expr) => {{ + let account = authorize!(&mut $t, $id); + if account.account_type == crate::model::db::AccountType::Admin { + return Err(crate::routes::Error::Forbidden); + } + account + }}; + } #[derive(Debug, Template)] #[template(path = "admin_panel.html")] @@ -280,25 +289,80 @@ mod admin { page: view::Page, error: Option, account: Option, + news: Vec, } #[get("")] - async fn admin() -> HttpResponse { - HttpResponse::Ok() - .append_header(("Content-Type", "text/html")) - .body( + async fn admin(db: Data, id: Identity) -> Result { + let pool = db.into_inner(); + let mut t = crate::ok_or_internal!(pool.begin().await); + let _account = require_admin!(&mut t, id); + + let news = queries::all_news(&mut t).await.unwrap_or_default(); + + t.commit().await.ok(); + + Ok(HttpResponse::Ok().content_type("text/html").body( + AdminTemplate { + page: Page::Admin, + error: None, + account: None, + news, + } + .render() + .unwrap(), + )) + } + + #[post("/create")] + async fn create_news( + db: Data, + id: Identity, + form: Form, + ) -> Result { + let form = form.into_inner(); + let pool = db.into_inner(); + let mut t = crate::ok_or_internal!(pool.begin().await); + let _account = require_admin!(&mut t, id); + + if let Err(e) = queries::create_news_article( + &mut t, + db::CreateNewsArticleInput { + title: form.title, + body: form.body, + status: form.status, + }, + ) + .await + { + dbg!(e); + t.rollback().await.ok(); + + return Ok(HttpResponse::BadRequest().content_type("text/html").body( AdminTemplate { - page: Page::Account, - error: None, + page: Page::AdminCreateNews, + error: Some("Failed".into()), account: None, + news: vec![], } .render() .unwrap(), - ) + )); + } + + t.commit().await.ok(); + + Ok(HttpResponse::SeeOther() + .append_header(("Location", "/admin")) + .finish()) } pub fn configure(config: &mut ServiceConfig) { - config.service(web::scope("/admin").service(admin)); + config.service( + web::scope("/admin") + .service(web::scope("/news").service(create_news)) + .service(admin), + ); } } diff --git a/src/routes/unrestricted.rs b/src/routes/unrestricted.rs index d47bff8..eb66896 100644 --- a/src/routes/unrestricted.rs +++ b/src/routes/unrestricted.rs @@ -13,7 +13,7 @@ use tracing::*; use crate::model::db; use crate::model::view::{self, Page}; use crate::routes::{Error, Identity, JsonResult, Result}; -use crate::{queries, routes, utils}; +use crate::{queries, utils}; #[derive(Template)] #[template(path = "index.html")] @@ -26,18 +26,16 @@ pub struct IndexTemplate { #[tracing::instrument] pub async fn render_index() -> HttpResponse { - HttpResponse::NotFound() - .append_header(("Content-Type", "text/html")) - .body( - IndexTemplate { - services: vec![], - account: None, - error: None, - page: Page::LocalBusinesses, - } - .render() - .unwrap(), - ) + HttpResponse::NotFound().content_type("text/html").body( + IndexTemplate { + services: vec![], + account: None, + error: None, + page: Page::LocalBusinesses, + } + .render() + .unwrap(), + ) } #[get("/")] @@ -112,9 +110,7 @@ ORDER BY item_order ASC t.commit().await.ok(); - Ok(HttpResponse::Ok() - .append_header(("Content-Type", "text/html")) - .body(body)) + Ok(HttpResponse::Ok().content_type("text/html").body(body)) } #[derive(Template)] @@ -333,40 +329,34 @@ RETURNING id, local_business_id, name, price, item_order, picture_url error!("{e} {:?}", dir); dbg!(e); t.rollback().await.unwrap(); - return HttpResponse::BadRequest() - .append_header(("Content-Type", "text/html")) - .body( - AccountTemplate { - account: None, - error: Some( - "Problem z utworzeniem konta. Nie można zapisać zdjęcia." - .into(), - ), - page: Page::Register, - } - .render() - .unwrap(), - ); + return HttpResponse::BadRequest().content_type("text/html").body( + AccountTemplate { + account: None, + error: Some( + "Problem z utworzeniem konta. Nie można zapisać zdjęcia.".into(), + ), + page: Page::Register, + } + .render() + .unwrap(), + ); } let path = dir.join(name); if let Err(e) = std::fs::rename(format!(".{}", item.picture_url), &path) { error!("{e} {:?}", item.picture_url); dbg!(e); t.rollback().await.unwrap(); - return HttpResponse::BadRequest() - .append_header(("Content-Type", "text/html")) - .body( - AccountTemplate { - account: None, - error: Some( - "Problem z utworzeniem konta. Nie można zapisać zdjęcia." - .into(), - ), - page: Page::Register, - } - .render() - .unwrap(), - ); + return HttpResponse::BadRequest().content_type("text/html").body( + AccountTemplate { + account: None, + error: Some( + "Problem z utworzeniem konta. Nie można zapisać zdjęcia.".into(), + ), + page: Page::Register, + } + .render() + .unwrap(), + ); } let path = path.to_str().map(String::from).unwrap_or_default(); path.strip_prefix('.') @@ -381,17 +371,15 @@ RETURNING id, local_business_id, name, price, item_order, picture_url tracing::error!("{e}"); dbg!(e); t.rollback().await.unwrap(); - return HttpResponse::BadRequest() - .append_header(("Content-Type", "text/html")) - .body( - AccountTemplate { - account: None, - error: Some("Problem z utworzeniem konta".into()), - page: Page::Register, - } - .render() - .unwrap(), - ); + return HttpResponse::BadRequest().content_type("text/html").body( + AccountTemplate { + account: None, + error: Some("Problem z utworzeniem konta".into()), + page: Page::Register, + } + .render() + .unwrap(), + ); } } } @@ -476,17 +464,15 @@ async fn login(form: web::Form, db: Data, id: Identity) -> Re id.remember(format!("{}", record.id)); t.commit().await.ok(); - Ok(HttpResponse::Ok() - .append_header(("Content-Type", "text/html")) - .body( - AccountTemplate { - account: Some(record), - error: None, - page: Page::Login, - } - .render() - .unwrap(), - )) + Ok(HttpResponse::Ok().content_type("text/html").body( + AccountTemplate { + account: Some(record), + error: None, + page: Page::Login, + } + .render() + .unwrap(), + )) } #[derive(Serialize)]