Move to using rendered templates in rust.

This commit is contained in:
Drew Galbraith 2024-12-02 21:33:33 -08:00
parent 6050f1cb70
commit 381910d462
13 changed files with 691 additions and 715 deletions

877
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
askama = { version = "0.12.1", features=[ "with-axum" ] }
askama_axum = "0.4.0"
axum = "0.7" axum = "0.7"
dotenvy = "0.15" dotenvy = "0.15"
http = "1.1.0" http = "1.1.0"
@ -12,4 +14,4 @@ serde_json = "1.0"
sqlx = { version = "0.7", features=[ "runtime-tokio", "sqlite" ] } sqlx = { version = "0.7", features=[ "runtime-tokio", "sqlite" ] }
tokio = { version = "1.38", features=[ "full" ] } tokio = { version = "1.38", features=[ "full" ] }
tower = "0.4.13" tower = "0.4.13"
tower-http = { version = "0.5.2", features=[ "cors" ] } tower-http = { version = "0.5.2", features=[ "cors", "fs" ] }

12
assets/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,14 +0,0 @@
{
auto_https off
}
http://localhost:8000
handle_errors {
rewrite * /index.html
file_server
}
file_server

View File

@ -1,10 +0,0 @@
{
"targets": {
"Captain's Log": {
"inputs": [
"src/Main.elm"
],
"output": "build/main.js"
}
}
}

View File

@ -1,27 +0,0 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View File

@ -1,19 +0,0 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8" />
<title>Captains Log</title>
<script type="text/javascript" src="/build/main.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous" />
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
</head>
<body>
<script>
var app = Elm.Main.init();
</script>
</body>
</html>

View File

@ -1,259 +0,0 @@
module Dashboard exposing (Model, Msg, Task, init, update, view)
import Browser exposing (Document)
import Browser.Navigation as Nav
import Html exposing (Html, button, div, h6, input, p, text, textarea)
import Html.Attributes exposing (class, placeholder, value)
import Html.Events exposing (onClick, onInput)
import Http
import Json.Decode exposing (Decoder, field, int, list, map3, maybe, string)
import Json.Encode as Encode
type alias Task =
{ id : Int
, title : String
, description : Maybe String
}
type alias CreateTaskInfo =
{ title : String, description : String }
defaultCreateTask : CreateTaskInfo
defaultCreateTask =
{ title = ""
, description = ""
}
type alias Model =
{ navKey : Nav.Key
, tasks : Status (List Task)
, createTaskCollapsed : Bool
, createTask : CreateTaskInfo
}
type Status a
= Failure
| Loading
| Success a
init : Nav.Key -> ( Model, Cmd Msg )
init navKey =
reloadTasks (Model navKey Loading True defaultCreateTask)
reloadTasks : Model -> ( Model, Cmd Msg )
reloadTasks model =
( model
, Http.get
{ url = "http://localhost:3000/tasks"
, expect = Http.expectJson GotTasks (list taskDecoder)
}
)
type Msg
= GotTasks (Result Http.Error (List Task))
| ToggleCreateTask
| CreateTaskUpdateTitle String
| CreateTaskUpdateDescription String
| CreateTask
| GotTaskCreated (Result Http.Error Task)
| DeleteTask Int
| GotTaskDeleted (Result Http.Error ())
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotTasks result ->
case result of
Ok tasks ->
( { model | tasks = Success tasks }, Cmd.none )
Err _ ->
( { model | tasks = Failure }, Cmd.none )
ToggleCreateTask ->
( { model | createTaskCollapsed = not model.createTaskCollapsed }
, Cmd.none
)
CreateTask ->
( model
, Http.post
{ url = "http://localhost:3000/tasks"
, body = Http.jsonBody (taskEncoder model.createTask)
, expect = Http.expectJson GotTaskCreated taskDecoder
}
)
GotTaskCreated result ->
case result of
Ok task ->
case model.tasks of
Success tasks ->
( { model | tasks = Success (task :: tasks) }, Cmd.none )
_ ->
( { model | tasks = Success [ task ] }, Cmd.none )
Err _ ->
( model, Cmd.none )
CreateTaskUpdateTitle title ->
let
createTask =
model.createTask
in
( { model | createTask = { createTask | title = title } }, Cmd.none )
CreateTaskUpdateDescription description ->
let
createTask =
model.createTask
in
( { model | createTask = { createTask | description = description } }, Cmd.none )
DeleteTask id ->
( model
, Http.request
{ method = "DELETE"
, headers = []
, url = "http://localhost:3000/tasks/" ++ String.fromInt id
, body = Http.emptyBody
, expect = Http.expectWhatever GotTaskDeleted
, timeout = Nothing
, tracker = Nothing
}
)
GotTaskDeleted _ ->
reloadTasks model
taskDecoder : Decoder Task
taskDecoder =
map3 Task
(field "id" int)
(field "title" string)
(maybe
(field "description"
string
)
)
taskEncoder : CreateTaskInfo -> Encode.Value
taskEncoder task =
Encode.object
[ ( "title", Encode.string task.title )
, ( "description", Encode.string task.description )
]
view : Model -> Document Msg
view model =
{ title = "Dashboard"
, body = [ viewBody model ]
}
viewBody : Model -> Html Msg
viewBody model =
case model.tasks of
Failure ->
div [] [ text "Failed to load" ]
Loading ->
div [] [ text "Loading" ]
Success tasks ->
div [ class "container", class "bg-light" ]
[ viewTaskList tasks
, viewCreate model
]
viewTaskList : List Task -> Html Msg
viewTaskList tasks =
div [ class "p-3" ]
[ div
[ class "list-group" ]
(List.map viewTaskListItem tasks
++ [ div [ class "list-group-item" ]
[ text
("Loaded "
++ String.fromInt (List.length tasks)
++ " tasks"
)
]
]
)
]
viewTaskListItem : Task -> Html Msg
viewTaskListItem task =
div [ class "list-group-item d-flex justify-content-between" ]
[ input [ Html.Attributes.type_ "checkbox" ] []
, div []
[ h6 []
[ text task.title ]
, p []
[ text
(case task.description of
Just desc ->
desc
_ ->
""
)
]
]
, div []
[ button
[ class "btn"
, class "btn-danger"
, onClick (DeleteTask task.id)
]
[ Html.i [ class "bi-trash3" ] []
]
]
]
viewCreate : Model -> Html Msg
viewCreate model =
if model.createTaskCollapsed then
div [] [ button [ onClick ToggleCreateTask ] [ text "+ Expand" ] ]
else
div []
[ button [ onClick ToggleCreateTask ] [ text "- Collapse" ]
, div []
[ input
[ placeholder "Task Title"
, value
model.createTask.title
, onInput CreateTaskUpdateTitle
]
[]
]
, div []
[ textarea
[ placeholder "Task Description"
, value
model.createTask.description
, onInput CreateTaskUpdateDescription
]
[]
]
, div []
[ button [ onClick CreateTask ] [ text "Create Task" ] ]
]

View File

@ -1,129 +0,0 @@
module Main exposing (..)
import Browser
import Browser.Navigation as Nav
import Dashboard
import Html exposing (..)
import Html.Attributes exposing (..)
import Url
import Url.Parser as Parser exposing (Parser, oneOf)
main : Program () Model Msg
main =
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlChange = UrlChanged
, onUrlRequest = LinkClicked
}
type Model
= NotFound Nav.Key
| Dashboard Dashboard.Model
init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url key =
stepUrl url (NotFound key)
type Route
= DashboardRoute
parser : Parser (Route -> a) a
parser =
oneOf
[ Parser.map DashboardRoute (Parser.s "tasks")
]
stepUrl : Url.Url -> Model -> ( Model, Cmd Msg )
stepUrl url model =
case Parser.parse parser url of
Just DashboardRoute ->
Dashboard.init (toNavKey model)
|> mapModelAndMsg Dashboard GotDashboardMsg
Nothing ->
( NotFound (toNavKey model), Cmd.none )
mapModelAndMsg : (subModel -> Model) -> (subMsg -> Msg) -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg )
mapModelAndMsg toModel toMsg ( subModel, subCmd ) =
( toModel subModel
, Cmd.map toMsg subCmd
)
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url.Url
| GotDashboardMsg Dashboard.Msg
toNavKey : Model -> Nav.Key
toNavKey model =
case model of
NotFound key ->
key
Dashboard dashboard ->
dashboard.navKey
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case ( msg, model ) of
( LinkClicked urlRequest, _ ) ->
case urlRequest of
Browser.Internal url ->
( model, Nav.pushUrl (toNavKey model) (Url.toString url) )
Browser.External href ->
( model, Nav.load href )
( UrlChanged url, _ ) ->
stepUrl url model
( GotDashboardMsg dashboardMsg, Dashboard dashboard ) ->
Dashboard.update dashboardMsg dashboard
|> mapModelAndMsg Dashboard GotDashboardMsg
( _, _ ) ->
( model, Cmd.none )
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.none
view : Model -> Browser.Document Msg
view model =
let
viewPage toMsg content =
{ title = content.title
, body = List.map (Html.map toMsg) content.body
}
in
case model of
NotFound _ ->
{ title = "Not Found"
, body =
[ text "This page was not found!"
, viewLink "/tasks"
]
}
Dashboard dashboard ->
viewPage GotDashboardMsg (Dashboard.view dashboard)
viewLink : String -> Html msg
viewLink path =
li [] [ a [ href path ] [ text path ] ]

View File

@ -25,6 +25,12 @@ async fn main() {
let app = axum::Router::new() let app = axum::Router::new()
.fallback(handle404) .fallback(handle404)
.route("/", get(routes::tasks::home))
.nest_service("/assets", tower_http::services::ServeDir::new("assets"))
.nest_service(
"/favicon.ico",
tower_http::services::ServeFile::new("assests/favicon.ico"),
)
.route( .route(
"/tasks", "/tasks",
get(routes::tasks::list).post(routes::tasks::create), get(routes::tasks::list).post(routes::tasks::create),

View File

@ -3,9 +3,9 @@ use sqlx::{sqlite::SqliteQueryResult, FromRow, SqlitePool};
#[derive(Serialize, Deserialize, FromRow)] #[derive(Serialize, Deserialize, FromRow)]
pub struct Task { pub struct Task {
id: i64, pub id: i64,
title: String, pub title: String,
description: Option<String>, pub description: Option<String>,
} }
impl Task { impl Task {

View File

@ -1,5 +1,5 @@
use askama_axum::Template;
use axum::extract::{Json, Path, State}; use axum::extract::{Json, Path, State};
use axum::http::header;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use serde::Deserialize; use serde::Deserialize;
@ -7,6 +7,18 @@ use serde::Deserialize;
use crate::global::FormErrorResponse; use crate::global::FormErrorResponse;
use crate::{global::AppState, models::Task}; use crate::{global::AppState, models::Task};
#[derive(Template)]
#[template(path = "index.html")]
struct HomeTemplate {
pub tasks: Vec<Task>,
}
pub async fn home(state: State<AppState>) -> impl IntoResponse {
HomeTemplate {
tasks: Task::all(&state.db_pool).await.unwrap(),
}
}
pub async fn list(state: State<AppState>) -> impl IntoResponse { pub async fn list(state: State<AppState>) -> impl IntoResponse {
let tasks = Task::all(&state.db_pool).await.unwrap(); let tasks = Task::all(&state.db_pool).await.unwrap();

29
templates/index.html Normal file
View File

@ -0,0 +1,29 @@
<html>
<head>
<meta charset="UTF-8">
<title>Captains Log</title>
<script type="text/javascript" src="/build/main.js"></script>
<link href="/assets/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto link-body-emphasis text-decoration-none">
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
<span class="fs-4">Captains Log</span>
</a>
<ul class="nav nav-pills">
<li class="nav-item"><a href="#" class="nav-link active" aria-current="page">Home</a></li>
<li class="nav-item"><a href="#" class="nav-link">Features</a></li>
<li class="nav-item"><a href="#" class="nav-link">Pricing</a></li>
<li class="nav-item"><a href="#" class="nav-link">FAQs</a></li>
<li class="nav-item"><a href="#" class="nav-link">About</a></li>
</ul>
</header>
<ul>
{% for task in tasks %}
<li>{{ task.title }}</li>
{% endfor %}
</ul>
</body>
</html>