V2 #1
@ -352,6 +352,14 @@ module Page =
|
||||
/// Functions to manipulate posts
|
||||
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
|
||||
let countByStatus (status : PostStatus) (webLogId : WebLogId) =
|
||||
rethink<int> {
|
||||
@ -372,6 +380,15 @@ module Post =
|
||||
result; withRetryDefault
|
||||
}
|
||||
|> 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
|
||||
let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) =
|
||||
@ -409,6 +426,15 @@ module Post =
|
||||
limit (postsPerPage + 1)
|
||||
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
|
||||
|
@ -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
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LogOnModel =
|
||||
@ -173,6 +221,9 @@ type PostListItem =
|
||||
/// The ID of the user who authored the post
|
||||
authorId : string
|
||||
|
||||
/// The name of the user who authored the post
|
||||
authorName : string
|
||||
|
||||
/// The status of the post
|
||||
status : string
|
||||
|
||||
@ -202,6 +253,7 @@ type PostListItem =
|
||||
static member fromPost (post : Post) =
|
||||
{ id = PostId.toString post.id
|
||||
authorId = WebLogUserId.toString post.authorId
|
||||
authorName = ""
|
||||
status = PostStatus.toString post.status
|
||||
title = post.title
|
||||
permalink = Permalink.toString post.permalink
|
||||
@ -221,8 +273,8 @@ type PostDisplay =
|
||||
/// Category ID -> name lookup
|
||||
categories : IDictionary<string, string>
|
||||
|
||||
/// Author ID -> name lookup
|
||||
authors : IDictionary<string, string>
|
||||
/// A subtitle for the page
|
||||
subtitle : string option
|
||||
|
||||
/// Whether there are newer posts than the ones in this model
|
||||
hasNewer : bool
|
||||
|
@ -246,7 +246,7 @@ module Admin =
|
||||
let updated =
|
||||
{ webLog with
|
||||
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
|
||||
postsPerPage = model.postsPerPage
|
||||
timeZone = model.timeZone
|
||||
@ -315,8 +315,8 @@ module Category =
|
||||
{ cat with
|
||||
name = model.name
|
||||
slug = model.slug
|
||||
description = match model.description with "" -> None | it -> Some it
|
||||
parentId = match model.parentId with "" -> None | it -> Some (CategoryId it)
|
||||
description = if model.description = "" then None else Some model.description
|
||||
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! addMessage ctx { UserMessage.success with message = "Category saved successfully" }
|
||||
@ -383,10 +383,10 @@ module Page =
|
||||
| "new" ->
|
||||
return Some
|
||||
{ Page.empty with
|
||||
id = PageId.create ()
|
||||
webLogId = webLogId
|
||||
authorId = userId ctx
|
||||
publishedOn = now
|
||||
id = PageId.create ()
|
||||
webLogId = webLogId
|
||||
authorId = userId ctx
|
||||
publishedOn = now
|
||||
}
|
||||
| pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn
|
||||
}
|
||||
@ -421,16 +421,41 @@ module Page =
|
||||
/// Handlers to manipulate posts
|
||||
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}
|
||||
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.get ctx
|
||||
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
|
||||
let hash = Hash.FromAnonymousObject {| posts = posts |}
|
||||
let conn = conn ctx
|
||||
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn
|
||||
let! hash = preparePostList webLog posts pageNbr webLog.postsPerPage conn
|
||||
let title =
|
||||
match pageNbr, webLog.defaultPage with
|
||||
| 1, "posts" -> None
|
||||
| _, "posts" -> Some $"Page {pageNbr}"
|
||||
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
|
||||
return! themedView "index" next ctx hash
|
||||
}
|
||||
@ -482,39 +507,86 @@ module Post =
|
||||
// GET /posts
|
||||
// GET /posts/page/{pageNbr}
|
||||
let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLog = WebLogCache.get ctx
|
||||
let conn = conn ctx
|
||||
let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn
|
||||
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 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
|
||||
let webLog = WebLogCache.get ctx
|
||||
let conn = conn ctx
|
||||
let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn
|
||||
let! hash = preparePostList webLog posts pageNbr 25 conn
|
||||
hash.Add ("page_title", "Posts")
|
||||
return! viewForTheme "admin" "post-list" next ctx hash
|
||||
}
|
||||
|
||||
// GET /post/{id}/edit
|
||||
let edit _ : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
// TODO: write handler
|
||||
return! Error.notFound next ctx
|
||||
let edit postId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLogId = webLogId 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 {
|
||||
// TODO: write handler
|
||||
return! Error.notFound next ctx
|
||||
let! model = ctx.BindFormAsync<EditPostModel> ()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
@ -194,23 +194,16 @@ let main args =
|
||||
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<DisplayCategory>, all)
|
||||
Template.RegisterSafeType (typeof<DisplayPage>, all)
|
||||
Template.RegisterSafeType (typeof<EditCategoryModel>, all)
|
||||
Template.RegisterSafeType (typeof<EditPageModel>, all)
|
||||
Template.RegisterSafeType (typeof<PostDisplay>, all)
|
||||
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)
|
||||
[ // Domain types
|
||||
typeof<Page>; typeof<WebLog>
|
||||
// View models
|
||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayPage>; typeof<EditCategoryModel>
|
||||
typeof<EditPageModel>; typeof<EditPostModel>; typeof<PostDisplay>; typeof<PostListItem>
|
||||
typeof<SettingsModel>; typeof<UserMessage>
|
||||
// Framework types
|
||||
typeof<AntiforgeryTokenSet>; typeof<string option>; typeof<KeyValuePair>
|
||||
]
|
||||
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
|
||||
|
||||
let app = builder.Build ()
|
||||
|
||||
|
67
src/MyWebLog/themes/admin/post-edit.liquid
Normal file
67
src/MyWebLog/themes/admin/post-edit.liquid
Normal 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>
|
||||
<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 %} ⟩ {% 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>
|
@ -31,9 +31,9 @@
|
||||
<a href="#" class="text-danger">Delete</a>
|
||||
</small>
|
||||
</td>
|
||||
<td>{{ model.authors[post.author_id] }}</td>
|
||||
<td>{{ post.author_name }}</td>
|
||||
<td>{{ post.status }}</td>
|
||||
<td>{{ tags[post.id] }}</td>
|
||||
<td>{{ post.tags | join: ", " }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
|
23
src/MyWebLog/themes/default/index.liquid
Normal file
23
src/MyWebLog/themes/default/index.liquid
Normal 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 "{{ post.title | escape }}"">
|
||||
{{ 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>
|
@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/themes/{{ web_log.theme_path }}/style.css">
|
||||
<title>{{ page_title | escape }} « {{ web_log.name | escape }}</title>
|
||||
<title>{{ page_title | escape }}{% if page_title %} « {% endif %}{{ web_log.name | escape }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
@ -11,3 +11,12 @@
|
||||
width: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
fieldset {
|
||||
border: solid 1px lightgray;
|
||||
border-radius: .5rem;
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
legend {
|
||||
float: unset;
|
||||
width: unset;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user