Add post edit / index view

This commit is contained in:
Daniel J. Summers 2022-04-23 23:35:18 -04:00
parent a58cc25bbb
commit d0e016fd28
9 changed files with 301 additions and 59 deletions

View File

@ -352,6 +352,14 @@ module Page =
/// Functions to manipulate posts /// Functions to manipulate posts
module Post = module Post =
/// Add a post
let add (post : Post) =
rethink {
withTable Table.Post
insert post
write; withRetryDefault; ignoreResult
}
/// Count posts for a web log by their status /// Count posts for a web log by their status
let countByStatus (status : PostStatus) (webLogId : WebLogId) = let countByStatus (status : PostStatus) (webLogId : WebLogId) =
rethink<int> { rethink<int> {
@ -373,6 +381,15 @@ module Post =
} }
|> tryFirst |> tryFirst
/// Find a post by its ID, including all revisions and prior permalinks
let findByFullId (postId : PostId) webLogId =
rethink<Post> {
withTable Table.Post
get postId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun p -> p.webLogId)
/// Find the current permalink for a post by a prior permalink /// Find the current permalink for a post by a prior permalink
let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) = let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Permalink list> { rethink<Permalink list> {
@ -410,6 +427,15 @@ module Post =
result; withRetryDefault result; withRetryDefault
} }
/// Update a post (all fields are updated)
let update (post : Post) =
rethink {
withTable Table.Post
get post.id
replace post
write; withRetryDefault; ignoreResult
}
/// Functions to manipulate web logs /// Functions to manipulate web logs
module WebLog = module WebLog =

View File

@ -153,6 +153,54 @@ type EditPageModel =
} }
/// View model to edit a post
[<CLIMutable; NoComparison; NoEquality>]
type EditPostModel =
{ /// The ID of the post being edited
postId : string
/// The title of the post
title : string
/// The permalink for the post
permalink : string
/// The source format for the text
source : string
/// The text of the post
text : string
/// The tags for the post
tags : string
/// The category IDs for the post
categoryIds : string[]
/// The post status
status : string
/// Whether this post should be published
doPublish : bool
}
/// Create an edit model from an existing past
static member fromPost (post : Post) =
let latest =
match post.revisions |> List.sortByDescending (fun r -> r.asOf) |> List.tryHead with
| Some rev -> rev
| None -> Revision.empty
{ postId = PostId.toString post.id
title = post.title
permalink = Permalink.toString post.permalink
source = MarkupText.sourceType latest.text
text = MarkupText.text latest.text
tags = String.Join (", ", post.tags)
categoryIds = post.categoryIds |> List.map CategoryId.toString |> Array.ofList
status = PostStatus.toString post.status
doPublish = false
}
/// The model to use to allow a user to log on /// The model to use to allow a user to log on
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type LogOnModel = type LogOnModel =
@ -173,6 +221,9 @@ type PostListItem =
/// The ID of the user who authored the post /// The ID of the user who authored the post
authorId : string authorId : string
/// The name of the user who authored the post
authorName : string
/// The status of the post /// The status of the post
status : string status : string
@ -202,6 +253,7 @@ type PostListItem =
static member fromPost (post : Post) = static member fromPost (post : Post) =
{ id = PostId.toString post.id { id = PostId.toString post.id
authorId = WebLogUserId.toString post.authorId authorId = WebLogUserId.toString post.authorId
authorName = ""
status = PostStatus.toString post.status status = PostStatus.toString post.status
title = post.title title = post.title
permalink = Permalink.toString post.permalink permalink = Permalink.toString post.permalink
@ -221,8 +273,8 @@ type PostDisplay =
/// Category ID -> name lookup /// Category ID -> name lookup
categories : IDictionary<string, string> categories : IDictionary<string, string>
/// Author ID -> name lookup /// A subtitle for the page
authors : IDictionary<string, string> subtitle : string option
/// Whether there are newer posts than the ones in this model /// Whether there are newer posts than the ones in this model
hasNewer : bool hasNewer : bool

View File

@ -246,7 +246,7 @@ module Admin =
let updated = let updated =
{ webLog with { webLog with
name = model.name name = model.name
subtitle = match model.subtitle with "" -> None | it -> Some it subtitle = if model.subtitle = "" then None else Some model.subtitle
defaultPage = model.defaultPage defaultPage = model.defaultPage
postsPerPage = model.postsPerPage postsPerPage = model.postsPerPage
timeZone = model.timeZone timeZone = model.timeZone
@ -315,8 +315,8 @@ module Category =
{ cat with { cat with
name = model.name name = model.name
slug = model.slug slug = model.slug
description = match model.description with "" -> None | it -> Some it description = if model.description = "" then None else Some model.description
parentId = match model.parentId with "" -> None | it -> Some (CategoryId it) parentId = if model.parentId = "" then None else Some (CategoryId model.parentId)
} }
do! (match model.categoryId with "new" -> Data.Category.add | _ -> Data.Category.update) cat conn do! (match model.categoryId with "new" -> Data.Category.add | _ -> Data.Category.update) cat conn
do! addMessage ctx { UserMessage.success with message = "Category saved successfully" } do! addMessage ctx { UserMessage.success with message = "Category saved successfully" }
@ -383,10 +383,10 @@ module Page =
| "new" -> | "new" ->
return Some return Some
{ Page.empty with { Page.empty with
id = PageId.create () id = PageId.create ()
webLogId = webLogId webLogId = webLogId
authorId = userId ctx authorId = userId ctx
publishedOn = now publishedOn = now
} }
| pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn | pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn
} }
@ -421,16 +421,41 @@ module Page =
/// Handlers to manipulate posts /// Handlers to manipulate posts
module Post = module Post =
/// Convert a list of posts into items ready to be displayed
let private preparePostList (webLog : WebLog) (posts : Post list) pageNbr perPage conn = task {
let! authors =
Data.WebLogUser.findNames (posts |> List.map (fun p -> p.authorId) |> List.distinct) webLog.id conn
let! cats =
Data.Category.findNames (posts |> List.map (fun c -> c.categoryIds) |> List.concat |> List.distinct)
webLog.id conn
let postItems =
posts
|> Seq.ofList
|> Seq.truncate perPage
|> Seq.map PostListItem.fromPost
|> Seq.map (fun pi -> { pi with authorName = authors[pi.authorId] })
|> Array.ofSeq
let model =
{ posts = postItems
categories = cats
subtitle = None
hasNewer = pageNbr <> 1
hasOlder = posts |> List.length > perPage
}
return Hash.FromAnonymousObject {| model = model |}
}
// GET /page/{pageNbr} // GET /page/{pageNbr}
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task { let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.get ctx let webLog = WebLogCache.get ctx
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx) let conn = conn ctx
let hash = Hash.FromAnonymousObject {| posts = posts |} let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn
let! hash = preparePostList webLog posts pageNbr webLog.postsPerPage conn
let title = let title =
match pageNbr, webLog.defaultPage with match pageNbr, webLog.defaultPage with
| 1, "posts" -> None | 1, "posts" -> None
| _, "posts" -> Some $"Page {pageNbr}" | _, "posts" -> Some $"Page {pageNbr}"
| _, _ -> Some $"Page {pageNbr} &#xab; Posts" | _, _ -> Some $"Page {pageNbr} &laquo; Posts"
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
return! themedView "index" next ctx hash return! themedView "index" next ctx hash
} }
@ -482,39 +507,86 @@ module Post =
// GET /posts // GET /posts
// GET /posts/page/{pageNbr} // GET /posts/page/{pageNbr}
let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.get ctx let webLog = WebLogCache.get ctx
let conn = conn ctx let conn = conn ctx
let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn
let! authors = let! hash = preparePostList webLog posts pageNbr 25 conn
Data.WebLogUser.findNames (posts |> List.map (fun p -> p.authorId) |> List.distinct) webLog.id conn hash.Add ("page_title", "Posts")
let! cats = return! viewForTheme "admin" "post-list" next ctx hash
Data.Category.findNames (posts |> List.map (fun c -> c.categoryIds) |> List.concat |> List.distinct)
webLog.id conn
let tags = posts
|> List.map (fun p -> PostId.toString p.id, p.tags |> List.fold (fun t tag -> $"{t}, {tag}") "")
|> dict
let model =
{ posts = posts |> Seq.ofList |> Seq.truncate 25 |> Seq.map PostListItem.fromPost |> Array.ofSeq
authors = authors
categories = cats
hasNewer = pageNbr <> 1
hasOlder = posts |> List.length > webLog.postsPerPage
}
return!
Hash.FromAnonymousObject {| model = model; tags = tags; page_title = "Posts" |}
|> viewForTheme "admin" "post-list" next ctx
} }
// GET /post/{id}/edit // GET /post/{id}/edit
let edit _ : HttpHandler = requireUser >=> fun next ctx -> task { let edit postId : HttpHandler = requireUser >=> fun next ctx -> task {
// TODO: write handler let webLogId = webLogId ctx
return! Error.notFound next ctx let conn = conn ctx
let! result = task {
match postId with
| "new" -> return Some ("Write a New Post", { Post.empty with id = PostId "new" })
| _ ->
match! Data.Post.findByFullId (PostId postId) webLogId conn with
| Some post -> return Some ("Edit Post", post)
| None -> return None
}
match result with
| Some (title, post) ->
let! cats = Data.Category.findAllForView webLogId conn
return!
Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditPostModel.fromPost post
page_title = title
categories = cats
|}
|> viewForTheme "admin" "post-edit" next ctx
| None -> return! Error.notFound next ctx
} }
// POST /post/{id}/edit // POST /post/save
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
// TODO: write handler let! model = ctx.BindFormAsync<EditPostModel> ()
return! Error.notFound next ctx let webLogId = webLogId ctx
let conn = conn ctx
let now = DateTime.UtcNow
let! pst = task {
match model.postId with
| "new" ->
return Some
{ Post.empty with
id = PostId.create ()
webLogId = webLogId
authorId = userId ctx
}
| postId -> return! Data.Post.findByFullId (PostId postId) webLogId conn
}
match pst with
| Some post ->
let revision = { asOf = now; text = MarkupText.parse $"{model.source}: {model.text}" }
// Detect a permalink change, and add the prior one to the prior list
let page =
match Permalink.toString post.permalink with
| "" -> post
| link when link = model.permalink -> post
| _ -> { post with priorPermalinks = post.permalink :: post.priorPermalinks }
let post =
{ post with
title = model.title
permalink = Permalink model.permalink
publishedOn = if model.doPublish then Some now else post.publishedOn
updatedOn = now
text = MarkupText.toHtml revision.text
tags = model.tags.Split ","
|> Seq.ofArray
|> Seq.map (fun it -> it.Trim().ToLower ())
|> Seq.sort
|> List.ofSeq
categoryIds = model.categoryIds |> Array.map CategoryId |> List.ofArray
status = if model.doPublish then Published else post.status
revisions = revision :: page.revisions
}
do! (match model.postId with "new" -> Data.Post.add | _ -> Data.Post.update) post conn
do! addMessage ctx { UserMessage.success with message = "Post saved successfully" }
return! redirectToGet $"/post/{PostId.toString post.id}/edit" next ctx
| None -> return! Error.notFound next ctx
} }

View File

@ -194,23 +194,16 @@ let main args =
Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter> Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter>
Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links" Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links"
let all = [| "*" |] [ // Domain types
Template.RegisterSafeType (typeof<Page>, all) typeof<Page>; typeof<WebLog>
Template.RegisterSafeType (typeof<WebLog>, all) // View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayPage>; typeof<EditCategoryModel>
Template.RegisterSafeType (typeof<DashboardModel>, all) typeof<EditPageModel>; typeof<EditPostModel>; typeof<PostDisplay>; typeof<PostListItem>
Template.RegisterSafeType (typeof<DisplayCategory>, all) typeof<SettingsModel>; typeof<UserMessage>
Template.RegisterSafeType (typeof<DisplayPage>, all) // Framework types
Template.RegisterSafeType (typeof<EditCategoryModel>, all) typeof<AntiforgeryTokenSet>; typeof<string option>; typeof<KeyValuePair>
Template.RegisterSafeType (typeof<EditPageModel>, all) ]
Template.RegisterSafeType (typeof<PostDisplay>, all) |> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
Template.RegisterSafeType (typeof<PostListItem>, all)
Template.RegisterSafeType (typeof<SettingsModel>, all)
Template.RegisterSafeType (typeof<UserMessage>, all)
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
Template.RegisterSafeType (typeof<string option>, all)
Template.RegisterSafeType (typeof<KeyValuePair>, all)
let app = builder.Build () let app = builder.Build ()

View File

@ -0,0 +1,67 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<form action="/post/save" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type="hidden" name="postId" value="{{ model.post_id }}">
<div class="container">
<div class="row mb-3">
<div class="col-12 col-lg-9">
<div class="form-floating pb-3">
<input type="text" name="title" id="title" class="form-control" placeholder="Title" autofocus required
value="{{ model.title }}">
<label for="title">Title</label>
</div>
<div class="form-floating pb-3">
<input type="text" name="permalink" id="permalink" class="form-control" placeholder="Permalink" required
value="{{ model.permalink }}">
<label for="permalink">Permalink</label>
</div>
<div class="mb-2">
<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 class="pb-3">
<textarea name="text" id="text" class="form-control" rows="10">{{ model.text }}</textarea>
</div>
<div class="form-floating pb-3">
<input type="text" name="tags" id="tags" class="form-control" placeholder="Tags" required
value="{{ model.tags }}">
<label for="tags">Tags</label>
<div class="form-text">comma-delimited</div>
</div>
</div>
<div class="col-12 col-lg-3">
<fieldset>
<legend>Categories</legend>
{% for cat in categories %}
<div class="form-check">
<input type="checkbox" name="categoryIds" id="categoryId_{{ cat.id }}" class="form-check-input"
value="{{ cat.id }}" {% if model.category_ids contains cat.id %} checked="checked"{% endif %}>
<label for="categoryId_{{ cat.id }}" class="form-check-label"
{%- if cat.description %} title="{{ cat.description.value | escape }}"{% endif %}>
{%- for it in cat.parent_names %}&nbsp; &rang; &nbsp;{% endfor %}{{ cat.name }}
</label>
</div>
{% endfor %}
</fieldset>
</div>
</div>
<div class="row mb-3">
<div class="col">
{% if model.status == "Draft" %}
<div class="form-check pb-2">
<input type="checkbox" name="doPublish" id="doPublish" class="form-check-input" value="true">
<label for="doPublish" class="form-check-label">Publish This Post</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</article>

View File

@ -31,9 +31,9 @@
<a href="#" class="text-danger">Delete</a> <a href="#" class="text-danger">Delete</a>
</small> </small>
</td> </td>
<td>{{ model.authors[post.author_id] }}</td> <td>{{ post.author_name }}</td>
<td>{{ post.status }}</td> <td>{{ post.status }}</td>
<td>{{ tags[post.id] }}</td> <td>{{ post.tags | join: ", " }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

View File

@ -0,0 +1,23 @@
{% if model.subtitle %}
<h2>{{ model.subtitle.value }}</h2>
{% endif %}
<section class="container" aria-label="The posts for the page">
{% for post in model.posts %}
<div class="row">
<div class="col">
<article>
<h1>
<a href="/{{ post.permalink }}" title="Permanent link to &quot;{{ post.title | escape }}&quot;">
{{ post.title }}
</a>
</h1>
<p>
Published on {{ post.published_on | date: "MMMM d, yyyy" }}
at {{ post.published_on | date: "h:mmtt" | downcase }}
</p>
{{ post.text }}
</article>
</div>
</div>
{% endfor %}
</section>

View File

@ -7,7 +7,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" href="/themes/{{ web_log.theme_path }}/style.css"> <link rel="stylesheet" href="/themes/{{ web_log.theme_path }}/style.css">
<title>{{ page_title | escape }} &laquo; {{ web_log.name | escape }}</title> <title>{{ page_title | escape }}{% if page_title %} &laquo; {% endif %}{{ web_log.name | escape }}</title>
</head> </head>
<body> <body>
<header> <header>

View File

@ -11,3 +11,12 @@
width: 1rem; width: 1rem;
white-space: nowrap; white-space: nowrap;
} }
fieldset {
border: solid 1px lightgray;
border-radius: .5rem;
padding: 0 1rem 1rem;
}
legend {
float: unset;
width: unset;
}