diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 1d9fdc8..d7d2746 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -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 { @@ -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 { + 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 diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index be8d861..f318ad8 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -153,6 +153,54 @@ type EditPageModel = } +/// View model to edit a post +[] +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 [] 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 - /// Author ID -> name lookup - authors : IDictionary + /// A subtitle for the page + subtitle : string option /// Whether there are newer posts than the ones in this model hasNewer : bool diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs index 06e4a8d..80a9b57 100644 --- a/src/MyWebLog/Handlers.fs +++ b/src/MyWebLog/Handlers.fs @@ -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 () + 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 } diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 15fe9e2..2a46f4f 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -194,23 +194,16 @@ let main args = Template.RegisterFilter typeof Template.RegisterTag "user_links" - let all = [| "*" |] - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) - Template.RegisterSafeType (typeof, all) + [ // Domain types + typeof; typeof + // View models + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof + // Framework types + typeof; typeof; typeof + ] + |> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |])) let app = builder.Build () diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid new file mode 100644 index 0000000..9e9aca0 --- /dev/null +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -0,0 +1,67 @@ +

{{ page_title }}

+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+     + + + + +
+
+ +
+
+ + +
comma-delimited
+
+
+
+
+ Categories + {% for cat in categories %} +
+ + +
+ {% endfor %} +
+
+
+
+
+ {% if model.status == "Draft" %} +
+ + +
+ {% endif %} + +
+
+
+
+
diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index 2a51cbf..b3056b9 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -31,9 +31,9 @@ Delete - {{ model.authors[post.author_id] }} + {{ post.author_name }} {{ post.status }} - {{ tags[post.id] }} + {{ post.tags | join: ", " }} {%- endfor %} diff --git a/src/MyWebLog/themes/default/index.liquid b/src/MyWebLog/themes/default/index.liquid new file mode 100644 index 0000000..c3c2fa1 --- /dev/null +++ b/src/MyWebLog/themes/default/index.liquid @@ -0,0 +1,23 @@ +{% if model.subtitle %} +

{{ model.subtitle.value }}

+{% endif %} +
+ {% for post in model.posts %} +
+
+
+

+ + {{ post.title }} + +

+

+ Published on {{ post.published_on | date: "MMMM d, yyyy" }} + at {{ post.published_on | date: "h:mmtt" | downcase }} +

+ {{ post.text }} +
+
+
+ {% endfor %} +
\ No newline at end of file diff --git a/src/MyWebLog/themes/default/layout.liquid b/src/MyWebLog/themes/default/layout.liquid index 6451cdd..bbdb5d1 100644 --- a/src/MyWebLog/themes/default/layout.liquid +++ b/src/MyWebLog/themes/default/layout.liquid @@ -7,7 +7,7 @@ - {{ page_title | escape }} « {{ web_log.name | escape }} + {{ page_title | escape }}{% if page_title %} « {% endif %}{{ web_log.name | escape }}
diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.css b/src/MyWebLog/wwwroot/themes/admin/admin.css index c0dad3e..bcaf068 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.css +++ b/src/MyWebLog/wwwroot/themes/admin/admin.css @@ -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; +}