Add page edit

- Add nav link and user log-on link filter/tag
- Add page list support
- Add prior permalink index/search
- Remove v2 C# projects
This commit is contained in:
2022-04-18 18:06:17 -04:00
parent 8ce2d5a2ed
commit 48e6d3edfa
85 changed files with 593 additions and 4878 deletions

74
src/MyWebLog/Caches.fs Normal file
View File

@@ -0,0 +1,74 @@
namespace MyWebLog
open Microsoft.AspNetCore.Http
/// Helper functions for caches
module Cache =
/// Create the cache key for the web log for the current request
let makeKey (ctx : HttpContext) = ctx.Request.Host.ToUriComponent ()
open System.Collections.Concurrent
/// <summary>
/// In-memory cache of web log details
/// </summary>
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
/// settings update page</remarks>
module WebLogCache =
/// The cache of web log details
let private _cache = ConcurrentDictionary<string, WebLog> ()
/// Does a host exist in the cache?
let exists ctx = _cache.ContainsKey (Cache.makeKey ctx)
/// Get the web log for the current request
let get ctx = _cache[Cache.makeKey ctx]
/// Cache the web log for a particular host
let set ctx webLog = _cache[Cache.makeKey ctx] <- webLog
/// A cache of page information needed to display the page list in templates
module PageListCache =
open Microsoft.Extensions.DependencyInjection
open MyWebLog.ViewModels
open RethinkDb.Driver.Net
/// Cache of displayed pages
let private _cache = ConcurrentDictionary<string, DisplayPage[]> ()
/// Get the pages for the web log for this request
let get ctx = _cache[Cache.makeKey ctx]
/// Update the pages for the current web log
let update ctx = task {
let webLog = WebLogCache.get ctx
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
let! pages = Data.Page.findListed webLog.id conn
_cache[Cache.makeKey ctx] <- pages |> List.map (DisplayPage.fromPage webLog) |> Array.ofList
}
/// Cache for parsed templates
module TemplateCache =
open DotLiquid
open System.IO
/// Cache of parsed templates
let private _cache = ConcurrentDictionary<string, Template> ()
/// Get a template for the given theme and template nate
let get (theme : string) (templateName : string) = task {
let templatePath = $"themes/{theme}/{templateName}"
match _cache.ContainsKey templatePath with
| true -> ()
| false ->
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
_cache[templatePath] <- Template.Parse (file, SyntaxCompatibility.DotLiquid22)
return _cache[templatePath]
}

View File

@@ -1,7 +1,6 @@
[<RequireQualifiedAccess>]
module MyWebLog.Handlers
open System.Collections.Generic
open DotLiquid
open Giraffe
open Microsoft.AspNetCore.Http
@@ -26,12 +25,11 @@ module Error =
>=> text ex.Message *)
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler =
fun next ctx ->
(next, ctx)
||> match ctx.Request.Method with
| "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
let notAuthorized : HttpHandler = fun next ctx ->
(next, ctx)
||> match ctx.Request.Method with
| "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler =
@@ -41,42 +39,27 @@ module Error =
[<AutoOpen>]
module private Helpers =
open Markdig
open Microsoft.AspNetCore.Antiforgery
open Microsoft.Extensions.DependencyInjection
open System.Collections.Concurrent
open System.IO
/// Cache for parsed templates
module private TemplateCache =
/// Cache of parsed templates
let private views = ConcurrentDictionary<string, Template> ()
/// Get a template for the given web log
let get (theme : string) (templateName : string) = task {
let templatePath = $"themes/{theme}/{templateName}"
match views.ContainsKey templatePath with
| true -> ()
| false ->
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
views[templatePath] <- Template.Parse (file, SyntaxCompatibility.DotLiquid22)
return views[templatePath]
}
open System.Security.Claims
/// Either get the web log from the hash, or get it from the cache and add it to the hash
let deriveWebLogFromHash (hash : Hash) ctx =
let private deriveWebLogFromHash (hash : Hash) ctx =
match hash.ContainsKey "web_log" with
| true -> hash["web_log"] :?> WebLog
| false ->
let wl = WebLogCache.getByCtx ctx
let wl = WebLogCache.get ctx
hash.Add ("web_log", wl)
wl
/// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme theme template layout next ctx = fun (hash : Hash) -> task {
let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
// Don't need the web log, but this adds it to the hash if the function is called directly
let _ = deriveWebLogFromHash hash ctx
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
hash.Add ("page_list", PageListCache.get ctx)
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a two-pass
// render; the net effect is a "layout" capability similar to Razor or Pug
@@ -86,20 +69,26 @@ module private Helpers =
hash.Add ("content", contentTemplate.Render hash)
// ...then render that content with its layout
let! layoutTemplate = TemplateCache.get theme (defaultArg layout "layout")
let! layoutTemplate = TemplateCache.get theme "layout"
return! htmlString (layoutTemplate.Render hash) next ctx
}
/// Return a view for the web log's default theme
let themedView template layout next ctx = fun (hash : Hash) -> task {
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template layout next ctx hash
let themedView template next ctx = fun (hash : Hash) -> task {
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash
}
/// The web log ID for the current request
let webLogId ctx = (WebLogCache.getByCtx ctx).id
/// Get the web log ID for the current request
let webLogId ctx = (WebLogCache.get ctx).id
/// Get the user ID for the current request
let userId (ctx : HttpContext) =
WebLogUserId (ctx.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value
/// Get the RethinkDB connection
let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> ()
/// Get the Anti-CSRF service
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
/// Get the cross-site request forgery token set
@@ -115,16 +104,26 @@ module private Helpers =
/// Require a user to be logged on
let requireUser = requiresAuthentication Error.notAuthorized
/// Pipeline with most extensions enabled
let mdPipeline =
MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().Build ()
/// Get the HTML representation of the text of a revision
let revisionToHtml (rev : Revision) =
match rev.sourceType with Html -> rev.text | Markdown -> Markdown.ToHtml (rev.text, mdPipeline)
open System.Collections.Generic
/// Handlers to manipulate admin functions
module Admin =
// GET /admin/
// GET /admin
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
let webLogId' = webLogId ctx
let conn' = conn ctx
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
let webLogId = webLogId ctx
let conn = conn ctx
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId conn
let! posts = Data.Post.countByStatus Published |> getCount
let! drafts = Data.Post.countByStatus Draft |> getCount
let! pages = Data.Page.countAll |> getCount
@@ -143,12 +142,12 @@ module Admin =
topLevelCategories = topCats
}
|}
|> viewForTheme "admin" "dashboard" None next ctx
|> viewForTheme "admin" "dashboard" next ctx
}
// GET /admin/settings
let settings : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let webLog = WebLogCache.get ctx
let! allPages = Data.Page.findAll webLog.id (conn ctx)
return!
Hash.FromAnonymousObject
@@ -170,14 +169,14 @@ module Admin =
web_log = webLog
page_title = "Web Log Settings"
|}
|> viewForTheme "admin" "settings" None next ctx
|> viewForTheme "admin" "settings" next ctx
}
// POST /admin/settings
let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let conn' = conn ctx
let conn = conn ctx
let! model = ctx.BindFormAsync<SettingsModel> ()
match! Data.WebLog.findByHost (WebLogCache.getByCtx ctx).urlBase conn' with
match! Data.WebLog.findById (WebLogCache.get ctx).id conn with
| Some webLog ->
let updated =
{ webLog with
@@ -187,14 +186,102 @@ module Admin =
postsPerPage = model.postsPerPage
timeZone = model.timeZone
}
do! Data.WebLog.updateSettings updated conn'
do! Data.WebLog.updateSettings updated conn
// Update cache
WebLogCache.set updated.urlBase updated
WebLogCache.set ctx updated
// TODO: confirmation message
return! redirectTo false "/admin/" next ctx
return! redirectTo false "/admin" next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers to manipulate pages
module Page =
// GET /pages
// GET /pages/page/{pageNbr}
let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.get ctx
let! pages = Data.Page.findPageOfPages webLog.id pageNbr (conn ctx)
return!
Hash.FromAnonymousObject
{| pages = pages |> List.map (DisplayPage.fromPage webLog)
page_title = "Pages"
|}
|> viewForTheme "admin" "page-list" next ctx
}
// GET /page/{id}/edit
let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task {
let! hash = task {
match pgId with
| "new" ->
return
Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditPageModel.fromPage { Page.empty with id = PageId "new" }
page_title = "Add a New Page"
|} |> Some
| _ ->
match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with
| Some page ->
return
Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditPageModel.fromPage page
page_title = "Edit Page"
|} |> Some
| None -> return None
}
match hash with
| Some h -> return! viewForTheme "admin" "page-edit" next ctx h
| None -> return! Error.notFound next ctx
}
// POST /page/{id}/edit
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditPageModel> ()
let webLogId = webLogId ctx
let conn = conn ctx
let now = DateTime.UtcNow
let! pg = task {
match model.pageId with
| "new" ->
return Some
{ Page.empty with
id = PageId.create ()
webLogId = webLogId
authorId = userId ctx
publishedOn = now
}
| pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn
}
match pg with
| Some page ->
let updateList = page.showInPageList <> model.isShownInPageList
let revision = { asOf = now; sourceType = RevisionSource.ofString model.source; text = model.text }
// Detect a permalink change, and add the prior one to the prior list
let page =
match Permalink.toString page.permalink with
| "" -> page
| link when link = model.permalink -> page
| _ -> { page with priorPermalinks = page.permalink :: page.priorPermalinks }
let page =
{ page with
title = model.title
permalink = Permalink model.permalink
updatedOn = now
showInPageList = model.isShownInPageList
text = revisionToHtml revision
revisions = revision :: page.revisions
}
do! (match model.pageId with "new" -> Data.Page.add | _ -> Data.Page.update) page conn
if updateList then do! PageListCache.update ctx
// TODO: confirmation
return! redirectTo false $"/page/{PageId.toString page.id}/edit" next ctx
| None -> return! Error.notFound next ctx
}
@@ -204,21 +291,21 @@ module Post =
// GET /page/{pageNbr}
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
let hash = Hash.FromAnonymousObject {| posts = posts |}
let title =
let webLog = WebLogCache.get ctx
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
let hash = Hash.FromAnonymousObject {| posts = posts |}
let title =
match pageNbr, webLog.defaultPage with
| 1, "posts" -> None
| _, "posts" -> Some $"Page {pageNbr}"
| _, _ -> Some $"Page {pageNbr} &#xab; Posts"
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
return! themedView "index" None next ctx hash
return! themedView "index" next ctx hash
}
// GET /
let home : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let webLog = WebLogCache.get ctx
match webLog.defaultPage with
| "posts" -> return! pageOfPosts 1 next ctx
| pageId ->
@@ -226,31 +313,38 @@ module Post =
| Some page ->
return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx
|> themedView (defaultArg page.template "single-page") next ctx
| None -> return! Error.notFound next ctx
}
// GET *
let catchAll (link : string) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let conn' = conn ctx
let permalink = Permalink link
match! Data.Post.findByPermalink permalink webLog.id conn' with
| Some post -> return! Error.notFound next ctx
// GET {**link}
let catchAll : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.get ctx
let conn = conn ctx
let permalink = (string >> Permalink) ctx.Request.RouteValues["link"]
// Current post
match! Data.Post.findByPermalink permalink webLog.id conn with
| Some _ -> return! Error.notFound next ctx
// TODO: return via single-post action
| None ->
match! Data.Page.findByPermalink permalink webLog.id conn' with
// Current page
match! Data.Page.findByPermalink permalink webLog.id conn with
| Some page ->
return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx
|> themedView (defaultArg page.template "single-page") next ctx
| None ->
// TOOD: search prior permalinks for posts and pages
// We tried, we really tried...
Console.Write($"Returning 404 for permalink |{permalink}|");
return! Error.notFound next ctx
// Prior post
match! Data.Post.findCurrentPermalink permalink webLog.id conn with
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
| None ->
// Prior page
match! Data.Page.findCurrentPermalink permalink webLog.id conn with
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
| None ->
// We tried, we really did...
Console.Write($"Returning 404 for permalink |{permalink}|");
return! Error.notFound next ctx
}
@@ -265,15 +359,15 @@ module User =
/// Hash a password for a given user
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ]
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
Convert.ToBase64String(alg.GetBytes(64))
let allSalt = Array.concat [ salt.ToByteArray (); Encoding.UTF8.GetBytes email ]
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
Convert.ToBase64String (alg.GetBytes 64)
// GET /user/log-on
let logOn : HttpHandler = fun next ctx -> task {
return!
Hash.FromAnonymousObject {| page_title = "Log On"; csrf = (csrfToken ctx) |}
|> viewForTheme "admin" "log-on" None next ctx
|> viewForTheme "admin" "log-on" next ctx
}
// POST /user/log-on
@@ -283,9 +377,9 @@ module User =
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
let claims = seq {
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id)
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
Claim (ClaimTypes.GivenName, user.preferredName)
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
Claim (ClaimTypes.GivenName, user.preferredName)
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
}
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
@@ -294,7 +388,7 @@ module User =
// TODO: confirmation message
return! redirectTo false "/admin/" next ctx
return! redirectTo false "/admin" next ctx
| _ ->
// TODO: make error, not 404
return! Error.notFound next ctx
@@ -318,7 +412,7 @@ let endpoints = [
]
subRoute "/admin" [
GET [
route "/" Admin.dashboard
route "" Admin.dashboard
route "/settings" Admin.settings
]
POST [
@@ -327,7 +421,13 @@ let endpoints = [
]
subRoute "/page" [
GET [
routef "/%d" Post.pageOfPosts
routef "/%d" Post.pageOfPosts
routef "/%s/edit" Page.edit
route "s" (Page.all 1)
routef "s/page/%d" Page.all
]
POST [
route "/save" Page.save
]
]
subRoute "/user" [
@@ -339,4 +439,5 @@ let endpoints = [
route "/log-on" User.doLogOn
]
]
route "{**link}" Post.catchAll
]

View File

@@ -3,11 +3,12 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>3391</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
<Compile Include="WebLogCache.fs" />
<Compile Include="Caches.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
@@ -15,6 +16,7 @@
<ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.610" />
<PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Markdig" Version="0.28.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -9,19 +9,57 @@ open System
type WebLogMiddleware (next : RequestDelegate) =
member this.InvokeAsync (ctx : HttpContext) = task {
let host = ctx.Request.Host.ToUriComponent ()
match WebLogCache.exists host with
match WebLogCache.exists ctx with
| true -> return! next.Invoke ctx
| false ->
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost host conn with
match! Data.WebLog.findByHost (Cache.makeKey ctx) conn with
| Some webLog ->
WebLogCache.set host webLog
WebLogCache.set ctx webLog
do! PageListCache.update ctx
return! next.Invoke ctx
| None -> ctx.Response.StatusCode <- 404
}
/// DotLiquid filters
module DotLiquidBespoke =
open DotLiquid
open System.IO
/// A filter to generate nav links, highlighting the active link (exact match)
type NavLinkFilter () =
static member NavLink (ctx : Context, url : string, text : string) =
seq {
"<li class=\"nav-item\"><a class=\"nav-link"
if url = string ctx.Environments[0].["current_page"] then " active"
"\" href=\"/"
url
"\">"
text
"</a></li>"
}
|> Seq.fold (+) ""
/// Create links for a user to log on or off, and a dashboard link if they are logged off
type UserLinksTag () =
inherit Tag ()
override this.Render (context : Context, result : TextWriter) =
seq {
"""<ul class="navbar-nav flex-grow-1 justify-content-end">"""
match Convert.ToBoolean context.Environments[0].["logged_on"] with
| true ->
"""<li class="nav-item"><a class="nav-link" href="/admin">Dashboard</a></li>"""
"""<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>"""
| false ->
"""<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>"""
"</ul>"
}
|> Seq.iter result.WriteLine
/// Initialize a new database
let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
@@ -140,14 +178,20 @@ let main args =
let _ = builder.Services.AddSingleton<IConnection> conn
// Set up DotLiquid
Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter>
Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links"
let all = [| "*" |]
Template.RegisterSafeType (typeof<Page>, all)
Template.RegisterSafeType (typeof<WebLog>, all)
Template.RegisterSafeType (typeof<DashboardModel>, all)
Template.RegisterSafeType (typeof<DisplayPage>, all)
Template.RegisterSafeType (typeof<SettingsModel>, all)
Template.RegisterSafeType (typeof<EditPageModel>, all)
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
Template.RegisterSafeType (typeof<Option<_>>, all) // doesn't quite get the job done....
Template.RegisterSafeType (typeof<string option>, all)
Template.RegisterSafeType (typeof<KeyValuePair>, all)
let app = builder.Build ()
@@ -160,7 +204,7 @@ let main args =
let _ = app.UseAuthentication ()
let _ = app.UseStaticFiles ()
let _ = app.UseRouting ()
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapGiraffeEndpoints Handlers.endpoints)
let _ = app.UseGiraffe Handlers.endpoints
app.Run()

View File

@@ -1,24 +0,0 @@
/// <summary>
/// In-memory cache of web log details
/// </summary>
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
/// settings update page</remarks>
module MyWebLog.WebLogCache
open Microsoft.AspNetCore.Http
open System.Collections.Concurrent
/// The cache of web log details
let private _cache = ConcurrentDictionary<string, WebLog> ()
/// Does a host exist in the cache?
let exists host = _cache.ContainsKey host
/// Get the details for a web log via its host
let get host = _cache[host]
/// Get the details for a web log via its host
let getByCtx (ctx : HttpContext) = _cache[ctx.Request.Host.ToUriComponent ()]
/// Set the details for a particular host
let set host details = _cache[host] <- details

View File

@@ -1,4 +1,5 @@
<article class="container pt-3">
<h2 class="py-3">{{ web_log.name }} &bull; Dashboard</h2>
<article class="container">
<div class="row">
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
<div class="card">
@@ -21,7 +22,7 @@
All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span>
&nbsp; Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span>
</h6>
<a href="/pages/list" class="btn btn-secondary me-2">View All</a>
<a href="/pages" class="btn btn-secondary me-2">View All</a>
<a href="/page/new/edit" class="btn btn-primary">Create a New Page</a>
</div>
</div>

View File

@@ -17,20 +17,26 @@
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
<span class="navbar-text">{{ page_title }}</span>
{% if logged_on -%}
<ul class="navbar-nav">
{{ "admin" | nav_link: "Dashboard" }}
{{ "pages" | nav_link: "Pages" }}
{{ "posts" | nav_link: "Posts" }}
{{ "categories" | nav_link: "Categories" }}
</ul>
{%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end">
{% if logged_on -%}
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>
{{ "user/log-off" | nav_link: "Log Off" }}
{%- else -%}
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>
{{ "user/log-on" | nav_link: "Log On" }}
{%- endif %}
</ul>
</div>
</div>
</nav>
</header>
<main>
<main class="mx-3">
{{ content }}
</main>
<footer>

View File

@@ -1,4 +1,4 @@
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2>
<h2 class="py-3">Log On to {{ web_log.name }}</h2>
<article class="pb-3">
<form action="/user/log-on" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">

View File

@@ -0,0 +1,55 @@
<h2 class="py-3">{{ page_title }}</h2>
<article>
<form action="/page/save" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type="hidden" name="pageId" value="{{ model.page_id }}">
<div class="container">
<div class="row mb-3">
<div class="col">
<div class="form-floating">
<input type="text" name="title" id="title" class="form-control" autofocus required
value="{{ model.title }}">
<label for="title">Title</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-9">
<div class="form-floating">
<input type="text" name="permalink" id="permalink" class="form-control" required
value="{{ model.permalink }}">
<label for="permalink">Permalink</label>
</div>
</div>
<div class="col-3 align-self-center">
<div class="form-check form-switch">
<input type="checkbox" name="isShownInPageList" id="showList" class="form-check-input" value="true"
{%- if model.is_shown_in_page_list %} checked="checked"{% endif %}>
<label for="showList" class="form-check-label">Show in Page List</label>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col">
<label for="text">Text</label> &nbsp; &nbsp;
<input type="radio" name="source" id="source_html" class="btn-check" value="HTML"
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
<input type="radio" name="source" id="source_md" class="btn-check" value="Markdown"
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div>
</div>
<div class="row mb-3">
<div class="col">
<textarea name="Text" id="text" class="form-control" rows="10">{{ model.text }}</textarea>
</div>
</div>
<div class="row mb-3">
<div class="col">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</article>

View File

@@ -0,0 +1,27 @@
<h2 class="py-3">{{ page_title }}</h2>
<article class="container">
<a href="/page/new/edit" class="btn btn-primary btn-sm my-3">Create a New Page</a>
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th scope="col">Actions</th>
<th scope="col">Title</th>
<th scope="col">In List?</th>
<th scope="col">Last Updated</th>
</tr>
</thead>
<tbody>
{% for pg in pages -%}
<tr>
<td><a href="/page/{{ pg.id }}/edit">Edit</a></td>
<td>
{{ pg.title }}
{%- if pg.is_default %} &nbsp; <span class="badge bg-success">HOME PAGE</span>{% endif -%}
</td>
<td>{% if pg.show_in_page_list %} Yes {% else %} No {% endif %}</td>
<td>{{ pg.updated_on | date: "MMMM d, yyyy" }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</article>

View File

@@ -1,4 +1,5 @@
<article class="pt-3">
<h2 class="py-3">{{ web_log.name }} Settings</h2>
<article>
<form action="/admin/settings" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">

View File

@@ -20,21 +20,21 @@
</button>
<div class="collapse navbar-collapse" id="navbarText">
{% if web_log.subtitle -%}
<span class="navbar-text">{{ web_log.subtitle | escape }}</span>
<span class="navbar-text">{{ web_log.subtitle.value | escape }}</span>
{%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end">
{% if logged_on %}
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>
{% endif %}
</ul>
{% if page_list -%}
<ul class="navbar-nav">
{% for pg in page_list -%}
{{ pg.permalink | nav_link: pg.title }}
{%- endfor %}
</ul>
{%- endif %}
{% user_links %}
</div>
</div>
</nav>
</header>
<main>
<main class="mx-3">
{{ content }}
</main>
<footer>

View File

@@ -1,4 +1,4 @@
<h2>{{ page.title }}</h2>
<h2 class="py-3">{{ page.title }}</h2>
<article>
{{ page.text }}
</article>