From a67892a08222e69b9f4ede32ef4b01538fb581da Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 18 Oct 2021 22:28:39 -0400 Subject: [PATCH 001/102] Move old server project --- src/{MyWebLog => MyWebLog.Old}/MyWebLog.xproj | 0 src/{MyWebLog => MyWebLog.Old}/Program.cs | 0 .../Properties/AssemblyInfo.cs | 0 src/{MyWebLog => MyWebLog.Old}/config.json | 0 .../content/logo-dark.png | Bin .../content/logo-light.png | Bin src/{MyWebLog => MyWebLog.Old}/project.json | 0 .../views/admin/admin-layout.html | 0 .../views/admin/category/edit.html | 0 .../views/admin/category/list.html | 0 .../views/admin/content/admin.css | 0 .../views/admin/content/tinymce-init.js | 0 .../views/admin/dashboard.html | 0 .../views/admin/page/edit.html | 0 .../views/admin/page/list.html | 0 .../views/admin/post/edit.html | 0 .../views/admin/post/list.html | 0 .../views/admin/user/log-on.html | 0 .../views/themes/default/comment.html | 0 .../themes/default/content/bootstrap-theme.css | 0 .../themes/default/content/bootstrap-theme.css.map | 0 .../themes/default/content/bootstrap-theme.min.css | 0 .../views/themes/default/footer.html | 0 .../views/themes/default/index-content.html | 0 .../views/themes/default/index.html | 0 .../views/themes/default/layout.html | 0 .../views/themes/default/page-content.html | 0 .../views/themes/default/page.html | 0 .../views/themes/default/single-content.html | 0 .../views/themes/default/single.html | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename src/{MyWebLog => MyWebLog.Old}/MyWebLog.xproj (100%) rename src/{MyWebLog => MyWebLog.Old}/Program.cs (100%) rename src/{MyWebLog => MyWebLog.Old}/Properties/AssemblyInfo.cs (100%) rename src/{MyWebLog => MyWebLog.Old}/config.json (100%) rename src/{MyWebLog => MyWebLog.Old}/content/logo-dark.png (100%) rename src/{MyWebLog => MyWebLog.Old}/content/logo-light.png (100%) rename src/{MyWebLog => MyWebLog.Old}/project.json (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/admin-layout.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/category/edit.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/category/list.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/content/admin.css (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/content/tinymce-init.js (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/dashboard.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/page/edit.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/page/list.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/post/edit.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/post/list.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/admin/user/log-on.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/comment.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/content/bootstrap-theme.css (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/content/bootstrap-theme.css.map (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/content/bootstrap-theme.min.css (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/footer.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/index-content.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/index.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/layout.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/page-content.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/page.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/single-content.html (100%) rename src/{MyWebLog => MyWebLog.Old}/views/themes/default/single.html (100%) diff --git a/src/MyWebLog/MyWebLog.xproj b/src/MyWebLog.Old/MyWebLog.xproj similarity index 100% rename from src/MyWebLog/MyWebLog.xproj rename to src/MyWebLog.Old/MyWebLog.xproj diff --git a/src/MyWebLog/Program.cs b/src/MyWebLog.Old/Program.cs similarity index 100% rename from src/MyWebLog/Program.cs rename to src/MyWebLog.Old/Program.cs diff --git a/src/MyWebLog/Properties/AssemblyInfo.cs b/src/MyWebLog.Old/Properties/AssemblyInfo.cs similarity index 100% rename from src/MyWebLog/Properties/AssemblyInfo.cs rename to src/MyWebLog.Old/Properties/AssemblyInfo.cs diff --git a/src/MyWebLog/config.json b/src/MyWebLog.Old/config.json similarity index 100% rename from src/MyWebLog/config.json rename to src/MyWebLog.Old/config.json diff --git a/src/MyWebLog/content/logo-dark.png b/src/MyWebLog.Old/content/logo-dark.png similarity index 100% rename from src/MyWebLog/content/logo-dark.png rename to src/MyWebLog.Old/content/logo-dark.png diff --git a/src/MyWebLog/content/logo-light.png b/src/MyWebLog.Old/content/logo-light.png similarity index 100% rename from src/MyWebLog/content/logo-light.png rename to src/MyWebLog.Old/content/logo-light.png diff --git a/src/MyWebLog/project.json b/src/MyWebLog.Old/project.json similarity index 100% rename from src/MyWebLog/project.json rename to src/MyWebLog.Old/project.json diff --git a/src/MyWebLog/views/admin/admin-layout.html b/src/MyWebLog.Old/views/admin/admin-layout.html similarity index 100% rename from src/MyWebLog/views/admin/admin-layout.html rename to src/MyWebLog.Old/views/admin/admin-layout.html diff --git a/src/MyWebLog/views/admin/category/edit.html b/src/MyWebLog.Old/views/admin/category/edit.html similarity index 100% rename from src/MyWebLog/views/admin/category/edit.html rename to src/MyWebLog.Old/views/admin/category/edit.html diff --git a/src/MyWebLog/views/admin/category/list.html b/src/MyWebLog.Old/views/admin/category/list.html similarity index 100% rename from src/MyWebLog/views/admin/category/list.html rename to src/MyWebLog.Old/views/admin/category/list.html diff --git a/src/MyWebLog/views/admin/content/admin.css b/src/MyWebLog.Old/views/admin/content/admin.css similarity index 100% rename from src/MyWebLog/views/admin/content/admin.css rename to src/MyWebLog.Old/views/admin/content/admin.css diff --git a/src/MyWebLog/views/admin/content/tinymce-init.js b/src/MyWebLog.Old/views/admin/content/tinymce-init.js similarity index 100% rename from src/MyWebLog/views/admin/content/tinymce-init.js rename to src/MyWebLog.Old/views/admin/content/tinymce-init.js diff --git a/src/MyWebLog/views/admin/dashboard.html b/src/MyWebLog.Old/views/admin/dashboard.html similarity index 100% rename from src/MyWebLog/views/admin/dashboard.html rename to src/MyWebLog.Old/views/admin/dashboard.html diff --git a/src/MyWebLog/views/admin/page/edit.html b/src/MyWebLog.Old/views/admin/page/edit.html similarity index 100% rename from src/MyWebLog/views/admin/page/edit.html rename to src/MyWebLog.Old/views/admin/page/edit.html diff --git a/src/MyWebLog/views/admin/page/list.html b/src/MyWebLog.Old/views/admin/page/list.html similarity index 100% rename from src/MyWebLog/views/admin/page/list.html rename to src/MyWebLog.Old/views/admin/page/list.html diff --git a/src/MyWebLog/views/admin/post/edit.html b/src/MyWebLog.Old/views/admin/post/edit.html similarity index 100% rename from src/MyWebLog/views/admin/post/edit.html rename to src/MyWebLog.Old/views/admin/post/edit.html diff --git a/src/MyWebLog/views/admin/post/list.html b/src/MyWebLog.Old/views/admin/post/list.html similarity index 100% rename from src/MyWebLog/views/admin/post/list.html rename to src/MyWebLog.Old/views/admin/post/list.html diff --git a/src/MyWebLog/views/admin/user/log-on.html b/src/MyWebLog.Old/views/admin/user/log-on.html similarity index 100% rename from src/MyWebLog/views/admin/user/log-on.html rename to src/MyWebLog.Old/views/admin/user/log-on.html diff --git a/src/MyWebLog/views/themes/default/comment.html b/src/MyWebLog.Old/views/themes/default/comment.html similarity index 100% rename from src/MyWebLog/views/themes/default/comment.html rename to src/MyWebLog.Old/views/themes/default/comment.html diff --git a/src/MyWebLog/views/themes/default/content/bootstrap-theme.css b/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css similarity index 100% rename from src/MyWebLog/views/themes/default/content/bootstrap-theme.css rename to src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css diff --git a/src/MyWebLog/views/themes/default/content/bootstrap-theme.css.map b/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css.map similarity index 100% rename from src/MyWebLog/views/themes/default/content/bootstrap-theme.css.map rename to src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css.map diff --git a/src/MyWebLog/views/themes/default/content/bootstrap-theme.min.css b/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.min.css similarity index 100% rename from src/MyWebLog/views/themes/default/content/bootstrap-theme.min.css rename to src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.min.css diff --git a/src/MyWebLog/views/themes/default/footer.html b/src/MyWebLog.Old/views/themes/default/footer.html similarity index 100% rename from src/MyWebLog/views/themes/default/footer.html rename to src/MyWebLog.Old/views/themes/default/footer.html diff --git a/src/MyWebLog/views/themes/default/index-content.html b/src/MyWebLog.Old/views/themes/default/index-content.html similarity index 100% rename from src/MyWebLog/views/themes/default/index-content.html rename to src/MyWebLog.Old/views/themes/default/index-content.html diff --git a/src/MyWebLog/views/themes/default/index.html b/src/MyWebLog.Old/views/themes/default/index.html similarity index 100% rename from src/MyWebLog/views/themes/default/index.html rename to src/MyWebLog.Old/views/themes/default/index.html diff --git a/src/MyWebLog/views/themes/default/layout.html b/src/MyWebLog.Old/views/themes/default/layout.html similarity index 100% rename from src/MyWebLog/views/themes/default/layout.html rename to src/MyWebLog.Old/views/themes/default/layout.html diff --git a/src/MyWebLog/views/themes/default/page-content.html b/src/MyWebLog.Old/views/themes/default/page-content.html similarity index 100% rename from src/MyWebLog/views/themes/default/page-content.html rename to src/MyWebLog.Old/views/themes/default/page-content.html diff --git a/src/MyWebLog/views/themes/default/page.html b/src/MyWebLog.Old/views/themes/default/page.html similarity index 100% rename from src/MyWebLog/views/themes/default/page.html rename to src/MyWebLog.Old/views/themes/default/page.html diff --git a/src/MyWebLog/views/themes/default/single-content.html b/src/MyWebLog.Old/views/themes/default/single-content.html similarity index 100% rename from src/MyWebLog/views/themes/default/single-content.html rename to src/MyWebLog.Old/views/themes/default/single-content.html diff --git a/src/MyWebLog/views/themes/default/single.html b/src/MyWebLog.Old/views/themes/default/single.html similarity index 100% rename from src/MyWebLog/views/themes/default/single.html rename to src/MyWebLog.Old/views/themes/default/single.html -- 2.45.1 From 9ace4b907a535b09e95caa6f8cbf422cc104bd69 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 18 Oct 2021 22:36:30 -0400 Subject: [PATCH 002/102] Remove old cfg --- .gitignore | 2 ++ src/Settings.FSharpLint | 5 ----- src/global.json | 6 ------ src/myWebLog.sln | 43 ----------------------------------------- 4 files changed, 2 insertions(+), 54 deletions(-) delete mode 100644 src/Settings.FSharpLint delete mode 100644 src/global.json delete mode 100644 src/myWebLog.sln diff --git a/.gitignore b/.gitignore index 51a0960..56c3de8 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,5 @@ paket-files/ src/MyWebLog/views/themes/daniel-j-summers src/MyWebLog/views/themes/daniels-weekly-devotions src/MyWebLog/views/themes/djs-consulting + +.ionide \ No newline at end of file diff --git a/src/Settings.FSharpLint b/src/Settings.FSharpLint deleted file mode 100644 index d8e7530..0000000 --- a/src/Settings.FSharpLint +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/global.json b/src/global.json deleted file mode 100644 index f0dd758..0000000 --- a/src/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "projects":[ - "MyWebLog", - "MyWebLog.App" - ] -} \ No newline at end of file diff --git a/src/myWebLog.sln b/src/myWebLog.sln deleted file mode 100644 index b16adba..0000000 --- a/src/myWebLog.sln +++ /dev/null @@ -1,43 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "MyWebLog.App", "MyWebLog.App\MyWebLog.App.xproj", "{9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}" -EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "MyWebLog", "MyWebLog\MyWebLog.xproj", "{B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A87F3CF5-2189-442B-8ACF-929F5153AC22}.Release|Any CPU.Build.0 = Release|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6C2BE5E-883A-4F34-9905-B730543CA380}.Release|Any CPU.Build.0 = Release|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Release|Any CPU.ActiveCfg = Release|Any CPU - {29F6EDA3-4F43-4BB3-9C63-AE238A9B7F12}.Release|Any CPU.Build.0 = Release|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9CEA3A8B-E8AA-44E6-9F5F-2095CEED54EB}.Release|Any CPU.Build.0 = Release|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB}.Release|Any CPU.Build.0 = Release|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A12EA8DA-88BC-4447-90CB-A0E2DCC37523}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal -- 2.45.1 From 69bc105e40170f8556547597da61d1dc4395575e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 19 Oct 2021 23:06:31 -0400 Subject: [PATCH 003/102] Migrate strings/domain --- src/MyWebLog/Domain.fs | 489 ++++++++++++++++++++++++++++++ src/MyWebLog/MyWebLog.fsproj | 28 ++ src/MyWebLog/Program.fs | 4 + src/MyWebLog/Resources/en-US.json | 83 +++++ src/MyWebLog/Strings.fs | 40 +++ 5 files changed, 644 insertions(+) create mode 100644 src/MyWebLog/Domain.fs create mode 100644 src/MyWebLog/MyWebLog.fsproj create mode 100644 src/MyWebLog/Program.fs create mode 100644 src/MyWebLog/Resources/en-US.json create mode 100644 src/MyWebLog/Strings.fs diff --git a/src/MyWebLog/Domain.fs b/src/MyWebLog/Domain.fs new file mode 100644 index 0000000..b1cf41d --- /dev/null +++ b/src/MyWebLog/Domain.fs @@ -0,0 +1,489 @@ +namespace MyWebLog.Domain + +// -- Supporting Types -- + +/// Types of markup text supported +type MarkupText = + /// Text in Markdown format + | Markdown of string + /// Text in HTML format + | Html of string + +/// Functions to support maniuplating markup text +module MarkupText = + /// Get the string representation of this markup text + let toString it = + match it with + | Markdown x -> "Markdown", x + | Html x -> "HTML", x + ||> sprintf "%s: %s" + /// Get the HTML value of the text + let toHtml = function + | Markdown it -> sprintf "TODO: convert to HTML - %s" it + | Html it -> it + /// Parse a string representation to markup text + let ofString (it : string) = + match true with + | _ when it.StartsWith "Markdown: " -> it.Substring 10 |> Markdown + | _ when it.StartsWith "HTML: " -> it.Substring 6 |> Html + | _ -> sprintf "Cannot determine text type - %s" it |> invalidOp + + +/// Authorization levels +type AuthorizationLevel = + /// Authorization to administer a weblog + | Administrator + /// Authorization to comment on a weblog + | User + +/// Functions to support authorization levels +module AuthorizationLevel = + /// Get the string reprsentation of an authorization level + let toString = function Administrator -> "Administrator" | User -> "User" + /// Create an authorization level from a string + let ofString it = + match it with + | "Administrator" -> Administrator + | "User" -> User + | _ -> sprintf "%s is not an authorization level" it |> invalidOp + + +/// Post statuses +type PostStatus = + /// Post has not been released for public consumption + | Draft + /// Post is released + | Published + +/// Functions to support post statuses +module PostStatus = + /// Get the string representation of a post status + let toString = function Draft -> "Draft" | Published -> "Published" + /// Create a post status from a string + let ofString it = + match it with + | "Draft" -> Draft + | "Published" -> Published + | _ -> sprintf "%s is not a post status" it |> invalidOp + + +/// Comment statuses +type CommentStatus = + /// Comment is approved + | Approved + /// Comment has yet to be approved + | Pending + /// Comment was flagged as spam + | Spam + +/// Functions to support comment statuses +module CommentStatus = + /// Get the string representation of a comment status + let toString = function Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam" + /// Create a comment status from a string + let ofString it = + match it with + | "Approved" -> Approved + | "Pending" -> Pending + | "Spam" -> Spam + | _ -> sprintf "%s is not a comment status" it |> invalidOp + + +/// Seconds since the Unix epoch +type UnixSeconds = UnixSeconds of int64 + +/// Functions to support Unix seconds +module UnixSeconds = + /// Get the long (int64) representation of Unix seconds + let toLong = function UnixSeconds it -> it + /// Zero seconds past the epoch + let none = UnixSeconds 0L + + +// -- IDs -- + +open System + +// See https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID for info on "short GUIDs" + +/// A short GUID +type ShortGuid = ShortGuid of Guid + +/// Functions to support short GUIDs +module ShortGuid = + /// Encode a GUID into a short GUID + let toString = function + | ShortGuid guid -> + Convert.ToBase64String(guid.ToByteArray ()) + .Replace("/", "_") + .Replace("+", "-") + .Substring (0, 22) + /// Decode a short GUID into a GUID + let ofString (it : string) = + it.Replace("_", "/").Replace ("-", "+") + |> (sprintf "%s==" >> Convert.FromBase64String >> Guid >> ShortGuid) + /// Create a new short GUID + let create () = (Guid.NewGuid >> ShortGuid) () + /// The empty short GUID + let empty = ShortGuid Guid.Empty + + +/// The ID of a category +type CategoryId = CategoryId of ShortGuid + +/// Functions to support category IDs +module CategoryId = + /// Get the string representation of a page ID + let toString = function CategoryId it -> ShortGuid.toString it + /// Create a category ID from its string representation + let ofString = ShortGuid.ofString >> CategoryId + /// An empty category ID + let empty = CategoryId ShortGuid.empty + + +/// The ID of a comment +type CommentId = CommentId of ShortGuid + +/// Functions to support comment IDs +module CommentId = + /// Get the string representation of a comment ID + let toString = function CommentId it -> ShortGuid.toString it + /// Create a comment ID from its string representation + let ofString = ShortGuid.ofString >> CommentId + /// An empty comment ID + let empty = CommentId ShortGuid.empty + + +/// The ID of a page +type PageId = PageId of ShortGuid + +/// Functions to support page IDs +module PageId = + /// Get the string representation of a page ID + let toString = function PageId it -> ShortGuid.toString it + /// Create a page ID from its string representation + let ofString = ShortGuid.ofString >> PageId + /// An empty page ID + let empty = PageId ShortGuid.empty + + +/// The ID of a post +type PostId = PostId of ShortGuid + +/// Functions to support post IDs +module PostId = + /// Get the string representation of a post ID + let toString = function PostId it -> ShortGuid.toString it + /// Create a post ID from its string representation + let ofString = ShortGuid.ofString >> PostId + /// An empty post ID + let empty = PostId ShortGuid.empty + + +/// The ID of a user +type UserId = UserId of ShortGuid + +/// Functions to support user IDs +module UserId = + /// Get the string representation of a user ID + let toString = function UserId it -> ShortGuid.toString it + /// Create a user ID from its string representation + let ofString = ShortGuid.ofString >> UserId + /// An empty user ID + let empty = UserId ShortGuid.empty + + +/// The ID of a web log +type WebLogId = WebLogId of ShortGuid + +/// Functions to support web log IDs +module WebLogId = + /// Get the string representation of a web log ID + let toString = function WebLogId it -> ShortGuid.toString it + /// Create a web log ID from its string representation + let ofString = ShortGuid.ofString >> WebLogId + /// An empty web log ID + let empty = WebLogId ShortGuid.empty + + +// -- Domain Entities -- +// fsharplint:disable RecordFieldNames + +/// A revision of a post or page +type Revision = { + /// The instant which this revision was saved + asOf : UnixSeconds + /// The text + text : MarkupText + } +with + /// An empty revision + static member empty = + { asOf = UnixSeconds.none + text = Markdown "" + } + + +/// A page with static content +[] +type Page = { + /// The Id + id : PageId + /// The Id of the web log to which this page belongs + webLogId : WebLogId + /// The Id of the author of this page + authorId : UserId + /// The title of the page + title : string + /// The link at which this page is displayed + permalink : string + /// The instant this page was published + publishedOn : UnixSeconds + /// The instant this page was last updated + updatedOn : UnixSeconds + /// Whether this page shows as part of the web log's navigation + showInPageList : bool + /// The current text of the page + text : MarkupText + /// Revisions of this page + revisions : Revision list + } +with + static member empty = + { id = PageId.empty + webLogId = WebLogId.empty + authorId = UserId.empty + title = "" + permalink = "" + publishedOn = UnixSeconds.none + updatedOn = UnixSeconds.none + showInPageList = false + text = Markdown "" + revisions = [] + } + + +/// An entry in the list of pages displayed as part of the web log (derived via query) +type PageListEntry = { + /// The permanent link for the page + permalink : string + /// The title of the page + title : string + } + + +/// A web log +[] +type WebLog = { + /// The Id + id : WebLogId + /// The name + name : string + /// The subtitle + subtitle : string option + /// The default page ("posts" or a page Id) + defaultPage : string + /// The path of the theme (within /views/themes) + themePath : string + /// The URL base + urlBase : string + /// The time zone in which dates/times should be displayed + timeZone : string + /// A list of pages to be rendered as part of the site navigation (not stored) + pageList : PageListEntry list + } +with + /// An empty web log + static member empty = + { id = WebLogId.empty + name = "" + subtitle = None + defaultPage = "" + themePath = "default" + urlBase = "" + timeZone = "America/New_York" + pageList = [] + } + + +/// An authorization between a user and a web log +type Authorization = { + /// The Id of the web log to which this authorization grants access + webLogId : WebLogId + /// The level of access granted by this authorization + level : AuthorizationLevel +} + + +/// A user of myWebLog +[] +type User = { + /// The Id + id : UserId + /// The user name (e-mail address) + userName : string + /// The first name + firstName : string + /// The last name + lastName : string + /// The user's preferred name + preferredName : string + /// The hash of the user's password + passwordHash : string + /// The URL of the user's personal site + url : string option + /// The user's authorizations + authorizations : Authorization list + } +with + /// An empty user + static member empty = + { id = UserId.empty + userName = "" + firstName = "" + lastName = "" + preferredName = "" + passwordHash = "" + url = None + authorizations = [] + } + +/// Functions supporting users +module User = + /// Claims for this user + let claims user = + user.authorizations + |> List.map (fun a -> sprintf "%s|%s" (WebLogId.toString a.webLogId) (AuthorizationLevel.toString a.level)) + + +/// A category to which posts may be assigned +[] +type Category = { + /// The Id + id : CategoryId + /// The Id of the web log to which this category belongs + webLogId : WebLogId + /// The displayed name + name : string + /// The slug (used in category URLs) + slug : string + /// A longer description of the category + description : string option + /// The parent Id of this category (if a subcategory) + parentId : CategoryId option + /// The categories for which this category is the parent + children : CategoryId list + } +with + /// An empty category + static member empty = + { id = CategoryId.empty + webLogId = WebLogId.empty + name = "" + slug = "" + description = None + parentId = None + children = [] + } + + +/// A comment (applies to a post) +[] +type Comment = { + /// The Id + id : CommentId + /// The Id of the post to which this comment applies + postId : PostId + /// The Id of the comment to which this comment is a reply + inReplyToId : CommentId option + /// The name of the commentor + name : string + /// The e-mail address of the commentor + email : string + /// The URL of the commentor's personal website + url : string option + /// The status of the comment + status : CommentStatus + /// The instant the comment was posted + postedOn : UnixSeconds + /// The text of the comment + text : string + } +with + static member empty = + { id = CommentId.empty + postId = PostId.empty + inReplyToId = None + name = "" + email = "" + url = None + status = Pending + postedOn = UnixSeconds.none + text = "" + } + + +/// A post +[] +type Post = { + /// The Id + id : PostId + /// The Id of the web log to which this post belongs + webLogId : WebLogId + /// The Id of the author of this post + authorId : UserId + /// The status + status : PostStatus + /// The title + title : string + /// The link at which the post resides + permalink : string + /// The instant on which the post was originally published + publishedOn : UnixSeconds + /// The instant on which the post was last updated + updatedOn : UnixSeconds + /// The text of the post + text : MarkupText + /// The Ids of the categories to which this is assigned + categoryIds : CategoryId list + /// The tags for the post + tags : string list + /// The permalinks at which this post may have once resided + priorPermalinks : string list + /// Revisions of this post + revisions : Revision list + /// The categories to which this is assigned (not stored in database) + categories : Category list + /// The comments (not stored in database) + comments : Comment list + } +with + static member empty = + { id = PostId.empty + webLogId = WebLogId.empty + authorId = UserId.empty + status = Draft + title = "" + permalink = "" + publishedOn = UnixSeconds.none + updatedOn = UnixSeconds.none + text = Markdown "" + categoryIds = [] + tags = [] + priorPermalinks = [] + revisions = [] + categories = [] + comments = [] + } + +// --- UI Support --- + +/// Counts of items displayed on the admin dashboard +type DashboardCounts = { + /// The number of pages for the web log + pages : int + /// The number of pages for the web log + posts : int + /// The number of categories for the web log + categories : int + } diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj new file mode 100644 index 0000000..e11ac96 --- /dev/null +++ b/src/MyWebLog/MyWebLog.fsproj @@ -0,0 +1,28 @@ + + + + Exe + net6.0 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs new file mode 100644 index 0000000..139a10e --- /dev/null +++ b/src/MyWebLog/Program.fs @@ -0,0 +1,4 @@ +open MyWebLog +open Suave + +startWebServer defaultConfig (Successful.OK (Strings.get "LastUpdated")) diff --git a/src/MyWebLog/Resources/en-US.json b/src/MyWebLog/Resources/en-US.json new file mode 100644 index 0000000..be2715a --- /dev/null +++ b/src/MyWebLog/Resources/en-US.json @@ -0,0 +1,83 @@ +{ + "Action": "Action", + "Added": "Added", + "AddNew": "Add New", + "AddNewCategory": "Add New Category", + "AddNewPage": "Add New Page", + "AddNewPost": "Add New Post", + "Admin": "Admin", + "AndPublished": " and Published", + "andXMore": "and {0} more...", + "at": "at", + "BackToCategoryList": "Back to Category List", + "BackToPageList": "Back to Page List", + "BackToPostList": "Back to Post List", + "Categories": "Categories", + "Category": "Category", + "CategoryDeleteWarning": "Are you sure you wish to delete the category", + "Close": "Close", + "Comments": "Comments", + "Dashboard": "Dashboard", + "Date": "Date", + "Delete": "Delete", + "Description": "Description", + "Edit": "Edit", + "EditCategory": "Edit Category", + "EditPage": "Edit Page", + "EditPost": "Edit Post", + "EmailAddress": "E-mail Address", + "ErrBadAppConfig": "Could not convert config.json to myWebLog configuration", + "ErrBadLogOnAttempt": "Invalid e-mail address or password", + "ErrDataConfig": "Could not convert data-config.json to RethinkDB connection", + "ErrNotConfigured": "is not properly configured for myWebLog", + "Error": "Error", + "LastUpdated": "Last Updated", + "LastUpdatedDate": "Last Updated Date", + "ListAll": "List All", + "LoadedIn": "Loaded in", + "LogOff": "Log Off", + "LogOn": "Log On", + "MsgCategoryDeleted": "Deleted category {0} successfully", + "MsgCategoryEditSuccess": "{0} category successfully", + "MsgLogOffSuccess": "Log off successful | Have a nice day!", + "MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!", + "MsgPageDeleted": "Deleted page successfully", + "MsgPageEditSuccess": "{0} page successfully", + "MsgPostEditSuccess": "{0}{1} post successfully", + "Name": "Name", + "NewerPosts": "Newer Posts", + "NextPost": "Next Post", + "NoComments": "No Comments", + "NoParent": "No Parent", + "OlderPosts": "Older Posts", + "OneComment": "1 Comment", + "PageDeleteWarning": "Are you sure you wish to delete the page", + "PageDetails": "Page Details", + "PageHash": "Page #", + "Pages": "Pages", + "ParentCategory": "Parent Category", + "Password": "Password", + "Permalink": "Permalink", + "PermanentLinkTo": "Permanent Link to", + "PostDetails": "Post Details", + "Posts": "Posts", + "PostsTagged": "Posts Tagged", + "PostStatus": "Post Status", + "PoweredBy": "Powered by", + "PreviousPost": "Previous Post", + "PublishedDate": "Published Date", + "PublishThisPost": "Publish This Post", + "Save": "Save", + "Seconds": "Seconds", + "ShowInPageList": "Show in Page List", + "Slug": "Slug", + "startingWith": "starting with", + "Status": "Status", + "Tags": "Tags", + "Time": "Time", + "Title": "Title", + "Updated": "Updated", + "View": "View", + "Warning": "Warning", + "XComments": "{0} Comments" +} diff --git a/src/MyWebLog/Strings.fs b/src/MyWebLog/Strings.fs new file mode 100644 index 0000000..55a725b --- /dev/null +++ b/src/MyWebLog/Strings.fs @@ -0,0 +1,40 @@ +module MyWebLog.Strings + +open System.Collections.Generic +open System.Globalization +open System.IO +open System.Reflection +open System.Text.Json + +/// The locales we'll try to load +let private supportedLocales = [ "en-US" ] + +/// The fallback locale, if a key is not found in a non-default locale +let private fallbackLocale = "en-US" + +/// Get an embedded JSON file as a string +let private getEmbedded locale = + let str = sprintf "MyWebLog.Resources.%s.json" locale |> Assembly.GetExecutingAssembly().GetManifestResourceStream + use rdr = new StreamReader (str) + rdr.ReadToEnd() + +/// The dictionary of localized strings +let private strings = + supportedLocales + |> List.map (fun loc -> loc, getEmbedded loc |> JsonSerializer.Deserialize>) + |> dict + +/// Get a key from the resources file for the given locale +let getForLocale locale key = + let getString thisLocale = + match strings.ContainsKey thisLocale && strings.[thisLocale].ContainsKey key with + | true -> Some strings.[thisLocale].[key] + | false -> None + match getString locale with + | Some xlat -> Some xlat + | None when locale <> fallbackLocale -> getString fallbackLocale + | None -> None + |> function Some xlat -> xlat | None -> sprintf "%s.%s" locale key + +/// Translate the key for the current locale +let get key = getForLocale CultureInfo.CurrentCulture.Name key -- 2.45.1 From 8bc2cf0aa55dc2afb7ea91f8aa9554eca4dbe5d1 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 24 Feb 2022 22:52:01 -0500 Subject: [PATCH 004/102] WIP on EF Core data context --- src/MyWebLog.Data/Category.cs | 42 +++++++++++++++ src/MyWebLog.Data/Comment.cs | 62 ++++++++++++++++++++++ src/MyWebLog.Data/Enums.cs | 47 +++++++++++++++++ src/MyWebLog.Data/MyWebLog.Data.csproj | 9 ++++ src/MyWebLog.Data/Page.cs | 62 ++++++++++++++++++++++ src/MyWebLog.Data/Permalink.cs | 49 ++++++++++++++++++ src/MyWebLog.Data/Post.cs | 72 ++++++++++++++++++++++++++ src/MyWebLog.Data/Revision.cs | 59 +++++++++++++++++++++ src/MyWebLog.Data/Tag.cs | 17 ++++++ src/MyWebLog.Data/WebLogDbContext.cs | 69 ++++++++++++++++++++++++ src/MyWebLog.Data/WebLogDetails.cs | 37 +++++++++++++ src/MyWebLog.Data/WebLogUser.cs | 57 ++++++++++++++++++++ src/MyWebLog.sln | 31 +++++++++++ 13 files changed, 613 insertions(+) create mode 100644 src/MyWebLog.Data/Category.cs create mode 100644 src/MyWebLog.Data/Comment.cs create mode 100644 src/MyWebLog.Data/Enums.cs create mode 100644 src/MyWebLog.Data/MyWebLog.Data.csproj create mode 100644 src/MyWebLog.Data/Page.cs create mode 100644 src/MyWebLog.Data/Permalink.cs create mode 100644 src/MyWebLog.Data/Post.cs create mode 100644 src/MyWebLog.Data/Revision.cs create mode 100644 src/MyWebLog.Data/Tag.cs create mode 100644 src/MyWebLog.Data/WebLogDbContext.cs create mode 100644 src/MyWebLog.Data/WebLogDetails.cs create mode 100644 src/MyWebLog.Data/WebLogUser.cs create mode 100644 src/MyWebLog.sln diff --git a/src/MyWebLog.Data/Category.cs b/src/MyWebLog.Data/Category.cs new file mode 100644 index 0000000..c472dec --- /dev/null +++ b/src/MyWebLog.Data/Category.cs @@ -0,0 +1,42 @@ +namespace MyWebLog.Data; + +/// +/// A category under which a post may be identfied +/// +public class Category +{ + /// + /// The ID of the category + /// + public string Id { get; set; } = ""; + + /// + /// The displayed name + /// + public string Name { get; set; } = ""; + + /// + /// The slug (used in category URLs) + /// + public string Slug { get; set; } = ""; + + /// + /// A longer description of the category + /// + public string? Description { get; set; } = null; + + /// + /// The parent ID of this category (if a subcategory) + /// + public string? ParentId { get; set; } = null; + + /// + /// The parent of this category (if a subcategory) + /// + public Category? Parent { get; set; } = default; + + /// + /// The posts assigned to this category + /// + public ICollection Posts { get; set; } = default!; +} diff --git a/src/MyWebLog.Data/Comment.cs b/src/MyWebLog.Data/Comment.cs new file mode 100644 index 0000000..bdcbded --- /dev/null +++ b/src/MyWebLog.Data/Comment.cs @@ -0,0 +1,62 @@ +namespace MyWebLog.Data; + +/// +/// A comment on a post +/// +public class Comment +{ + /// + /// The ID of the comment + /// + public string Id { get; set; } = ""; + + /// + /// The ID of the post to which this comment applies + /// + public string PostId { get; set; } = ""; + + /// + /// The post to which this comment applies + /// + public Post Post { get; set; } = default!; + + /// + /// The ID of the comment to which this comment is a reply + /// + public string? InReplyToId { get; set; } = null; + + /// + /// The comment to which this comment is a reply + /// + public Comment? InReplyTo { get; set; } = default; + + /// + /// The name of the commentor + /// + public string Name { get; set; } = ""; + + /// + /// The e-mail address of the commentor + /// + public string Email { get; set; } = ""; + + /// + /// The URL of the commentor's personal website + /// + public string? Url { get; set; } = null; + + /// + /// The status of the comment + /// + public CommentStatus Status { get; set; } = CommentStatus.Pending; + + /// + /// When the comment was posted + /// + public DateTime PostedOn { get; set; } = DateTime.UtcNow; + + /// + /// The text of the comment + /// + public string Text { get; set; } = ""; +} diff --git a/src/MyWebLog.Data/Enums.cs b/src/MyWebLog.Data/Enums.cs new file mode 100644 index 0000000..3eecc51 --- /dev/null +++ b/src/MyWebLog.Data/Enums.cs @@ -0,0 +1,47 @@ +namespace MyWebLog.Data; + +/// +/// The source format for a revision +/// +public enum RevisionSource +{ + /// Markdown text + Markdown, + /// HTML + Html +} + +/// +/// A level of authorization for a given web log +/// +public enum AuthorizationLevel +{ + /// The user may administer all aspects of a web log + Administrator, + /// The user is a known user of a web log + User +} + +/// +/// Statuses for posts +/// +public enum PostStatus +{ + /// The post should not be publicly available + Draft, + /// The post is publicly viewable + Published +} + +/// +/// Statuses for post comments +/// +public enum CommentStatus +{ + /// The comment is approved + Approved, + /// The comment has yet to be approved + Pending, + /// The comment was unsolicited and unwelcome + Spam +} diff --git a/src/MyWebLog.Data/MyWebLog.Data.csproj b/src/MyWebLog.Data/MyWebLog.Data.csproj new file mode 100644 index 0000000..132c02c --- /dev/null +++ b/src/MyWebLog.Data/MyWebLog.Data.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + diff --git a/src/MyWebLog.Data/Page.cs b/src/MyWebLog.Data/Page.cs new file mode 100644 index 0000000..e1b3dd7 --- /dev/null +++ b/src/MyWebLog.Data/Page.cs @@ -0,0 +1,62 @@ +namespace MyWebLog.Data; + +/// +/// A page (text not associated with a date/time) +/// +public class Page +{ + /// + /// The ID of this page + /// + public string Id { get; set; } = ""; + + /// + /// The ID of the author of this page + /// + public string AuthorId { get; set; } = ""; + + /// + /// The author of this page + /// + public WebLogUser Author { get; set; } = default!; + + /// + /// The title of the page + /// + public string Title { get; set; } = ""; + + /// + /// The link at which this page is displayed + /// + public string Permalink { get; set; } = ""; + + /// + /// The instant this page was published + /// + public DateTime PublishedOn { get; set; } = DateTime.MinValue; + + /// + /// The instant this page was last updated + /// + public DateTime UpdatedOn { get; set; } = DateTime.MinValue; + + /// + /// Whether this page shows as part of the web log's navigation + /// + public bool ShowInPageList { get; set; } = false; + + /// + /// The current text of the page + /// + public string Text { get; set; } = ""; + + /// + /// Permalinks at which this page may have been previously served (useful for migrated content) + /// + public ICollection PriorPermalinks { get; set; } = default!; + + /// + /// Revisions of this page + /// + public ICollection Revisions { get; set; } = default!; +} diff --git a/src/MyWebLog.Data/Permalink.cs b/src/MyWebLog.Data/Permalink.cs new file mode 100644 index 0000000..b36ceca --- /dev/null +++ b/src/MyWebLog.Data/Permalink.cs @@ -0,0 +1,49 @@ +namespace MyWebLog.Data; + +/// +/// A permalink which a post or page used to have +/// +public abstract class Permalink +{ + /// + /// The ID of this permalink + /// + public string Id { get; set; } = ""; + + /// + /// The link + /// + public string Url { get; set; } = ""; +} + +/// +/// A prior permalink for a page +/// +public class PagePermalink : Permalink +{ + /// + /// The ID of the page to which this permalink belongs + /// + public string PageId { get; set; } = ""; + + /// + /// The page to which this permalink belongs + /// + public Page Page { get; set; } = default!; +} + +/// +/// A prior permalink for a post +/// +public class PostPermalink : Permalink +{ + /// + /// The ID of the post to which this permalink belongs + /// + public string PostId { get; set; } = ""; + + /// + /// The post to which this permalink belongs + /// + public Post Post { get; set; } = default!; +} diff --git a/src/MyWebLog.Data/Post.cs b/src/MyWebLog.Data/Post.cs new file mode 100644 index 0000000..f2afe39 --- /dev/null +++ b/src/MyWebLog.Data/Post.cs @@ -0,0 +1,72 @@ +namespace MyWebLog.Data; + +/// +/// A web log post +/// +public class Post +{ + /// + /// The ID of this post + /// + public string Id { get; set; } = ""; + + /// + /// The ID of the author of this post + /// + public string AuthorId { get; set; } = ""; + + /// + /// The author of the post + /// + public WebLogUser Author { get; set; } = default!; + + /// + /// The status + /// + public PostStatus Status { get; set; } = PostStatus.Draft; + + /// + /// The title + /// + public string Title { get; set; } = ""; + + /// + /// The link at which the post resides + /// + public string Permalink { get; set; } = ""; + + /// + /// The instant on which the post was originally published + /// + public DateTime? PublishedOn { get; set; } = null; + + /// + /// The instant on which the post was last updated + /// + public DateTime UpdatedOn { get; set; } = DateTime.MinValue; + + /// + /// The text of the post in HTML (ready to display) format + /// + public string Text { get; set; } = ""; + + /// + /// The Ids of the categories to which this is assigned + /// + public ICollection Categories { get; set; } = default!; + + /// + /// The tags for the post + /// + public ICollection Tags { get; set; } = default!; + + /// + /// Permalinks at which this post may have been previously served (useful for migrated content) + /// + public ICollection PriorPermalinks { get; set; } = default!; + + /// + /// The revisions for this post + /// + public ICollection Revisions { get; set; } = default!; +} diff --git a/src/MyWebLog.Data/Revision.cs b/src/MyWebLog.Data/Revision.cs new file mode 100644 index 0000000..cd50875 --- /dev/null +++ b/src/MyWebLog.Data/Revision.cs @@ -0,0 +1,59 @@ +namespace MyWebLog.Data; + +/// +/// A revision of a page or post +/// +public abstract class Revision +{ + /// + /// The ID of this revision + /// + public string Id { get; set; } = ""; + + /// + /// When this revision was saved + /// + public DateTime AsOf { get; set; } = DateTime.UtcNow; + + /// + /// The source language (Markdown or HTML) + /// + public RevisionSource SourceType { get; set; } = RevisionSource.Html; + + /// + /// The text of the revision + /// + public string Text { get; set; } = ""; +} + +/// +/// A revision of a page +/// +public class PageRevision : Revision +{ + /// + /// The ID of the page to which this revision belongs + /// + public string PageId { get; set; } = ""; + + /// + /// The page to which this revision belongs + /// + public Page Page { get; set; } = default!; +} + +/// +/// A revision of a post +/// +public class PostRevision : Revision +{ + /// + /// The ID of the post to which this revision applies + /// + public string PostId { get; set; } = ""; + + /// + /// The post to which this revision applies + /// + public Post Post { get; set; } = default!; +} diff --git a/src/MyWebLog.Data/Tag.cs b/src/MyWebLog.Data/Tag.cs new file mode 100644 index 0000000..28f07ef --- /dev/null +++ b/src/MyWebLog.Data/Tag.cs @@ -0,0 +1,17 @@ +namespace MyWebLog.Data; + +/// +/// A tag +/// +public class Tag +{ + /// + /// The name of the tag + /// + public string Name { get; set; } = ""; + + /// + /// The posts with this tag assigned + /// + public ICollection Posts { get; set; } = default!; +} diff --git a/src/MyWebLog.Data/WebLogDbContext.cs b/src/MyWebLog.Data/WebLogDbContext.cs new file mode 100644 index 0000000..afbcd8f --- /dev/null +++ b/src/MyWebLog.Data/WebLogDbContext.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +/// +/// Data context for web log data +/// +public sealed class WebLogDbContext : DbContext +{ + /// + /// The categories for the web log + /// + public DbSet Categories { get; set; } = default!; + + /// + /// Comments on posts + /// + public DbSet Comments { get; set; } = default!; + + /// + /// Pages + /// + public DbSet Pages { get; set; } = default!; + + /// + /// Web log posts + /// + public DbSet Posts { get; set; } = default!; + + /// + /// Post tags + /// + public DbSet Tags { get; set; } = default!; + + /// + /// The users of the web log + /// + public DbSet Users { get; set; } = default!; + + /// + /// The details for the web log + /// + public DbSet WebLogDetails { get; set; } = default!; + + /// + /// Constructor + /// + /// Configuration options + public WebLogDbContext(DbContextOptions options) : base(options) { } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Tag and WebLogDetails use Name as its ID + modelBuilder.Entity().HasKey(t => t.Name); + modelBuilder.Entity().HasKey(wld => wld.Name); + + // Index slugs and links + modelBuilder.Entity().HasIndex(c => c.Slug); + modelBuilder.Entity().HasIndex(p => p.Permalink); + modelBuilder.Entity().HasIndex(p => p.Permalink); + + // Link "author" to "user" + modelBuilder.Entity().HasOne(p => p.Author).WithMany(wbu => wbu.Pages).HasForeignKey(p => p.AuthorId); + modelBuilder.Entity().HasOne(p => p.Author).WithMany(wbu => wbu.Posts).HasForeignKey(p => p.AuthorId); + } +} diff --git a/src/MyWebLog.Data/WebLogDetails.cs b/src/MyWebLog.Data/WebLogDetails.cs new file mode 100644 index 0000000..b891669 --- /dev/null +++ b/src/MyWebLog.Data/WebLogDetails.cs @@ -0,0 +1,37 @@ +namespace MyWebLog.Data; + +/// +/// The details about a web log +/// +public class WebLogDetails +{ + /// + /// The name of the web log + /// + public string Name { get; set; } = ""; + + /// + /// A subtitle for the web log + /// + public string? Subtitle { get; set; } = null; + + /// + /// The default page ("posts" or a page Id) + /// + public string DefaultPage { get; set; } = ""; + + /// + /// The path of the theme (within /views/themes) + /// + public string ThemePath { get; set; } = ""; + + /// + /// The URL base + /// + public string UrlBase { get; set; } = ""; + + /// + /// The time zone in which dates/times should be displayed + /// + public string TimeZone { get; set; } = ""; +} diff --git a/src/MyWebLog.Data/WebLogUser.cs b/src/MyWebLog.Data/WebLogUser.cs new file mode 100644 index 0000000..075e83f --- /dev/null +++ b/src/MyWebLog.Data/WebLogUser.cs @@ -0,0 +1,57 @@ +namespace MyWebLog.Data; + +/// +/// A user of the web log +/// +public class WebLogUser +{ + /// + /// The ID of the user + /// + public string Id { get; set; } = ""; + + /// + /// The user name (e-mail address) + /// + public string UserName { get; set; } = ""; + + /// + /// The user's first name + /// + public string FirstName { get; set; } = ""; + + /// + /// The user's last name + /// + public string LastName { get; set; } = ""; + + /// + /// The user's preferred name + /// + public string PreferredName { get; set; } = ""; + + /// + /// The hash of the user's password + /// + public string PasswordHash { get; set; } = ""; + + /// + /// Salt used to calculate the user's password hash + /// + public Guid Salt { get; set; } = Guid.Empty; + + /// + /// The URL of the user's personal site + /// + public string? Url { get; set; } = null; + + /// + /// Pages written by this author + /// + public ICollection Pages { get; set; } = default!; + + /// + /// Posts written by this author + /// + public ICollection Posts { get; set; } = default!; +} diff --git a/src/MyWebLog.sln b/src/MyWebLog.sln new file mode 100644 index 0000000..5251070 --- /dev/null +++ b/src/MyWebLog.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32210.238 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{2E5E2346-25FE-4CBD-89AA-6148A33DE09C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Release|Any CPU.Build.0 = Release|Any CPU + {0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {70EEF0F4-8709-44A8-AD9B-24D1EB84CFB4} + EndGlobalSection +EndGlobal -- 2.45.1 From df3467030ae93602c6fff4cea2fdcf3a730c2709 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 27 Feb 2022 12:38:08 -0500 Subject: [PATCH 005/102] Init data, single home page, multi-tenant --- .../Extensions/PageExtensions.cs | 14 + .../Extensions/PostExtensions.cs | 17 + .../Extensions/WebLogDetailsExtensions.cs | 14 + .../20220227160816_Initial.Designer.cs | 518 ++++++++++++++++++ .../Migrations/20220227160816_Initial.cs | 398 ++++++++++++++ .../WebLogDbContextModelSnapshot.cs | 516 +++++++++++++++++ src/MyWebLog.Data/MyWebLog.Data.csproj | 11 + src/MyWebLog.Data/WebLogDbContext.cs | 12 + src/MyWebLog.Data/WebLogDetails.cs | 7 +- src/MyWebLog.Data/WebLogUser.cs | 5 + src/MyWebLog.sln | 12 +- src/MyWebLog/Db/.gitignore | 1 + src/MyWebLog/Domain.fs | 489 ----------------- src/MyWebLog/Features/FeatureSupport.cs | 67 +++ src/MyWebLog/Features/Posts/PostController.cs | 25 + .../Features/Shared/MyWebLogController.cs | 25 + src/MyWebLog/Features/ThemeSupport.cs | 28 + src/MyWebLog/Features/Users/UserController.cs | 33 ++ src/MyWebLog/GlobalUsings.cs | 2 + src/MyWebLog/MyWebLog.csproj | 24 + src/MyWebLog/MyWebLog.fsproj | 28 - src/MyWebLog/Program.cs | 131 +++++ src/MyWebLog/Program.fs | 4 - src/MyWebLog/Properties/launchSettings.json | 28 + src/MyWebLog/Resources/en-US.json | 83 --- src/MyWebLog/Strings.fs | 40 -- .../Themes/Default/Shared/_Layout.cshtml | 14 + src/MyWebLog/Themes/Default/SinglePage.cshtml | 9 + src/MyWebLog/Themes/_ViewImports.cshtml | 1 + src/MyWebLog/WebLogMiddleware.cs | 90 +++ src/MyWebLog/appsettings.Development.json | 8 + src/MyWebLog/appsettings.json | 9 + 32 files changed, 2012 insertions(+), 651 deletions(-) create mode 100644 src/MyWebLog.Data/Extensions/PageExtensions.cs create mode 100644 src/MyWebLog.Data/Extensions/PostExtensions.cs create mode 100644 src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs create mode 100644 src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs create mode 100644 src/MyWebLog.Data/Migrations/20220227160816_Initial.cs create mode 100644 src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs create mode 100644 src/MyWebLog/Db/.gitignore delete mode 100644 src/MyWebLog/Domain.fs create mode 100644 src/MyWebLog/Features/FeatureSupport.cs create mode 100644 src/MyWebLog/Features/Posts/PostController.cs create mode 100644 src/MyWebLog/Features/Shared/MyWebLogController.cs create mode 100644 src/MyWebLog/Features/ThemeSupport.cs create mode 100644 src/MyWebLog/Features/Users/UserController.cs create mode 100644 src/MyWebLog/GlobalUsings.cs create mode 100644 src/MyWebLog/MyWebLog.csproj delete mode 100644 src/MyWebLog/MyWebLog.fsproj create mode 100644 src/MyWebLog/Program.cs delete mode 100644 src/MyWebLog/Program.fs create mode 100644 src/MyWebLog/Properties/launchSettings.json delete mode 100644 src/MyWebLog/Resources/en-US.json delete mode 100644 src/MyWebLog/Strings.fs create mode 100644 src/MyWebLog/Themes/Default/Shared/_Layout.cshtml create mode 100644 src/MyWebLog/Themes/Default/SinglePage.cshtml create mode 100644 src/MyWebLog/Themes/_ViewImports.cshtml create mode 100644 src/MyWebLog/WebLogMiddleware.cs create mode 100644 src/MyWebLog/appsettings.Development.json create mode 100644 src/MyWebLog/appsettings.json diff --git a/src/MyWebLog.Data/Extensions/PageExtensions.cs b/src/MyWebLog.Data/Extensions/PageExtensions.cs new file mode 100644 index 0000000..c105815 --- /dev/null +++ b/src/MyWebLog.Data/Extensions/PageExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class PageExtensions +{ + /// + /// Retrieve a page by its ID (non-tracked) + /// + /// The ID of the page to retrieve + /// The requested page (or null if it is not found) + public static async Task FindById(this DbSet db, string id) => + await db.FirstOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); +} diff --git a/src/MyWebLog.Data/Extensions/PostExtensions.cs b/src/MyWebLog.Data/Extensions/PostExtensions.cs new file mode 100644 index 0000000..818d3ca --- /dev/null +++ b/src/MyWebLog.Data/Extensions/PostExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class PostExtensions +{ + /// + /// Retrieve a page of published posts (non-tracked) + /// + /// The page number to retrieve + /// The number of posts per page + /// A list of posts representing the posts for the given page + public static async Task> FindPageOfPublishedPosts(this DbSet db, int pageNbr, int postsPerPage) => + await db.Where(p => p.Status == PostStatus.Published) + .Skip((pageNbr - 1) * postsPerPage).Take(postsPerPage) + .ToListAsync(); +} diff --git a/src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs b/src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs new file mode 100644 index 0000000..9d3f8e1 --- /dev/null +++ b/src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class WebLogDetailsExtensions +{ + /// + /// Find the details of a web log by its host + /// + /// The host + /// The web log (or null if not found) + public static async Task FindByHost(this DbSet db, string host) => + await db.FirstOrDefaultAsync(wld => wld.UrlBase == host).ConfigureAwait(false); +} diff --git a/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs b/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs new file mode 100644 index 0000000..f9df601 --- /dev/null +++ b/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs @@ -0,0 +1,518 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyWebLog.Data; + +#nullable disable + +namespace MyWebLog.Data.Migrations +{ + [DbContext(typeof(WebLogDbContext))] + [Migration("20220227160816_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); + + modelBuilder.Entity("CategoryPost", b => + { + b.Property("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("CategoryPost"); + }); + + modelBuilder.Entity("MyWebLog.Data.Category", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug"); + + b.ToTable("Category"); + }); + + modelBuilder.Entity("MyWebLog.Data.Comment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InReplyToId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InReplyToId"); + + b.HasIndex("PostId"); + + b.ToTable("Comment"); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("ShowInPageList") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.PagePermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PagePermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PageRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PageRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostPermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostPermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Tag", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogDetails", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("DefaultPage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostsPerPage") + .HasColumnType("INTEGER"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("ThemePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UrlBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("WebLogDetails"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorizationLevel") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreferredName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Salt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebLogUser"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.Property("TagsName") + .HasColumnType("TEXT"); + + b.HasKey("PostsId", "TagsName"); + + b.HasIndex("TagsName"); + + b.ToTable("PostTag"); + }); + + modelBuilder.Entity("CategoryPost", b => + { + b.HasOne("MyWebLog.Data.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWebLog.Data.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyWebLog.Data.Category", b => + { + b.HasOne("MyWebLog.Data.Category", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("MyWebLog.Data.Comment", b => + { + b.HasOne("MyWebLog.Data.Comment", "InReplyTo") + .WithMany() + .HasForeignKey("InReplyToId"); + + b.HasOne("MyWebLog.Data.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InReplyTo"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.HasOne("MyWebLog.Data.WebLogUser", "Author") + .WithMany("Pages") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("MyWebLog.Data.PagePermalink", b => + { + b.HasOne("MyWebLog.Data.Page", "Page") + .WithMany("PriorPermalinks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.PageRevision", b => + { + b.HasOne("MyWebLog.Data.Page", "Page") + .WithMany("Revisions") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.HasOne("MyWebLog.Data.WebLogUser", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostPermalink", b => + { + b.HasOne("MyWebLog.Data.Post", "Post") + .WithMany("PriorPermalinks") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostRevision", b => + { + b.HasOne("MyWebLog.Data.Post", "Post") + .WithMany("Revisions") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.HasOne("MyWebLog.Data.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWebLog.Data.Tag", null) + .WithMany() + .HasForeignKey("TagsName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.Navigation("PriorPermalinks"); + + b.Navigation("Revisions"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.Navigation("PriorPermalinks"); + + b.Navigation("Revisions"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogUser", b => + { + b.Navigation("Pages"); + + b.Navigation("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs b/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs new file mode 100644 index 0000000..cf155f5 --- /dev/null +++ b/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs @@ -0,0 +1,398 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyWebLog.Data.Migrations +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Category", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Slug = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + ParentId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Category", x => x.Id); + table.ForeignKey( + name: "FK_Category_Category_ParentId", + column: x => x.ParentId, + principalTable: "Category", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Tag", + columns: table => new + { + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tag", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "WebLogDetails", + columns: table => new + { + Name = table.Column(type: "TEXT", nullable: false), + Subtitle = table.Column(type: "TEXT", nullable: true), + DefaultPage = table.Column(type: "TEXT", nullable: false), + PostsPerPage = table.Column(type: "INTEGER", nullable: false), + ThemePath = table.Column(type: "TEXT", nullable: false), + UrlBase = table.Column(type: "TEXT", nullable: false), + TimeZone = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebLogDetails", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "WebLogUser", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", nullable: false), + FirstName = table.Column(type: "TEXT", nullable: false), + LastName = table.Column(type: "TEXT", nullable: false), + PreferredName = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + Salt = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", nullable: true), + AuthorizationLevel = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebLogUser", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Page", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Permalink = table.Column(type: "TEXT", nullable: false), + PublishedOn = table.Column(type: "TEXT", nullable: false), + UpdatedOn = table.Column(type: "TEXT", nullable: false), + ShowInPageList = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Page", x => x.Id); + table.ForeignKey( + name: "FK_Page_WebLogUser_AuthorId", + column: x => x.AuthorId, + principalTable: "WebLogUser", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Post", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Permalink = table.Column(type: "TEXT", nullable: false), + PublishedOn = table.Column(type: "TEXT", nullable: true), + UpdatedOn = table.Column(type: "TEXT", nullable: false), + Text = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Post", x => x.Id); + table.ForeignKey( + name: "FK_Post_WebLogUser_AuthorId", + column: x => x.AuthorId, + principalTable: "WebLogUser", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PagePermalink", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PageId = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PagePermalink", x => x.Id); + table.ForeignKey( + name: "FK_PagePermalink_Page_PageId", + column: x => x.PageId, + principalTable: "Page", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PageRevision", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PageId = table.Column(type: "TEXT", nullable: false), + AsOf = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PageRevision", x => x.Id); + table.ForeignKey( + name: "FK_PageRevision_Page_PageId", + column: x => x.PageId, + principalTable: "Page", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CategoryPost", + columns: table => new + { + CategoriesId = table.Column(type: "TEXT", nullable: false), + PostsId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CategoryPost", x => new { x.CategoriesId, x.PostsId }); + table.ForeignKey( + name: "FK_CategoryPost_Category_CategoriesId", + column: x => x.CategoriesId, + principalTable: "Category", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CategoryPost_Post_PostsId", + column: x => x.PostsId, + principalTable: "Post", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Comment", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PostId = table.Column(type: "TEXT", nullable: false), + InReplyToId = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + PostedOn = table.Column(type: "TEXT", nullable: false), + Text = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Comment", x => x.Id); + table.ForeignKey( + name: "FK_Comment_Comment_InReplyToId", + column: x => x.InReplyToId, + principalTable: "Comment", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Comment_Post_PostId", + column: x => x.PostId, + principalTable: "Post", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PostPermalink", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PostId = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PostPermalink", x => x.Id); + table.ForeignKey( + name: "FK_PostPermalink_Post_PostId", + column: x => x.PostId, + principalTable: "Post", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PostRevision", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PostId = table.Column(type: "TEXT", nullable: false), + AsOf = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PostRevision", x => x.Id); + table.ForeignKey( + name: "FK_PostRevision_Post_PostId", + column: x => x.PostId, + principalTable: "Post", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PostTag", + columns: table => new + { + PostsId = table.Column(type: "TEXT", nullable: false), + TagsName = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PostTag", x => new { x.PostsId, x.TagsName }); + table.ForeignKey( + name: "FK_PostTag_Post_PostsId", + column: x => x.PostsId, + principalTable: "Post", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PostTag_Tag_TagsName", + column: x => x.TagsName, + principalTable: "Tag", + principalColumn: "Name", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Category_ParentId", + table: "Category", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_Category_Slug", + table: "Category", + column: "Slug"); + + migrationBuilder.CreateIndex( + name: "IX_CategoryPost_PostsId", + table: "CategoryPost", + column: "PostsId"); + + migrationBuilder.CreateIndex( + name: "IX_Comment_InReplyToId", + table: "Comment", + column: "InReplyToId"); + + migrationBuilder.CreateIndex( + name: "IX_Comment_PostId", + table: "Comment", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_Page_AuthorId", + table: "Page", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_Page_Permalink", + table: "Page", + column: "Permalink"); + + migrationBuilder.CreateIndex( + name: "IX_PagePermalink_PageId", + table: "PagePermalink", + column: "PageId"); + + migrationBuilder.CreateIndex( + name: "IX_PageRevision_PageId", + table: "PageRevision", + column: "PageId"); + + migrationBuilder.CreateIndex( + name: "IX_Post_AuthorId", + table: "Post", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_Post_Permalink", + table: "Post", + column: "Permalink"); + + migrationBuilder.CreateIndex( + name: "IX_PostPermalink_PostId", + table: "PostPermalink", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_PostRevision_PostId", + table: "PostRevision", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_PostTag_TagsName", + table: "PostTag", + column: "TagsName"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CategoryPost"); + + migrationBuilder.DropTable( + name: "Comment"); + + migrationBuilder.DropTable( + name: "PagePermalink"); + + migrationBuilder.DropTable( + name: "PageRevision"); + + migrationBuilder.DropTable( + name: "PostPermalink"); + + migrationBuilder.DropTable( + name: "PostRevision"); + + migrationBuilder.DropTable( + name: "PostTag"); + + migrationBuilder.DropTable( + name: "WebLogDetails"); + + migrationBuilder.DropTable( + name: "Category"); + + migrationBuilder.DropTable( + name: "Page"); + + migrationBuilder.DropTable( + name: "Post"); + + migrationBuilder.DropTable( + name: "Tag"); + + migrationBuilder.DropTable( + name: "WebLogUser"); + } + } +} diff --git a/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs b/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs new file mode 100644 index 0000000..8b1df7b --- /dev/null +++ b/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs @@ -0,0 +1,516 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyWebLog.Data; + +#nullable disable + +namespace MyWebLog.Data.Migrations +{ + [DbContext(typeof(WebLogDbContext))] + partial class WebLogDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); + + modelBuilder.Entity("CategoryPost", b => + { + b.Property("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("CategoryPost"); + }); + + modelBuilder.Entity("MyWebLog.Data.Category", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug"); + + b.ToTable("Category"); + }); + + modelBuilder.Entity("MyWebLog.Data.Comment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InReplyToId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InReplyToId"); + + b.HasIndex("PostId"); + + b.ToTable("Comment"); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("ShowInPageList") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.PagePermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PagePermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PageRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PageRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostPermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostPermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Tag", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogDetails", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("DefaultPage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostsPerPage") + .HasColumnType("INTEGER"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("ThemePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UrlBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("WebLogDetails"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorizationLevel") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreferredName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Salt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebLogUser"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.Property("TagsName") + .HasColumnType("TEXT"); + + b.HasKey("PostsId", "TagsName"); + + b.HasIndex("TagsName"); + + b.ToTable("PostTag"); + }); + + modelBuilder.Entity("CategoryPost", b => + { + b.HasOne("MyWebLog.Data.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWebLog.Data.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyWebLog.Data.Category", b => + { + b.HasOne("MyWebLog.Data.Category", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("MyWebLog.Data.Comment", b => + { + b.HasOne("MyWebLog.Data.Comment", "InReplyTo") + .WithMany() + .HasForeignKey("InReplyToId"); + + b.HasOne("MyWebLog.Data.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InReplyTo"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.HasOne("MyWebLog.Data.WebLogUser", "Author") + .WithMany("Pages") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("MyWebLog.Data.PagePermalink", b => + { + b.HasOne("MyWebLog.Data.Page", "Page") + .WithMany("PriorPermalinks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.PageRevision", b => + { + b.HasOne("MyWebLog.Data.Page", "Page") + .WithMany("Revisions") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.HasOne("MyWebLog.Data.WebLogUser", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostPermalink", b => + { + b.HasOne("MyWebLog.Data.Post", "Post") + .WithMany("PriorPermalinks") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostRevision", b => + { + b.HasOne("MyWebLog.Data.Post", "Post") + .WithMany("Revisions") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.HasOne("MyWebLog.Data.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyWebLog.Data.Tag", null) + .WithMany() + .HasForeignKey("TagsName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.Navigation("PriorPermalinks"); + + b.Navigation("Revisions"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.Navigation("PriorPermalinks"); + + b.Navigation("Revisions"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogUser", b => + { + b.Navigation("Pages"); + + b.Navigation("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MyWebLog.Data/MyWebLog.Data.csproj b/src/MyWebLog.Data/MyWebLog.Data.csproj index 132c02c..4231758 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.csproj +++ b/src/MyWebLog.Data/MyWebLog.Data.csproj @@ -6,4 +6,15 @@ enable + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + True + diff --git a/src/MyWebLog.Data/WebLogDbContext.cs b/src/MyWebLog.Data/WebLogDbContext.cs index afbcd8f..e4fcb92 100644 --- a/src/MyWebLog.Data/WebLogDbContext.cs +++ b/src/MyWebLog.Data/WebLogDbContext.cs @@ -7,6 +7,14 @@ namespace MyWebLog.Data; /// public sealed class WebLogDbContext : DbContext { + /// + /// Create a new ID (short GUID) + /// + /// A new short GUID + /// https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID + public static string NewId() => + Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22]; + /// /// The categories for the web log /// @@ -53,6 +61,10 @@ public sealed class WebLogDbContext : DbContext { base.OnModelCreating(modelBuilder); + // Make tables use singular names + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + entityType.SetTableName(entityType.DisplayName().Split(' ')[0]); + // Tag and WebLogDetails use Name as its ID modelBuilder.Entity().HasKey(t => t.Name); modelBuilder.Entity().HasKey(wld => wld.Name); diff --git a/src/MyWebLog.Data/WebLogDetails.cs b/src/MyWebLog.Data/WebLogDetails.cs index b891669..e6e1d9a 100644 --- a/src/MyWebLog.Data/WebLogDetails.cs +++ b/src/MyWebLog.Data/WebLogDetails.cs @@ -20,10 +20,15 @@ public class WebLogDetails /// public string DefaultPage { get; set; } = ""; + /// + /// The number of posts to display on pages of posts + /// + public byte PostsPerPage { get; set; } = 10; + /// /// The path of the theme (within /views/themes) /// - public string ThemePath { get; set; } = ""; + public string ThemePath { get; set; } = "Default"; /// /// The URL base diff --git a/src/MyWebLog.Data/WebLogUser.cs b/src/MyWebLog.Data/WebLogUser.cs index 075e83f..3cec46c 100644 --- a/src/MyWebLog.Data/WebLogUser.cs +++ b/src/MyWebLog.Data/WebLogUser.cs @@ -45,6 +45,11 @@ public class WebLogUser /// public string? Url { get; set; } = null; + /// + /// The user's authorization level + /// + public AuthorizationLevel AuthorizationLevel { get; set; } = AuthorizationLevel.User; + /// /// Pages written by this author /// diff --git a/src/MyWebLog.sln b/src/MyWebLog.sln index 5251070..be8542f 100644 --- a/src/MyWebLog.sln +++ b/src/MyWebLog.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32210.238 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{2E5E2346-25FE-4CBD-89AA-6148A33DE09C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -13,14 +13,14 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Release|Any CPU.Build.0 = Release|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.Build.0 = Release|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/MyWebLog/Db/.gitignore b/src/MyWebLog/Db/.gitignore new file mode 100644 index 0000000..778e729 --- /dev/null +++ b/src/MyWebLog/Db/.gitignore @@ -0,0 +1 @@ +*.db* diff --git a/src/MyWebLog/Domain.fs b/src/MyWebLog/Domain.fs deleted file mode 100644 index b1cf41d..0000000 --- a/src/MyWebLog/Domain.fs +++ /dev/null @@ -1,489 +0,0 @@ -namespace MyWebLog.Domain - -// -- Supporting Types -- - -/// Types of markup text supported -type MarkupText = - /// Text in Markdown format - | Markdown of string - /// Text in HTML format - | Html of string - -/// Functions to support maniuplating markup text -module MarkupText = - /// Get the string representation of this markup text - let toString it = - match it with - | Markdown x -> "Markdown", x - | Html x -> "HTML", x - ||> sprintf "%s: %s" - /// Get the HTML value of the text - let toHtml = function - | Markdown it -> sprintf "TODO: convert to HTML - %s" it - | Html it -> it - /// Parse a string representation to markup text - let ofString (it : string) = - match true with - | _ when it.StartsWith "Markdown: " -> it.Substring 10 |> Markdown - | _ when it.StartsWith "HTML: " -> it.Substring 6 |> Html - | _ -> sprintf "Cannot determine text type - %s" it |> invalidOp - - -/// Authorization levels -type AuthorizationLevel = - /// Authorization to administer a weblog - | Administrator - /// Authorization to comment on a weblog - | User - -/// Functions to support authorization levels -module AuthorizationLevel = - /// Get the string reprsentation of an authorization level - let toString = function Administrator -> "Administrator" | User -> "User" - /// Create an authorization level from a string - let ofString it = - match it with - | "Administrator" -> Administrator - | "User" -> User - | _ -> sprintf "%s is not an authorization level" it |> invalidOp - - -/// Post statuses -type PostStatus = - /// Post has not been released for public consumption - | Draft - /// Post is released - | Published - -/// Functions to support post statuses -module PostStatus = - /// Get the string representation of a post status - let toString = function Draft -> "Draft" | Published -> "Published" - /// Create a post status from a string - let ofString it = - match it with - | "Draft" -> Draft - | "Published" -> Published - | _ -> sprintf "%s is not a post status" it |> invalidOp - - -/// Comment statuses -type CommentStatus = - /// Comment is approved - | Approved - /// Comment has yet to be approved - | Pending - /// Comment was flagged as spam - | Spam - -/// Functions to support comment statuses -module CommentStatus = - /// Get the string representation of a comment status - let toString = function Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam" - /// Create a comment status from a string - let ofString it = - match it with - | "Approved" -> Approved - | "Pending" -> Pending - | "Spam" -> Spam - | _ -> sprintf "%s is not a comment status" it |> invalidOp - - -/// Seconds since the Unix epoch -type UnixSeconds = UnixSeconds of int64 - -/// Functions to support Unix seconds -module UnixSeconds = - /// Get the long (int64) representation of Unix seconds - let toLong = function UnixSeconds it -> it - /// Zero seconds past the epoch - let none = UnixSeconds 0L - - -// -- IDs -- - -open System - -// See https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID for info on "short GUIDs" - -/// A short GUID -type ShortGuid = ShortGuid of Guid - -/// Functions to support short GUIDs -module ShortGuid = - /// Encode a GUID into a short GUID - let toString = function - | ShortGuid guid -> - Convert.ToBase64String(guid.ToByteArray ()) - .Replace("/", "_") - .Replace("+", "-") - .Substring (0, 22) - /// Decode a short GUID into a GUID - let ofString (it : string) = - it.Replace("_", "/").Replace ("-", "+") - |> (sprintf "%s==" >> Convert.FromBase64String >> Guid >> ShortGuid) - /// Create a new short GUID - let create () = (Guid.NewGuid >> ShortGuid) () - /// The empty short GUID - let empty = ShortGuid Guid.Empty - - -/// The ID of a category -type CategoryId = CategoryId of ShortGuid - -/// Functions to support category IDs -module CategoryId = - /// Get the string representation of a page ID - let toString = function CategoryId it -> ShortGuid.toString it - /// Create a category ID from its string representation - let ofString = ShortGuid.ofString >> CategoryId - /// An empty category ID - let empty = CategoryId ShortGuid.empty - - -/// The ID of a comment -type CommentId = CommentId of ShortGuid - -/// Functions to support comment IDs -module CommentId = - /// Get the string representation of a comment ID - let toString = function CommentId it -> ShortGuid.toString it - /// Create a comment ID from its string representation - let ofString = ShortGuid.ofString >> CommentId - /// An empty comment ID - let empty = CommentId ShortGuid.empty - - -/// The ID of a page -type PageId = PageId of ShortGuid - -/// Functions to support page IDs -module PageId = - /// Get the string representation of a page ID - let toString = function PageId it -> ShortGuid.toString it - /// Create a page ID from its string representation - let ofString = ShortGuid.ofString >> PageId - /// An empty page ID - let empty = PageId ShortGuid.empty - - -/// The ID of a post -type PostId = PostId of ShortGuid - -/// Functions to support post IDs -module PostId = - /// Get the string representation of a post ID - let toString = function PostId it -> ShortGuid.toString it - /// Create a post ID from its string representation - let ofString = ShortGuid.ofString >> PostId - /// An empty post ID - let empty = PostId ShortGuid.empty - - -/// The ID of a user -type UserId = UserId of ShortGuid - -/// Functions to support user IDs -module UserId = - /// Get the string representation of a user ID - let toString = function UserId it -> ShortGuid.toString it - /// Create a user ID from its string representation - let ofString = ShortGuid.ofString >> UserId - /// An empty user ID - let empty = UserId ShortGuid.empty - - -/// The ID of a web log -type WebLogId = WebLogId of ShortGuid - -/// Functions to support web log IDs -module WebLogId = - /// Get the string representation of a web log ID - let toString = function WebLogId it -> ShortGuid.toString it - /// Create a web log ID from its string representation - let ofString = ShortGuid.ofString >> WebLogId - /// An empty web log ID - let empty = WebLogId ShortGuid.empty - - -// -- Domain Entities -- -// fsharplint:disable RecordFieldNames - -/// A revision of a post or page -type Revision = { - /// The instant which this revision was saved - asOf : UnixSeconds - /// The text - text : MarkupText - } -with - /// An empty revision - static member empty = - { asOf = UnixSeconds.none - text = Markdown "" - } - - -/// A page with static content -[] -type Page = { - /// The Id - id : PageId - /// The Id of the web log to which this page belongs - webLogId : WebLogId - /// The Id of the author of this page - authorId : UserId - /// The title of the page - title : string - /// The link at which this page is displayed - permalink : string - /// The instant this page was published - publishedOn : UnixSeconds - /// The instant this page was last updated - updatedOn : UnixSeconds - /// Whether this page shows as part of the web log's navigation - showInPageList : bool - /// The current text of the page - text : MarkupText - /// Revisions of this page - revisions : Revision list - } -with - static member empty = - { id = PageId.empty - webLogId = WebLogId.empty - authorId = UserId.empty - title = "" - permalink = "" - publishedOn = UnixSeconds.none - updatedOn = UnixSeconds.none - showInPageList = false - text = Markdown "" - revisions = [] - } - - -/// An entry in the list of pages displayed as part of the web log (derived via query) -type PageListEntry = { - /// The permanent link for the page - permalink : string - /// The title of the page - title : string - } - - -/// A web log -[] -type WebLog = { - /// The Id - id : WebLogId - /// The name - name : string - /// The subtitle - subtitle : string option - /// The default page ("posts" or a page Id) - defaultPage : string - /// The path of the theme (within /views/themes) - themePath : string - /// The URL base - urlBase : string - /// The time zone in which dates/times should be displayed - timeZone : string - /// A list of pages to be rendered as part of the site navigation (not stored) - pageList : PageListEntry list - } -with - /// An empty web log - static member empty = - { id = WebLogId.empty - name = "" - subtitle = None - defaultPage = "" - themePath = "default" - urlBase = "" - timeZone = "America/New_York" - pageList = [] - } - - -/// An authorization between a user and a web log -type Authorization = { - /// The Id of the web log to which this authorization grants access - webLogId : WebLogId - /// The level of access granted by this authorization - level : AuthorizationLevel -} - - -/// A user of myWebLog -[] -type User = { - /// The Id - id : UserId - /// The user name (e-mail address) - userName : string - /// The first name - firstName : string - /// The last name - lastName : string - /// The user's preferred name - preferredName : string - /// The hash of the user's password - passwordHash : string - /// The URL of the user's personal site - url : string option - /// The user's authorizations - authorizations : Authorization list - } -with - /// An empty user - static member empty = - { id = UserId.empty - userName = "" - firstName = "" - lastName = "" - preferredName = "" - passwordHash = "" - url = None - authorizations = [] - } - -/// Functions supporting users -module User = - /// Claims for this user - let claims user = - user.authorizations - |> List.map (fun a -> sprintf "%s|%s" (WebLogId.toString a.webLogId) (AuthorizationLevel.toString a.level)) - - -/// A category to which posts may be assigned -[] -type Category = { - /// The Id - id : CategoryId - /// The Id of the web log to which this category belongs - webLogId : WebLogId - /// The displayed name - name : string - /// The slug (used in category URLs) - slug : string - /// A longer description of the category - description : string option - /// The parent Id of this category (if a subcategory) - parentId : CategoryId option - /// The categories for which this category is the parent - children : CategoryId list - } -with - /// An empty category - static member empty = - { id = CategoryId.empty - webLogId = WebLogId.empty - name = "" - slug = "" - description = None - parentId = None - children = [] - } - - -/// A comment (applies to a post) -[] -type Comment = { - /// The Id - id : CommentId - /// The Id of the post to which this comment applies - postId : PostId - /// The Id of the comment to which this comment is a reply - inReplyToId : CommentId option - /// The name of the commentor - name : string - /// The e-mail address of the commentor - email : string - /// The URL of the commentor's personal website - url : string option - /// The status of the comment - status : CommentStatus - /// The instant the comment was posted - postedOn : UnixSeconds - /// The text of the comment - text : string - } -with - static member empty = - { id = CommentId.empty - postId = PostId.empty - inReplyToId = None - name = "" - email = "" - url = None - status = Pending - postedOn = UnixSeconds.none - text = "" - } - - -/// A post -[] -type Post = { - /// The Id - id : PostId - /// The Id of the web log to which this post belongs - webLogId : WebLogId - /// The Id of the author of this post - authorId : UserId - /// The status - status : PostStatus - /// The title - title : string - /// The link at which the post resides - permalink : string - /// The instant on which the post was originally published - publishedOn : UnixSeconds - /// The instant on which the post was last updated - updatedOn : UnixSeconds - /// The text of the post - text : MarkupText - /// The Ids of the categories to which this is assigned - categoryIds : CategoryId list - /// The tags for the post - tags : string list - /// The permalinks at which this post may have once resided - priorPermalinks : string list - /// Revisions of this post - revisions : Revision list - /// The categories to which this is assigned (not stored in database) - categories : Category list - /// The comments (not stored in database) - comments : Comment list - } -with - static member empty = - { id = PostId.empty - webLogId = WebLogId.empty - authorId = UserId.empty - status = Draft - title = "" - permalink = "" - publishedOn = UnixSeconds.none - updatedOn = UnixSeconds.none - text = Markdown "" - categoryIds = [] - tags = [] - priorPermalinks = [] - revisions = [] - categories = [] - comments = [] - } - -// --- UI Support --- - -/// Counts of items displayed on the admin dashboard -type DashboardCounts = { - /// The number of pages for the web log - pages : int - /// The number of pages for the web log - posts : int - /// The number of categories for the web log - categories : int - } diff --git a/src/MyWebLog/Features/FeatureSupport.cs b/src/MyWebLog/Features/FeatureSupport.cs new file mode 100644 index 0000000..041ebcb --- /dev/null +++ b/src/MyWebLog/Features/FeatureSupport.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Razor; +using System.Collections.Concurrent; +using System.Reflection; + +namespace MyWebLog.Features; + +/// +/// A controller model convention that identifies the feature in which a controller exists +/// +public class FeatureControllerModelConvention : IControllerModelConvention +{ + /// + /// A cache of controller types to features + /// + private static readonly ConcurrentDictionary _features = new(); + + /// + /// Derive the feature name from the controller's type + /// + private static string? GetFeatureName(TypeInfo typ) + { + var cacheKey = typ.FullName ?? ""; + if (_features.ContainsKey(cacheKey)) return _features[cacheKey]; + + var tokens = cacheKey.Split('.'); + if (tokens.Any(it => it == "Features")) + { + var feature = tokens.SkipWhile(it => it != "Features").Skip(1).Take(1).FirstOrDefault(); + if (feature is not null) + { + _features[cacheKey] = feature; + return feature; + } + } + return null; + } + + /// + public void Apply(ControllerModel controller) => + controller.Properties.Add("feature", GetFeatureName(controller.ControllerType)); + +} + +/// +/// Expand the location token with the feature name +/// +public class FeatureViewLocationExpander : IViewLocationExpander +{ + /// + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + _ = viewLocations ?? throw new ArgumentNullException(nameof(viewLocations)); + if (context.ActionContext.ActionDescriptor is not ControllerActionDescriptor descriptor) + throw new ArgumentException("ActionDescriptor not found"); + + var feature = descriptor.Properties["feature"] as string ?? ""; + foreach (var location in viewLocations) + yield return location.Replace("{2}", feature); + } + + /// + public void PopulateValues(ViewLocationExpanderContext _) { } +} diff --git a/src/MyWebLog/Features/Posts/PostController.cs b/src/MyWebLog/Features/Posts/PostController.cs new file mode 100644 index 0000000..123d6c8 --- /dev/null +++ b/src/MyWebLog/Features/Posts/PostController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Posts; + +/// +/// Handle post-related requests +/// +public class PostController : MyWebLogController +{ + /// + public PostController(WebLogDbContext db) : base(db) { } + + [HttpGet("~/")] + public async Task Index() + { + var webLog = WebLogCache.Get(HttpContext); + if (webLog.DefaultPage == "posts") + { + var posts = await Db.Posts.FindPageOfPublishedPosts(1, webLog.PostsPerPage); + return ThemedView("Index", posts); + } + var page = await Db.Pages.FindById(webLog.DefaultPage); + return page is null ? NotFound() : ThemedView("SinglePage", page); + } +} diff --git a/src/MyWebLog/Features/Shared/MyWebLogController.cs b/src/MyWebLog/Features/Shared/MyWebLogController.cs new file mode 100644 index 0000000..a6bd7b3 --- /dev/null +++ b/src/MyWebLog/Features/Shared/MyWebLogController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Shared; + +public abstract class MyWebLogController : Controller +{ + /// + /// The data context to use to fulfil this request + /// + protected WebLogDbContext Db { get; init; } + + /// + /// Constructor + /// + /// The data context to use to fulfil this request + protected MyWebLogController(WebLogDbContext db) + { + Db = db; + } + + protected ViewResult ThemedView(string template, object model) + { + return View(template, model); + } +} diff --git a/src/MyWebLog/Features/ThemeSupport.cs b/src/MyWebLog/Features/ThemeSupport.cs new file mode 100644 index 0000000..1e495e8 --- /dev/null +++ b/src/MyWebLog/Features/ThemeSupport.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc.Razor; + +namespace MyWebLog.Features; + +/// +/// Expand the location token with the theme path +/// +public class ThemeViewLocationExpander : IViewLocationExpander +{ + /// + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + _ = viewLocations ?? throw new ArgumentNullException(nameof(viewLocations)); + + foreach (var location in viewLocations) + yield return location.Replace("{3}", context.Values["theme"]!); + } + + /// + public void PopulateValues(ViewLocationExpanderContext context) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + context.Values["theme"] = WebLogCache.Get(context.ActionContext.HttpContext).ThemePath; + } +} diff --git a/src/MyWebLog/Features/Users/UserController.cs b/src/MyWebLog/Features/Users/UserController.cs new file mode 100644 index 0000000..7123c8a --- /dev/null +++ b/src/MyWebLog/Features/Users/UserController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; +using System.Text; + +namespace MyWebLog.Features.Users; + +/// +/// Controller for the users feature +/// +public class UserController : MyWebLogController +{ + /// + /// Hash a password for a given user + /// + /// The plain-text password + /// The user's e-mail address + /// The user-specific salt + /// + internal static string HashedPassword(string plainText, string email, Guid salt) + { + var allSalt = salt.ToByteArray().Concat(Encoding.UTF8.GetBytes(email)).ToArray(); + using var alg = new Rfc2898DeriveBytes(plainText, allSalt, 2_048); + return Convert.ToBase64String(alg.GetBytes(64)); + } + + /// + public UserController(WebLogDbContext db) : base(db) { } + + public IActionResult Index() + { + return View(); + } +} diff --git a/src/MyWebLog/GlobalUsings.cs b/src/MyWebLog/GlobalUsings.cs new file mode 100644 index 0000000..f3f143e --- /dev/null +++ b/src/MyWebLog/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using MyWebLog.Data; +global using MyWebLog.Features.Shared; diff --git a/src/MyWebLog/MyWebLog.csproj b/src/MyWebLog/MyWebLog.csproj new file mode 100644 index 0000000..c0b846f --- /dev/null +++ b/src/MyWebLog/MyWebLog.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj deleted file mode 100644 index e11ac96..0000000 --- a/src/MyWebLog/MyWebLog.fsproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - Exe - net6.0 - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/MyWebLog/Program.cs b/src/MyWebLog/Program.cs new file mode 100644 index 0000000..6f3b234 --- /dev/null +++ b/src/MyWebLog/Program.cs @@ -0,0 +1,131 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using MyWebLog; +using MyWebLog.Features; +using MyWebLog.Features.Users; + +if (args.Length > 0 && args[0] == "init") +{ + await InitDb(); + return; +} + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMvc(opts => opts.Conventions.Add(new FeatureControllerModelConvention())) + .AddRazorOptions(opts => + { + opts.ViewLocationFormats.Clear(); + opts.ViewLocationFormats.Add("/Themes/{3}/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Themes/{3}/Shared/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Themes/Default/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Themes/Default/Shared/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Features/{2}/{1}/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Features/{2}/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Features/Shared/{0}.cshtml"); + opts.ViewLocationExpanders.Add(new FeatureViewLocationExpander()); + opts.ViewLocationExpanders.Add(new ThemeViewLocationExpander()); + }); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(opts => + { + opts.ExpireTimeSpan = TimeSpan.FromMinutes(20); + opts.SlidingExpiration = true; + opts.AccessDeniedPath = "/forbidden"; + }); +builder.Services.AddAuthorization(); +builder.Services.AddSingleton(); +builder.Services.AddDbContext(o => +{ + // TODO: can get from DI? + var db = WebLogCache.HostToDb(new HttpContextAccessor().HttpContext!); + // "Data Source=Db/empty.db" + o.UseSqlite($"Data Source=Db/{db}.db"); +}); + +var app = builder.Build(); + +app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict }); +app.UseMiddleware(); +app.UseAuthentication(); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); +app.UseEndpoints(endpoints => endpoints.MapControllers()); + +app.Run(); + +/// +/// Initialize a new database +/// +async Task InitDb() +{ + if (args.Length != 5) + { + Console.WriteLine("Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"); + return; + } + + using var db = new WebLogDbContext(new DbContextOptionsBuilder() + .UseSqlite($"Data Source=Db/{args[1].Replace(':', '_')}.db").Options); + await db.Database.MigrateAsync(); + + // Create the admin user + var salt = Guid.NewGuid(); + var user = new WebLogUser + { + Id = WebLogDbContext.NewId(), + UserName = args[3], + FirstName = "Admin", + LastName = "User", + PreferredName = "Admin", + PasswordHash = UserController.HashedPassword(args[4], args[3], salt), + Salt = salt, + AuthorizationLevel = AuthorizationLevel.Administrator + }; + await db.Users.AddAsync(user); + + // Create the default home page + var home = new Page + { + Id = WebLogDbContext.NewId(), + AuthorId = user.Id, + Title = "Welcome to myWebLog!", + Permalink = "welcome-to-myweblog.html", + PublishedOn = DateTime.UtcNow, + UpdatedOn = DateTime.UtcNow, + Text = "

This is your default home page.

", + Revisions = new[] + { + new PageRevision + { + Id = WebLogDbContext.NewId(), + AsOf = DateTime.UtcNow, + SourceType = RevisionSource.Html, + Text = "

This is your default home page.

" + } + } + }; + await db.Pages.AddAsync(home); + + // Add the details + var timeZone = TimeZoneInfo.Local.Id; + if (!TimeZoneInfo.Local.HasIanaId) + { + timeZone = TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZone, out var ianaId) + ? ianaId + : throw new TimeZoneNotFoundException($"Cannot find IANA timezone for {timeZone}"); + } + var details = new WebLogDetails + { + Name = args[2], + UrlBase = args[1], + DefaultPage = home.Id, + TimeZone = timeZone + }; + await db.WebLogDetails.AddAsync(details); + + await db.SaveChangesAsync(); + + Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}"); +} diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs deleted file mode 100644 index 139a10e..0000000 --- a/src/MyWebLog/Program.fs +++ /dev/null @@ -1,4 +0,0 @@ -open MyWebLog -open Suave - -startWebServer defaultConfig (Successful.OK (Strings.get "LastUpdated")) diff --git a/src/MyWebLog/Properties/launchSettings.json b/src/MyWebLog/Properties/launchSettings.json new file mode 100644 index 0000000..7d7face --- /dev/null +++ b/src/MyWebLog/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:3330", + "sslPort": 0 + } + }, + "profiles": { + "MyWebLog": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5010", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/MyWebLog/Resources/en-US.json b/src/MyWebLog/Resources/en-US.json deleted file mode 100644 index be2715a..0000000 --- a/src/MyWebLog/Resources/en-US.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "Action": "Action", - "Added": "Added", - "AddNew": "Add New", - "AddNewCategory": "Add New Category", - "AddNewPage": "Add New Page", - "AddNewPost": "Add New Post", - "Admin": "Admin", - "AndPublished": " and Published", - "andXMore": "and {0} more...", - "at": "at", - "BackToCategoryList": "Back to Category List", - "BackToPageList": "Back to Page List", - "BackToPostList": "Back to Post List", - "Categories": "Categories", - "Category": "Category", - "CategoryDeleteWarning": "Are you sure you wish to delete the category", - "Close": "Close", - "Comments": "Comments", - "Dashboard": "Dashboard", - "Date": "Date", - "Delete": "Delete", - "Description": "Description", - "Edit": "Edit", - "EditCategory": "Edit Category", - "EditPage": "Edit Page", - "EditPost": "Edit Post", - "EmailAddress": "E-mail Address", - "ErrBadAppConfig": "Could not convert config.json to myWebLog configuration", - "ErrBadLogOnAttempt": "Invalid e-mail address or password", - "ErrDataConfig": "Could not convert data-config.json to RethinkDB connection", - "ErrNotConfigured": "is not properly configured for myWebLog", - "Error": "Error", - "LastUpdated": "Last Updated", - "LastUpdatedDate": "Last Updated Date", - "ListAll": "List All", - "LoadedIn": "Loaded in", - "LogOff": "Log Off", - "LogOn": "Log On", - "MsgCategoryDeleted": "Deleted category {0} successfully", - "MsgCategoryEditSuccess": "{0} category successfully", - "MsgLogOffSuccess": "Log off successful | Have a nice day!", - "MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!", - "MsgPageDeleted": "Deleted page successfully", - "MsgPageEditSuccess": "{0} page successfully", - "MsgPostEditSuccess": "{0}{1} post successfully", - "Name": "Name", - "NewerPosts": "Newer Posts", - "NextPost": "Next Post", - "NoComments": "No Comments", - "NoParent": "No Parent", - "OlderPosts": "Older Posts", - "OneComment": "1 Comment", - "PageDeleteWarning": "Are you sure you wish to delete the page", - "PageDetails": "Page Details", - "PageHash": "Page #", - "Pages": "Pages", - "ParentCategory": "Parent Category", - "Password": "Password", - "Permalink": "Permalink", - "PermanentLinkTo": "Permanent Link to", - "PostDetails": "Post Details", - "Posts": "Posts", - "PostsTagged": "Posts Tagged", - "PostStatus": "Post Status", - "PoweredBy": "Powered by", - "PreviousPost": "Previous Post", - "PublishedDate": "Published Date", - "PublishThisPost": "Publish This Post", - "Save": "Save", - "Seconds": "Seconds", - "ShowInPageList": "Show in Page List", - "Slug": "Slug", - "startingWith": "starting with", - "Status": "Status", - "Tags": "Tags", - "Time": "Time", - "Title": "Title", - "Updated": "Updated", - "View": "View", - "Warning": "Warning", - "XComments": "{0} Comments" -} diff --git a/src/MyWebLog/Strings.fs b/src/MyWebLog/Strings.fs deleted file mode 100644 index 55a725b..0000000 --- a/src/MyWebLog/Strings.fs +++ /dev/null @@ -1,40 +0,0 @@ -module MyWebLog.Strings - -open System.Collections.Generic -open System.Globalization -open System.IO -open System.Reflection -open System.Text.Json - -/// The locales we'll try to load -let private supportedLocales = [ "en-US" ] - -/// The fallback locale, if a key is not found in a non-default locale -let private fallbackLocale = "en-US" - -/// Get an embedded JSON file as a string -let private getEmbedded locale = - let str = sprintf "MyWebLog.Resources.%s.json" locale |> Assembly.GetExecutingAssembly().GetManifestResourceStream - use rdr = new StreamReader (str) - rdr.ReadToEnd() - -/// The dictionary of localized strings -let private strings = - supportedLocales - |> List.map (fun loc -> loc, getEmbedded loc |> JsonSerializer.Deserialize>) - |> dict - -/// Get a key from the resources file for the given locale -let getForLocale locale key = - let getString thisLocale = - match strings.ContainsKey thisLocale && strings.[thisLocale].ContainsKey key with - | true -> Some strings.[thisLocale].[key] - | false -> None - match getString locale with - | Some xlat -> Some xlat - | None when locale <> fallbackLocale -> getString fallbackLocale - | None -> None - |> function Some xlat -> xlat | None -> sprintf "%s.%s" locale key - -/// Translate the key for the current locale -let get key = getForLocale CultureInfo.CurrentCulture.Name key diff --git a/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml new file mode 100644 index 0000000..52c9886 --- /dev/null +++ b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml @@ -0,0 +1,14 @@ +@inject IHttpContextAccessor ctxAcc + + + + + + @ViewBag.Title « @WebLogCache.Get(ctxAcc.HttpContext!).Name + + +
+ @RenderBody() +
+ + diff --git a/src/MyWebLog/Themes/Default/SinglePage.cshtml b/src/MyWebLog/Themes/Default/SinglePage.cshtml new file mode 100644 index 0000000..51c3352 --- /dev/null +++ b/src/MyWebLog/Themes/Default/SinglePage.cshtml @@ -0,0 +1,9 @@ +@model Page +@{ + Layout = "_Layout"; + ViewBag.Title = Model.Title; +} +

@Model.Title

+
+ @Html.Raw(Model.Text) +
diff --git a/src/MyWebLog/Themes/_ViewImports.cshtml b/src/MyWebLog/Themes/_ViewImports.cshtml new file mode 100644 index 0000000..eaf3b3e --- /dev/null +++ b/src/MyWebLog/Themes/_ViewImports.cshtml @@ -0,0 +1 @@ +@namespace MyWebLog.Themes diff --git a/src/MyWebLog/WebLogMiddleware.cs b/src/MyWebLog/WebLogMiddleware.cs new file mode 100644 index 0000000..c285b79 --- /dev/null +++ b/src/MyWebLog/WebLogMiddleware.cs @@ -0,0 +1,90 @@ +using System.Collections.Concurrent; + +namespace MyWebLog; + +/// +/// In-memory cache of web log details +/// +/// This is filled by the middleware via the first request for each host, and can be updated via the web log +/// settings update page +public static class WebLogCache +{ + /// + /// The cache of web log details + /// + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Transform a hostname to a database name + /// + /// The current HTTP context + /// The hostname, with an underscore replacing a colon + public static string HostToDb(HttpContext ctx) => ctx.Request.Host.ToUriComponent().Replace(':', '_'); + + /// + /// Does a host exist in the cache? + /// + /// The host in question + /// True if it exists, false if not + public static bool Exists(string host) => _cache.ContainsKey(host); + + /// + /// Get the details for a web log via its host + /// + /// The host which should be retrieved + /// The web log details + public static WebLogDetails Get(string host) => _cache[host]; + + /// + /// Get the details for a web log via its host + /// + /// The HTTP context for the request + /// The web log details + public static WebLogDetails Get(HttpContext ctx) => _cache[HostToDb(ctx)]; + + /// + /// Set the details for a particular host + /// + /// The host for which details should be set + /// The details to be set + public static void Set(string host, WebLogDetails details) => _cache[host] = details; +} + +/// +/// Middleware to derive the current web log +/// +public class WebLogMiddleware +{ + /// + /// The next action in the pipeline + /// + private readonly RequestDelegate _next; + + /// + /// Constructor + /// + /// The next action in the pipeline + public WebLogMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var host = WebLogCache.HostToDb(context); + + if (WebLogCache.Exists(host)) return; + + var db = context.RequestServices.GetRequiredService(); + var details = await db.WebLogDetails.FindByHost(context.Request.Host.ToUriComponent()); + if (details == null) + { + context.Response.StatusCode = 404; + return; + } + + WebLogCache.Set(host, details); + + await _next.Invoke(context); + } +} diff --git a/src/MyWebLog/appsettings.Development.json b/src/MyWebLog/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/MyWebLog/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/MyWebLog/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} -- 2.45.1 From 39e0d5ec8b58713f9aa5621aa9439b524f5a2c83 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 27 Feb 2022 16:15:35 -0500 Subject: [PATCH 006/102] Log on/off works; WIP on layout --- .../Extensions/WebLogUserExtensions.cs | 16 ++ src/MyWebLog.Data/WebLogDbContext.cs | 6 + .../Features/Admin/AdminController.cs | 19 +++ src/MyWebLog/Features/Admin/Index.cshtml | 5 + .../Features/Shared/_AdminLayout.cshtml | 48 ++++++ .../Features/Shared/_LogOnOffPartial.cshtml | 11 ++ src/MyWebLog/Features/Users/LogOn.cshtml | 30 ++++ src/MyWebLog/Features/Users/LogOnModel.cs | 24 +++ src/MyWebLog/Features/Users/UserController.cs | 51 ++++++- src/MyWebLog/Features/_ViewImports.cshtml | 6 + src/MyWebLog/GlobalUsings.cs | 1 + src/MyWebLog/MyWebLog.csproj | 17 ++- src/MyWebLog/Program.cs | 32 ++-- src/MyWebLog/Properties/Resources.Designer.cs | 117 +++++++++++++++ src/MyWebLog/Properties/Resources.resx | 138 ++++++++++++++++++ .../Default/Shared/_DefaultFooter.cshtml | 6 + .../Default/Shared/_DefaultHeader.cshtml | 23 +++ .../Themes/Default/Shared/_Layout.cshtml | 31 +++- src/MyWebLog/WebLogMiddleware.cs | 19 +-- src/MyWebLog/wwwroot/css/admin.css | 5 + src/MyWebLog/wwwroot/img/logo-dark.png | Bin 0 -> 3362 bytes src/MyWebLog/wwwroot/img/logo-light.png | Bin 0 -> 4135 bytes 22 files changed, 573 insertions(+), 32 deletions(-) create mode 100644 src/MyWebLog.Data/Extensions/WebLogUserExtensions.cs create mode 100644 src/MyWebLog/Features/Admin/AdminController.cs create mode 100644 src/MyWebLog/Features/Admin/Index.cshtml create mode 100644 src/MyWebLog/Features/Shared/_AdminLayout.cshtml create mode 100644 src/MyWebLog/Features/Shared/_LogOnOffPartial.cshtml create mode 100644 src/MyWebLog/Features/Users/LogOn.cshtml create mode 100644 src/MyWebLog/Features/Users/LogOnModel.cs create mode 100644 src/MyWebLog/Features/_ViewImports.cshtml create mode 100644 src/MyWebLog/Properties/Resources.Designer.cs create mode 100644 src/MyWebLog/Properties/Resources.resx create mode 100644 src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml create mode 100644 src/MyWebLog/Themes/Default/Shared/_DefaultHeader.cshtml create mode 100644 src/MyWebLog/wwwroot/css/admin.css create mode 100644 src/MyWebLog/wwwroot/img/logo-dark.png create mode 100644 src/MyWebLog/wwwroot/img/logo-light.png diff --git a/src/MyWebLog.Data/Extensions/WebLogUserExtensions.cs b/src/MyWebLog.Data/Extensions/WebLogUserExtensions.cs new file mode 100644 index 0000000..71d614e --- /dev/null +++ b/src/MyWebLog.Data/Extensions/WebLogUserExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class WebLogUserExtensions +{ + /// + /// Find a user by their log on information (non-tracked) + /// + /// The user's e-mail address + /// The hash of the password provided by the user + /// The user, if the credentials match; null if they do not + public static async Task FindByEmail(this DbSet db, string email) => + await db.SingleOrDefaultAsync(wlu => wlu.UserName == email).ConfigureAwait(false); + +} diff --git a/src/MyWebLog.Data/WebLogDbContext.cs b/src/MyWebLog.Data/WebLogDbContext.cs index e4fcb92..3d63b97 100644 --- a/src/MyWebLog.Data/WebLogDbContext.cs +++ b/src/MyWebLog.Data/WebLogDbContext.cs @@ -56,6 +56,12 @@ public sealed class WebLogDbContext : DbContext /// Configuration options public WebLogDbContext(DbContextOptions options) : base(options) { } + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + } + /// protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/MyWebLog/Features/Admin/AdminController.cs b/src/MyWebLog/Features/Admin/AdminController.cs new file mode 100644 index 0000000..088ee6e --- /dev/null +++ b/src/MyWebLog/Features/Admin/AdminController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Admin; + +/// +/// Controller for admin-specific displays and routes +/// +[Route("/admin")] +public class AdminController : MyWebLogController +{ + /// + public AdminController(WebLogDbContext db) : base(db) { } + + [HttpGet("")] + public IActionResult Index() + { + return View(); + } +} diff --git a/src/MyWebLog/Features/Admin/Index.cshtml b/src/MyWebLog/Features/Admin/Index.cshtml new file mode 100644 index 0000000..52fecf9 --- /dev/null +++ b/src/MyWebLog/Features/Admin/Index.cshtml @@ -0,0 +1,5 @@ +@{ + Layout = "_AdminLayout"; + ViewBag.Title = Resources.Dashboard; +} +

You're logged on!

diff --git a/src/MyWebLog/Features/Shared/_AdminLayout.cshtml b/src/MyWebLog/Features/Shared/_AdminLayout.cshtml new file mode 100644 index 0000000..d32e32f --- /dev/null +++ b/src/MyWebLog/Features/Shared/_AdminLayout.cshtml @@ -0,0 +1,48 @@ +@inject IHttpContextAccessor ctxAcc +@{ + var details = WebLogCache.Get(ctxAcc.HttpContext!); +} + + + + + @ViewBag.Title « @Resources.Admin « @details.Name + + + + +
+ +
+
+

@ViewBag.Title

+ @* Each.Messages + @Current.ToDisplay + @EndEach *@ + @RenderBody() +
+
+
+
+
myWebLog
+
+
+
+ + + diff --git a/src/MyWebLog/Features/Shared/_LogOnOffPartial.cshtml b/src/MyWebLog/Features/Shared/_LogOnOffPartial.cshtml new file mode 100644 index 0000000..c182a99 --- /dev/null +++ b/src/MyWebLog/Features/Shared/_LogOnOffPartial.cshtml @@ -0,0 +1,11 @@ + diff --git a/src/MyWebLog/Features/Users/LogOn.cshtml b/src/MyWebLog/Features/Users/LogOn.cshtml new file mode 100644 index 0000000..9a034e9 --- /dev/null +++ b/src/MyWebLog/Features/Users/LogOn.cshtml @@ -0,0 +1,30 @@ +@model LogOnModel +@{ + Layout = "_AdminLayout"; + ViewBag.Title = @Resources.LogOn; +} +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+
diff --git a/src/MyWebLog/Features/Users/LogOnModel.cs b/src/MyWebLog/Features/Users/LogOnModel.cs new file mode 100644 index 0000000..b215ea8 --- /dev/null +++ b/src/MyWebLog/Features/Users/LogOnModel.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWebLog.Features.Users; + +/// +/// The model to use to allow a user to log on +/// +public class LogOnModel +{ + /// + /// The user's e-mail address + /// + [Required(AllowEmptyStrings = false)] + [EmailAddress] + [Display(ResourceType = typeof(Resources), Name = "EmailAddress")] + public string EmailAddress { get; set; } = ""; + + /// + /// The user's password + /// + [Required(AllowEmptyStrings = false)] + [Display(ResourceType = typeof(Resources), Name = "Password")] + public string Password { get; set; } = ""; +} diff --git a/src/MyWebLog/Features/Users/UserController.cs b/src/MyWebLog/Features/Users/UserController.cs index 7123c8a..5a2856c 100644 --- a/src/MyWebLog/Features/Users/UserController.cs +++ b/src/MyWebLog/Features/Users/UserController.cs @@ -1,4 +1,8 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; using System.Security.Cryptography; using System.Text; @@ -7,6 +11,7 @@ namespace MyWebLog.Features.Users; /// /// Controller for the users feature /// +[Route("/user")] public class UserController : MyWebLogController { /// @@ -19,15 +24,53 @@ public class UserController : MyWebLogController internal static string HashedPassword(string plainText, string email, Guid salt) { var allSalt = salt.ToByteArray().Concat(Encoding.UTF8.GetBytes(email)).ToArray(); - using var alg = new Rfc2898DeriveBytes(plainText, allSalt, 2_048); + using Rfc2898DeriveBytes alg = new(plainText, allSalt, 2_048); return Convert.ToBase64String(alg.GetBytes(64)); } /// public UserController(WebLogDbContext db) : base(db) { } - public IActionResult Index() + [HttpGet("log-on")] + public IActionResult LogOn() => + View(new LogOnModel()); + + [HttpPost("log-on")] + public async Task DoLogOn(LogOnModel model) { - return View(); + var user = await Db.Users.FindByEmail(model.EmailAddress); + + if (user == null || user.PasswordHash != HashedPassword(model.Password, user.UserName, user.Salt)) + { + // TODO: make error, not 404 + return NotFound(); + } + + List claims = new() + { + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"), + new(ClaimTypes.GivenName, user.PreferredName), + new(ClaimTypes.Role, user.AuthorizationLevel.ToString()) + }; + ClaimsIdentity identity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme); + + await HttpContext.SignInAsync(identity.AuthenticationType, new(identity), + new() { IssuedUtc = DateTime.UtcNow }); + + // TODO: confirmation message + + return RedirectToAction("Index", "Admin"); + } + + [HttpGet("log-off")] + [Authorize] + public async Task LogOff() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + // TODO: confirmation message + + return LocalRedirect("~/"); } } diff --git a/src/MyWebLog/Features/_ViewImports.cshtml b/src/MyWebLog/Features/_ViewImports.cshtml new file mode 100644 index 0000000..65c93a1 --- /dev/null +++ b/src/MyWebLog/Features/_ViewImports.cshtml @@ -0,0 +1,6 @@ +@namespace MyWebLog.Features + +@using MyWebLog +@using MyWebLog.Properties + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/MyWebLog/GlobalUsings.cs b/src/MyWebLog/GlobalUsings.cs index f3f143e..45f4200 100644 --- a/src/MyWebLog/GlobalUsings.cs +++ b/src/MyWebLog/GlobalUsings.cs @@ -1,2 +1,3 @@ global using MyWebLog.Data; global using MyWebLog.Features.Shared; +global using MyWebLog.Properties; diff --git a/src/MyWebLog/MyWebLog.csproj b/src/MyWebLog/MyWebLog.csproj index c0b846f..0f4f91a 100644 --- a/src/MyWebLog/MyWebLog.csproj +++ b/src/MyWebLog/MyWebLog.csproj @@ -7,7 +7,7 @@ - + @@ -21,4 +21,19 @@ + + + True + True + Resources.resx + + + + + + PublicResXFileCodeGenerator + Resources.Designer.cs + + + diff --git a/src/MyWebLog/Program.cs b/src/MyWebLog/Program.cs index 6f3b234..2a16434 100644 --- a/src/MyWebLog/Program.cs +++ b/src/MyWebLog/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MyWebLog; using MyWebLog.Features; @@ -12,20 +13,23 @@ if (args.Length > 0 && args[0] == "init") var builder = WebApplication.CreateBuilder(args); -builder.Services.AddMvc(opts => opts.Conventions.Add(new FeatureControllerModelConvention())) - .AddRazorOptions(opts => - { - opts.ViewLocationFormats.Clear(); - opts.ViewLocationFormats.Add("/Themes/{3}/{0}.cshtml"); - opts.ViewLocationFormats.Add("/Themes/{3}/Shared/{0}.cshtml"); - opts.ViewLocationFormats.Add("/Themes/Default/{0}.cshtml"); - opts.ViewLocationFormats.Add("/Themes/Default/Shared/{0}.cshtml"); - opts.ViewLocationFormats.Add("/Features/{2}/{1}/{0}.cshtml"); - opts.ViewLocationFormats.Add("/Features/{2}/{0}.cshtml"); - opts.ViewLocationFormats.Add("/Features/Shared/{0}.cshtml"); - opts.ViewLocationExpanders.Add(new FeatureViewLocationExpander()); - opts.ViewLocationExpanders.Add(new ThemeViewLocationExpander()); - }); +builder.Services.AddMvc(opts => +{ + opts.Conventions.Add(new FeatureControllerModelConvention()); + opts.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); +}).AddRazorOptions(opts => +{ + opts.ViewLocationFormats.Clear(); + opts.ViewLocationFormats.Add("/Themes/{3}/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Themes/{3}/Shared/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Themes/Default/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Themes/Default/Shared/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Features/{2}/{1}/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Features/{2}/{0}.cshtml"); + opts.ViewLocationFormats.Add("/Features/Shared/{0}.cshtml"); + opts.ViewLocationExpanders.Add(new FeatureViewLocationExpander()); + opts.ViewLocationExpanders.Add(new ThemeViewLocationExpander()); +}); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(opts => { diff --git a/src/MyWebLog/Properties/Resources.Designer.cs b/src/MyWebLog/Properties/Resources.Designer.cs new file mode 100644 index 0000000..a087989 --- /dev/null +++ b/src/MyWebLog/Properties/Resources.Designer.cs @@ -0,0 +1,117 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace MyWebLog.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MyWebLog.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Admin. + /// + public static string Admin { + get { + return ResourceManager.GetString("Admin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard. + /// + public static string Dashboard { + get { + return ResourceManager.GetString("Dashboard", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to E-mail Address. + /// + public static string EmailAddress { + get { + return ResourceManager.GetString("EmailAddress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Log Off. + /// + public static string LogOff { + get { + return ResourceManager.GetString("LogOff", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Log On. + /// + public static string LogOn { + get { + return ResourceManager.GetString("LogOn", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Password. + /// + public static string Password { + get { + return ResourceManager.GetString("Password", resourceCulture); + } + } + } +} diff --git a/src/MyWebLog/Properties/Resources.resx b/src/MyWebLog/Properties/Resources.resx new file mode 100644 index 0000000..8a5e1ba --- /dev/null +++ b/src/MyWebLog/Properties/Resources.resx @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Admin + + + Dashboard + + + E-mail Address + + + Log Off + + + Log On + + + Password + + \ No newline at end of file diff --git a/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml b/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml new file mode 100644 index 0000000..b9f86bb --- /dev/null +++ b/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml @@ -0,0 +1,6 @@ +
+
+
+ myWebLog +
+
diff --git a/src/MyWebLog/Themes/Default/Shared/_DefaultHeader.cshtml b/src/MyWebLog/Themes/Default/Shared/_DefaultHeader.cshtml new file mode 100644 index 0000000..6a958c4 --- /dev/null +++ b/src/MyWebLog/Themes/Default/Shared/_DefaultHeader.cshtml @@ -0,0 +1,23 @@ +@inject IHttpContextAccessor ctxAcc +@{ + var details = WebLogCache.Get(ctxAcc.HttpContext!); +} +
+ +
diff --git a/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml index 52c9886..bc16fbe 100644 --- a/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml +++ b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml @@ -1,14 +1,37 @@ @inject IHttpContextAccessor ctxAcc +@{ + var details = WebLogCache.Get(ctxAcc.HttpContext!); +} - - @ViewBag.Title « @WebLogCache.Get(ctxAcc.HttpContext!).Name + + + + @await RenderSectionAsync("Style", false) + @ViewBag.Title « @details.Name -
+ @if (IsSectionDefined("Header")) + { + @await RenderSectionAsync("Header") + } + else + { + @await Html.PartialAsync("_DefaultHeader") + } +
@RenderBody() -
+ + @if (IsSectionDefined("Footer")) + { + @await RenderSectionAsync("Footer") + } else + { + @await Html.PartialAsync("_DefaultFooter") + } + @await RenderSectionAsync("Script", false) diff --git a/src/MyWebLog/WebLogMiddleware.cs b/src/MyWebLog/WebLogMiddleware.cs index c285b79..bc24d0e 100644 --- a/src/MyWebLog/WebLogMiddleware.cs +++ b/src/MyWebLog/WebLogMiddleware.cs @@ -73,17 +73,18 @@ public class WebLogMiddleware { var host = WebLogCache.HostToDb(context); - if (WebLogCache.Exists(host)) return; - - var db = context.RequestServices.GetRequiredService(); - var details = await db.WebLogDetails.FindByHost(context.Request.Host.ToUriComponent()); - if (details == null) + if (!WebLogCache.Exists(host)) { - context.Response.StatusCode = 404; - return; + var db = context.RequestServices.GetRequiredService(); + var details = await db.WebLogDetails.FindByHost(context.Request.Host.ToUriComponent()); + if (details == null) + { + context.Response.StatusCode = 404; + return; + } + + WebLogCache.Set(host, details); } - - WebLogCache.Set(host, details); await _next.Invoke(context); } diff --git a/src/MyWebLog/wwwroot/css/admin.css b/src/MyWebLog/wwwroot/css/admin.css new file mode 100644 index 0000000..f720fda --- /dev/null +++ b/src/MyWebLog/wwwroot/css/admin.css @@ -0,0 +1,5 @@ +footer { + background-color: #808080; + border-top: solid 1px black; + color: white; +} diff --git a/src/MyWebLog/wwwroot/img/logo-dark.png b/src/MyWebLog/wwwroot/img/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..19bdcca2af3095d7f18345a28fbf9c6da0363daa GIT binary patch literal 3362 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 l1PBlyK!5-N0tD!0_<#2 Date: Sun, 27 Feb 2022 20:01:53 -0500 Subject: [PATCH 007/102] First cut of admin dashboard Add web log details to common model --- .../Extensions/CategoryExtensions.cs | 13 +++++ .../Extensions/PageExtensions.cs | 17 +++++- .../Extensions/PostExtensions.cs | 16 ++++++ .../Features/Admin/AdminController.cs | 12 ++-- src/MyWebLog/Features/Admin/DashboardModel.cs | 30 ++++++++++ src/MyWebLog/Features/Admin/Index.cshtml | 34 ++++++++++- .../Features/Categories/CategoryController.cs | 29 ++++++++++ src/MyWebLog/Features/Pages/PageController.cs | 29 ++++++++++ .../Features/Pages/SinglePageModel.cs | 22 +++++++ .../Features/Posts/MultiplePostModel.cs | 22 +++++++ src/MyWebLog/Features/Posts/PostController.cs | 57 ++++++++++++++++--- .../Features/Shared/MyWebLogController.cs | 7 ++- src/MyWebLog/Features/Shared/MyWebLogModel.cs | 21 +++++++ .../Features/Shared/_AdminLayout.cshtml | 9 +-- src/MyWebLog/Features/Users/LogOnModel.cs | 8 ++- src/MyWebLog/Features/Users/UserController.cs | 2 +- src/MyWebLog/Properties/Resources.Designer.cs | 54 ++++++++++++++++++ src/MyWebLog/Properties/Resources.resx | 18 ++++++ .../Default/Shared/_DefaultHeader.cshtml | 11 ++-- .../Themes/Default/Shared/_Layout.cshtml | 11 ++-- src/MyWebLog/Themes/Default/SinglePage.cshtml | 9 +-- 21 files changed, 390 insertions(+), 41 deletions(-) create mode 100644 src/MyWebLog.Data/Extensions/CategoryExtensions.cs create mode 100644 src/MyWebLog/Features/Admin/DashboardModel.cs create mode 100644 src/MyWebLog/Features/Categories/CategoryController.cs create mode 100644 src/MyWebLog/Features/Pages/PageController.cs create mode 100644 src/MyWebLog/Features/Pages/SinglePageModel.cs create mode 100644 src/MyWebLog/Features/Posts/MultiplePostModel.cs create mode 100644 src/MyWebLog/Features/Shared/MyWebLogModel.cs diff --git a/src/MyWebLog.Data/Extensions/CategoryExtensions.cs b/src/MyWebLog.Data/Extensions/CategoryExtensions.cs new file mode 100644 index 0000000..37d4dd8 --- /dev/null +++ b/src/MyWebLog.Data/Extensions/CategoryExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class CategoryEtensions +{ + /// + /// Count all categories + /// + /// A count of all categories + public static async Task CountAll(this DbSet db) => + await db.CountAsync().ConfigureAwait(false); +} diff --git a/src/MyWebLog.Data/Extensions/PageExtensions.cs b/src/MyWebLog.Data/Extensions/PageExtensions.cs index c105815..2f90f65 100644 --- a/src/MyWebLog.Data/Extensions/PageExtensions.cs +++ b/src/MyWebLog.Data/Extensions/PageExtensions.cs @@ -4,11 +4,26 @@ namespace MyWebLog.Data; public static class PageExtensions { + /// + /// Count the number of pages + /// + /// The number of pages + public static async Task CountAll(this DbSet db) => + await db.CountAsync().ConfigureAwait(false); + /// /// Retrieve a page by its ID (non-tracked) /// /// The ID of the page to retrieve /// The requested page (or null if it is not found) public static async Task FindById(this DbSet db, string id) => - await db.FirstOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); + await db.SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); + + /// + /// Retrieve a page by its permalink (non-tracked) + /// + /// The permalink + /// The requested page (or null if it is not found) + public static async Task FindByPermalink(this DbSet db, string permalink) => + await db.SingleOrDefaultAsync(p => p.Permalink == permalink).ConfigureAwait(false); } diff --git a/src/MyWebLog.Data/Extensions/PostExtensions.cs b/src/MyWebLog.Data/Extensions/PostExtensions.cs index 818d3ca..dd28fe1 100644 --- a/src/MyWebLog.Data/Extensions/PostExtensions.cs +++ b/src/MyWebLog.Data/Extensions/PostExtensions.cs @@ -4,6 +4,22 @@ namespace MyWebLog.Data; public static class PostExtensions { + /// + /// Count the posts in the given status + /// + /// The status for which posts should be counted + /// A count of the posts in the given status + public static async Task CountByStatus(this DbSet db, PostStatus status) => + await db.CountAsync(p => p.Status == status).ConfigureAwait(false); + + /// + /// Retrieve a post by its permalink (non-tracked) + /// + /// The possible post permalink + /// The post matching the permalink, or null if none is found + public static async Task FindByPermalink(this DbSet db, string permalink) => + await db.SingleOrDefaultAsync(p => p.Id == permalink).ConfigureAwait(false); + /// /// Retrieve a page of published posts (non-tracked) /// diff --git a/src/MyWebLog/Features/Admin/AdminController.cs b/src/MyWebLog/Features/Admin/AdminController.cs index 088ee6e..efd99e0 100644 --- a/src/MyWebLog/Features/Admin/AdminController.cs +++ b/src/MyWebLog/Features/Admin/AdminController.cs @@ -12,8 +12,12 @@ public class AdminController : MyWebLogController public AdminController(WebLogDbContext db) : base(db) { } [HttpGet("")] - public IActionResult Index() - { - return View(); - } + public async Task Index() => + View(new DashboardModel(WebLog) + { + Posts = await Db.Posts.CountByStatus(PostStatus.Published), + Drafts = await Db.Posts.CountByStatus(PostStatus.Draft), + Pages = await Db.Pages.CountAll(), + Categories = await Db.Categories.CountAll() + }); } diff --git a/src/MyWebLog/Features/Admin/DashboardModel.cs b/src/MyWebLog/Features/Admin/DashboardModel.cs new file mode 100644 index 0000000..2342334 --- /dev/null +++ b/src/MyWebLog/Features/Admin/DashboardModel.cs @@ -0,0 +1,30 @@ +namespace MyWebLog.Features.Admin; + +/// +/// The model used to display the dashboard +/// +public class DashboardModel : MyWebLogModel +{ + /// + /// The number of published posts + /// + public int Posts { get; set; } = 0; + + /// + /// The number of post drafts + /// + public int Drafts { get; set; } = 0; + + /// + /// The number of pages + /// + public int Pages { get; set; } = 0; + + /// + /// The number of categories + /// + public int Categories { get; set; } = 0; + + /// + public DashboardModel(WebLogDetails webLog) : base(webLog) { } +} diff --git a/src/MyWebLog/Features/Admin/Index.cshtml b/src/MyWebLog/Features/Admin/Index.cshtml index 52fecf9..b9f7ff7 100644 --- a/src/MyWebLog/Features/Admin/Index.cshtml +++ b/src/MyWebLog/Features/Admin/Index.cshtml @@ -1,5 +1,35 @@ -@{ +@model DashboardModel +@{ Layout = "_AdminLayout"; ViewBag.Title = Resources.Dashboard; } -

You're logged on!

+ diff --git a/src/MyWebLog/Features/Categories/CategoryController.cs b/src/MyWebLog/Features/Categories/CategoryController.cs new file mode 100644 index 0000000..5ad1b81 --- /dev/null +++ b/src/MyWebLog/Features/Categories/CategoryController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Categories; + +/// +/// Handle routes for categories +/// +[Route("/category")] +[Authorize] +public class CategoryController : MyWebLogController +{ + /// + public CategoryController(WebLogDbContext db) : base(db) { } + + [HttpGet("all")] + public async Task All() + { + await Task.CompletedTask; + throw new NotImplementedException(); + } + + [HttpGet("{id}/edit")] + public async Task Edit(string id) + { + await Task.CompletedTask; + throw new NotImplementedException(); + } +} diff --git a/src/MyWebLog/Features/Pages/PageController.cs b/src/MyWebLog/Features/Pages/PageController.cs new file mode 100644 index 0000000..353b5a8 --- /dev/null +++ b/src/MyWebLog/Features/Pages/PageController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Pages; + +/// +/// Handle routes for pages +/// +[Route("/page")] +[Authorize] +public class PageController : MyWebLogController +{ + /// + public PageController(WebLogDbContext db) : base(db) { } + + [HttpGet("all")] + public async Task All() + { + await Task.CompletedTask; + throw new NotImplementedException(); + } + + [HttpGet("{id}/edit")] + public async Task Edit(string id) + { + await Task.CompletedTask; + throw new NotImplementedException(); + } +} diff --git a/src/MyWebLog/Features/Pages/SinglePageModel.cs b/src/MyWebLog/Features/Pages/SinglePageModel.cs new file mode 100644 index 0000000..6138cac --- /dev/null +++ b/src/MyWebLog/Features/Pages/SinglePageModel.cs @@ -0,0 +1,22 @@ +namespace MyWebLog.Features.Pages; + +/// +/// The model used to render a single page +/// +public class SinglePageModel : MyWebLogModel +{ + /// + /// The page to be rendered + /// + public Page Page { get; init; } + + /// + /// Constructor + /// + /// The page to be rendered + /// The details for the web log + public SinglePageModel(Page page, WebLogDetails webLog) : base(webLog) + { + Page = page; + } +} diff --git a/src/MyWebLog/Features/Posts/MultiplePostModel.cs b/src/MyWebLog/Features/Posts/MultiplePostModel.cs new file mode 100644 index 0000000..20c3fcb --- /dev/null +++ b/src/MyWebLog/Features/Posts/MultiplePostModel.cs @@ -0,0 +1,22 @@ +namespace MyWebLog.Features.Posts; + +/// +/// The model used to render multiple posts +/// +public class MultiplePostModel : MyWebLogModel +{ + /// + /// The posts to be rendered + /// + public IEnumerable Posts { get; init; } + + /// + /// Constructor + /// + /// The posts to be rendered + /// The details for the web log + public MultiplePostModel(IEnumerable posts, WebLogDetails webLog) : base(webLog) + { + Posts = posts; + } +} diff --git a/src/MyWebLog/Features/Posts/PostController.cs b/src/MyWebLog/Features/Posts/PostController.cs index 123d6c8..93c2b1f 100644 --- a/src/MyWebLog/Features/Posts/PostController.cs +++ b/src/MyWebLog/Features/Posts/PostController.cs @@ -1,25 +1,68 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MyWebLog.Features.Pages; namespace MyWebLog.Features.Posts; /// /// Handle post-related requests /// +[Route("/post")] +[Authorize] public class PostController : MyWebLogController { /// public PostController(WebLogDbContext db) : base(db) { } [HttpGet("~/")] + [AllowAnonymous] public async Task Index() { - var webLog = WebLogCache.Get(HttpContext); - if (webLog.DefaultPage == "posts") + if (WebLog.DefaultPage == "posts") return await PageOfPosts(1); + + var page = await Db.Pages.FindById(WebLog.DefaultPage); + return page is null ? NotFound() : ThemedView("SinglePage", new SinglePageModel(page, WebLog)); + } + + [HttpGet("~/page/{pageNbr:int}")] + [AllowAnonymous] + public async Task PageOfPosts(int pageNbr) => + ThemedView("Index", + new MultiplePostModel(await Db.Posts.FindPageOfPublishedPosts(pageNbr, WebLog.PostsPerPage), WebLog)); + + [HttpGet("~/{*permalink}")] + public async Task CatchAll(string permalink) + { + Console.Write($"Got permalink |{permalink}|"); + var post = await Db.Posts.FindByPermalink(permalink); + if (post != null) { - var posts = await Db.Posts.FindPageOfPublishedPosts(1, webLog.PostsPerPage); - return ThemedView("Index", posts); + // TODO: return via single-post action } - var page = await Db.Pages.FindById(webLog.DefaultPage); - return page is null ? NotFound() : ThemedView("SinglePage", page); + + var page = await Db.Pages.FindByPermalink(permalink); + if (page != null) + { + return ThemedView("SinglePage", new SinglePageModel(page, WebLog)); + } + + // TOOD: search prior permalinks for posts and pages + + await Task.CompletedTask; + throw new NotImplementedException(); + } + + [HttpGet("all")] + public async Task All() + { + await Task.CompletedTask; + throw new NotImplementedException(); + } + + [HttpGet("{id}/edit")] + public async Task Edit(string id) + { + await Task.CompletedTask; + throw new NotImplementedException(); } } diff --git a/src/MyWebLog/Features/Shared/MyWebLogController.cs b/src/MyWebLog/Features/Shared/MyWebLogController.cs index a6bd7b3..c45d05d 100644 --- a/src/MyWebLog/Features/Shared/MyWebLogController.cs +++ b/src/MyWebLog/Features/Shared/MyWebLogController.cs @@ -9,11 +9,16 @@ public abstract class MyWebLogController : Controller ///
protected WebLogDbContext Db { get; init; } + /// + /// The details for the current web log + /// + protected WebLogDetails WebLog => WebLogCache.Get(HttpContext); + /// /// Constructor /// /// The data context to use to fulfil this request - protected MyWebLogController(WebLogDbContext db) + protected MyWebLogController(WebLogDbContext db) : base() { Db = db; } diff --git a/src/MyWebLog/Features/Shared/MyWebLogModel.cs b/src/MyWebLog/Features/Shared/MyWebLogModel.cs new file mode 100644 index 0000000..48d6e7f --- /dev/null +++ b/src/MyWebLog/Features/Shared/MyWebLogModel.cs @@ -0,0 +1,21 @@ +namespace MyWebLog.Features.Shared; + +/// +/// Base model class for myWebLog views +/// +public class MyWebLogModel +{ + /// + /// The details for the web log + /// + public WebLogDetails WebLog { get; init; } + + /// + /// Constructor + /// + /// The details for the web log + protected MyWebLogModel(WebLogDetails webLog) + { + WebLog = webLog; + } +} diff --git a/src/MyWebLog/Features/Shared/_AdminLayout.cshtml b/src/MyWebLog/Features/Shared/_AdminLayout.cshtml index d32e32f..6eebde7 100644 --- a/src/MyWebLog/Features/Shared/_AdminLayout.cshtml +++ b/src/MyWebLog/Features/Shared/_AdminLayout.cshtml @@ -1,12 +1,9 @@ -@inject IHttpContextAccessor ctxAcc -@{ - var details = WebLogCache.Get(ctxAcc.HttpContext!); -} +@model MyWebLogModel - @ViewBag.Title « @Resources.Admin « @details.Name + @ViewBag.Title « @Resources.Admin « @Model.WebLog.Name @@ -15,7 +12,7 @@
-

@ViewBag.Title

@* Each.Messages @Current.ToDisplay @EndEach *@ diff --git a/src/MyWebLog/Features/Users/LogOn.cshtml b/src/MyWebLog/Features/Users/LogOn.cshtml index 9a034e9..9c77857 100644 --- a/src/MyWebLog/Features/Users/LogOn.cshtml +++ b/src/MyWebLog/Features/Users/LogOn.cshtml @@ -3,7 +3,8 @@ Layout = "_AdminLayout"; ViewBag.Title = @Resources.LogOn; } -
+

@Resources.LogOnTo @Model.WebLog.Name

+
diff --git a/src/MyWebLog/Properties/Resources.Designer.cs b/src/MyWebLog/Properties/Resources.Designer.cs index 4448d9b..8396edf 100644 --- a/src/MyWebLog/Properties/Resources.Designer.cs +++ b/src/MyWebLog/Properties/Resources.Designer.cs @@ -69,6 +69,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to All. + /// + public static string All { + get { + return ResourceManager.GetString("All", resourceCulture); + } + } + /// /// Looks up a localized string similar to Categories. /// @@ -87,6 +96,24 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Default Page. + /// + public static string DefaultPage { + get { + return ResourceManager.GetString("DefaultPage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Drafts. + /// + public static string Drafts { + get { + return ResourceManager.GetString("Drafts", resourceCulture); + } + } + /// /// Looks up a localized string similar to E-mail Address. /// @@ -96,6 +123,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to First Page of Posts. + /// + public static string FirstPageOfPosts { + get { + return ResourceManager.GetString("FirstPageOfPosts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Log Off. /// @@ -114,6 +150,24 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Log On to. + /// + public static string LogOnTo { + get { + return ResourceManager.GetString("LogOnTo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name. + /// + public static string Name { + get { + return ResourceManager.GetString("Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Pages. /// @@ -141,6 +195,51 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Posts per Page. + /// + public static string PostsPerPage { + get { + return ResourceManager.GetString("PostsPerPage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Published. + /// + public static string Published { + get { + return ResourceManager.GetString("Published", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save Changes. + /// + public static string SaveChanges { + get { + return ResourceManager.GetString("SaveChanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shown in Page List. + /// + public static string ShownInPageList { + get { + return ResourceManager.GetString("ShownInPageList", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Subtitle. + /// + public static string Subtitle { + get { + return ResourceManager.GetString("Subtitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to There are {0} categories. /// @@ -167,5 +266,23 @@ namespace MyWebLog.Properties { return ResourceManager.GetString("ThereAreXPublishedPostsAndYDrafts", resourceCulture); } } + + /// + /// Looks up a localized string similar to Time Zone. + /// + public static string TimeZone { + get { + return ResourceManager.GetString("TimeZone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Top Level. + /// + public static string TopLevel { + get { + return ResourceManager.GetString("TopLevel", resourceCulture); + } + } } } diff --git a/src/MyWebLog/Properties/Resources.resx b/src/MyWebLog/Properties/Resources.resx index 9c1bbeb..d56cd09 100644 --- a/src/MyWebLog/Properties/Resources.resx +++ b/src/MyWebLog/Properties/Resources.resx @@ -120,21 +120,39 @@ Admin + + All + Categories Dashboard + + Default Page + + + Drafts + E-mail Address + + First Page of Posts + Log Off Log On + + Log On to + + + Name + Pages @@ -144,6 +162,21 @@ Posts + + Posts per Page + + + Published + + + Save Changes + + + Shown in Page List + + + Subtitle + There are {0} categories @@ -153,4 +186,10 @@ There are {0} published posts and {1} drafts + + Time Zone + + + Top Level + \ No newline at end of file -- 2.45.1 From d0cc9d0d7cc147dd6945894840d698898b85e829 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 1 Mar 2022 22:08:36 -0500 Subject: [PATCH 009/102] First cut at page add/edit --- .../Extensions/PageExtensions.cs | 24 +++ src/MyWebLog/Features/Admin/Index.cshtml | 18 +- src/MyWebLog/Features/Admin/Settings.cshtml | 2 +- src/MyWebLog/Features/Pages/All.cshtml | 37 ++++ src/MyWebLog/Features/Pages/Edit.cshtml | 56 ++++++ src/MyWebLog/Features/Pages/EditPageModel.cs | 99 ++++++++++ src/MyWebLog/Features/Pages/PageController.cs | 51 +++++- src/MyWebLog/Features/Pages/PageListModel.cs | 19 ++ .../Features/Shared/MyWebLogController.cs | 9 + .../Shared/TagHelpers/YesNoTagHelper.cs | 29 +++ src/MyWebLog/Features/_ViewImports.cshtml | 1 + src/MyWebLog/MyWebLog.csproj | 1 + src/MyWebLog/Properties/Resources.Designer.cs | 171 ++++++++++++++++++ src/MyWebLog/Properties/Resources.resx | 57 ++++++ 14 files changed, 558 insertions(+), 16 deletions(-) create mode 100644 src/MyWebLog/Features/Pages/All.cshtml create mode 100644 src/MyWebLog/Features/Pages/Edit.cshtml create mode 100644 src/MyWebLog/Features/Pages/EditPageModel.cs create mode 100644 src/MyWebLog/Features/Pages/PageListModel.cs create mode 100644 src/MyWebLog/Features/Shared/TagHelpers/YesNoTagHelper.cs diff --git a/src/MyWebLog.Data/Extensions/PageExtensions.cs b/src/MyWebLog.Data/Extensions/PageExtensions.cs index 235cb94..4d8c26f 100644 --- a/src/MyWebLog.Data/Extensions/PageExtensions.cs +++ b/src/MyWebLog.Data/Extensions/PageExtensions.cs @@ -33,6 +33,14 @@ public static class PageExtensions public static async Task FindById(this DbSet db, string id) => await db.SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); + /// + /// Retrieve a page by its ID, including its revisions (non-tracked) + /// + /// The ID of the page to retrieve + /// The requested page (or null if it is not found) + public static async Task FindByIdWithRevisions(this DbSet db, string id) => + await db.Include(p => p.Revisions).SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); + /// /// Retrieve a page by its permalink (non-tracked) /// @@ -40,4 +48,20 @@ public static class PageExtensions /// The requested page (or null if it is not found) public static async Task FindByPermalink(this DbSet db, string permalink) => await db.SingleOrDefaultAsync(p => p.Permalink == permalink).ConfigureAwait(false); + + /// + /// Retrieve a page of pages (non-tracked) + /// + /// The page number to retrieve + /// The pages + public static async Task> FindPageOfPages(this DbSet db, int pageNbr) => + await db.Skip((pageNbr - 1) * 50).Take(25).ToListAsync().ConfigureAwait(false); + + /// + /// Retrieve a page by its ID (tracked) + /// + /// The ID of the page to retrieve + /// The requested page (or null if it is not found) + public static async Task GetById(this DbSet db, string id) => + await db.AsTracking().Include(p => p.Revisions).SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); } diff --git a/src/MyWebLog/Features/Admin/Index.cshtml b/src/MyWebLog/Features/Admin/Index.cshtml index 27fb44d..72ef232 100644 --- a/src/MyWebLog/Features/Admin/Index.cshtml +++ b/src/MyWebLog/Features/Admin/Index.cshtml @@ -13,8 +13,10 @@ @Resources.Published @Model.Posts   @Resources.Drafts @Model.Drafts - View All - Write a New Post + @Resources.ViewAll + + @Resources.WriteANewPost +
@@ -26,8 +28,10 @@ @Resources.All @Model.Pages   @Resources.ShownInPageList @Model.ListedPages - View All - Create a New Page + @Resources.ViewAll + + @Resources.CreateANewPage + @@ -41,9 +45,9 @@ @Resources.All @Model.Categories   @Resources.TopLevel @Model.TopLevelCategories - View All + @Resources.ViewAll - Add a New Category + @Resources.AddANewCategory @@ -51,7 +55,7 @@
diff --git a/src/MyWebLog/Features/Admin/Settings.cshtml b/src/MyWebLog/Features/Admin/Settings.cshtml index 7ef5d76..51c19a6 100644 --- a/src/MyWebLog/Features/Admin/Settings.cshtml +++ b/src/MyWebLog/Features/Admin/Settings.cshtml @@ -1,7 +1,7 @@ @model SettingsModel @{ Layout = "_AdminLayout"; - ViewBag.Title = "Web Log Settings"; + ViewBag.Title = Resources.WebLogSettings; }
diff --git a/src/MyWebLog/Features/Pages/All.cshtml b/src/MyWebLog/Features/Pages/All.cshtml new file mode 100644 index 0000000..3d474fd --- /dev/null +++ b/src/MyWebLog/Features/Pages/All.cshtml @@ -0,0 +1,37 @@ +@model PageListModel +@{ + Layout = "_AdminLayout"; + ViewBag.Title = Resources.Pages; +} +
+ @Resources.CreateANewPage + + + + + + + + + + + @foreach (var pg in Model.Pages) + { + + + + + + + } + +
@Resources.Actions@Resources.Title@Resources.InListQuestion@Resources.LastUpdated
+ @Resources.Edit + + @pg.Title + @if (pg.Id == Model.WebLog.DefaultPage) + { +   HOME PAGE + } + @pg.UpdatedOn.ToString(Resources.DateFormatString)
+
diff --git a/src/MyWebLog/Features/Pages/Edit.cshtml b/src/MyWebLog/Features/Pages/Edit.cshtml new file mode 100644 index 0000000..bd3482b --- /dev/null +++ b/src/MyWebLog/Features/Pages/Edit.cshtml @@ -0,0 +1,56 @@ +@model EditPageModel +@{ + Layout = "_AdminLayout"; + ViewBag.Title = Model.IsNew ? Resources.AddANewPage : Resources.EditPage; +} +
+

@ViewBag.Title

+ + +
+
+
+
+ + + +
+
+
+
+
+
+ + + +
+
+
+
+ + +
+
+
+
+
+     + + + + +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
diff --git a/src/MyWebLog/Features/Pages/EditPageModel.cs b/src/MyWebLog/Features/Pages/EditPageModel.cs new file mode 100644 index 0000000..5e0cd69 --- /dev/null +++ b/src/MyWebLog/Features/Pages/EditPageModel.cs @@ -0,0 +1,99 @@ +using System.ComponentModel.DataAnnotations; + +namespace MyWebLog.Features.Pages; + +/// +/// Model used to edit pages +/// +public class EditPageModel : MyWebLogModel +{ + /// + /// The ID of the page being edited + /// + public string PageId { get; set; } = "new"; + + /// + /// Whether this is a new page + /// + public bool IsNew => PageId == "new"; + + /// + /// The title of the page + /// + [Display(ResourceType = typeof(Resources), Name = "Title")] + [Required(AllowEmptyStrings = false)] + public string Title { get; set; } = ""; + + /// + /// The permalink for the page + /// + [Display(ResourceType = typeof(Resources), Name = "Permalink")] + [Required(AllowEmptyStrings = false)] + public string Permalink { get; set; } = ""; + + /// + /// Whether this page is shown in the page list + /// + [Display(ResourceType = typeof(Resources), Name = "ShowInPageList")] + public bool IsShownInPageList { get; set; } = false; + + /// + /// The source format for the text + /// + public RevisionSource Source { get; set; } = RevisionSource.Html; + + /// + /// The text of the page + /// + [Display(ResourceType = typeof(Resources), Name = "PageText")] + [Required(AllowEmptyStrings = false)] + public string Text { get; set; } = ""; + + [Obsolete("Only used for model binding; use the WebLogDetails constructor")] + public EditPageModel() : base(new()) { } + + /// + public EditPageModel(WebLogDetails webLog) : base(webLog) { } + + /// + /// Create a model from an existing page + /// + /// The page from which the model will be created + /// The web log to which the page belongs + /// A populated model + public static EditPageModel CreateFromPage(Page page, WebLogDetails webLog) + { + var lastRev = page.Revisions.OrderByDescending(r => r.AsOf).First(); + return new(webLog) + { + PageId = page.Id, + Title = page.Title, + Permalink = page.Permalink, + IsShownInPageList = page.ShowInPageList, + Source = lastRev.SourceType, + Text = lastRev.Text + }; + } + + /// + /// Populate a page from the values contained in this page + /// + /// The page to be populated + /// The populated page + public Page? PopulatePage(Page? page) + { + if (page == null) return null; + + page.Title = Title; + page.Permalink = Permalink; + page.ShowInPageList = IsShownInPageList; + page.Revisions.Add(new() + { + Id = WebLogDbContext.NewId(), + AsOf = DateTime.UtcNow, + SourceType = Source, + Text = Text + }); + return page; + } +} diff --git a/src/MyWebLog/Features/Pages/PageController.cs b/src/MyWebLog/Features/Pages/PageController.cs index 353b5a8..9fc26d5 100644 --- a/src/MyWebLog/Features/Pages/PageController.cs +++ b/src/MyWebLog/Features/Pages/PageController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authorization; +using Markdig; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace MyWebLog.Features.Pages; @@ -10,20 +11,54 @@ namespace MyWebLog.Features.Pages; [Authorize] public class PageController : MyWebLogController { + /// + /// Pipeline with most extensions enabled + /// + private readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder() + .UseSmartyPants().UseAdvancedExtensions().Build(); + /// public PageController(WebLogDbContext db) : base(db) { } [HttpGet("all")] - public async Task All() - { - await Task.CompletedTask; - throw new NotImplementedException(); - } + [HttpGet("all/page/{pageNbr:int}")] + public async Task All(int? pageNbr) => + View(new PageListModel(await Db.Pages.FindPageOfPages(pageNbr ?? 1), WebLog)); [HttpGet("{id}/edit")] public async Task Edit(string id) { - await Task.CompletedTask; - throw new NotImplementedException(); + if (id == "new") return View(new EditPageModel(WebLog)); + + var page = await Db.Pages.FindByIdWithRevisions(id); + if (page == null) return NotFound(); + + return View(EditPageModel.CreateFromPage(page, WebLog)); + } + + [HttpPost("{id}/edit")] + public async Task Save(EditPageModel model) + { + var page = model.PopulatePage(model.IsNew + ? new() + { + Id = WebLogDbContext.NewId(), + AuthorId = UserId, + PublishedOn = DateTime.UtcNow, + Revisions = new List() + } + : await Db.Pages.GetById(model.PageId)); + if (page == null) return NotFound(); + + page.Text = model.Source == RevisionSource.Html ? model.Text : Markdown.ToHtml(model.Text, _pipeline); + page.UpdatedOn = DateTime.UtcNow; + + if (model.IsNew) await Db.Pages.AddAsync(page); + + await Db.SaveChangesAsync(); + + // TODO: confirmation + + return RedirectToAction(nameof(All)); } } diff --git a/src/MyWebLog/Features/Pages/PageListModel.cs b/src/MyWebLog/Features/Pages/PageListModel.cs new file mode 100644 index 0000000..056d233 --- /dev/null +++ b/src/MyWebLog/Features/Pages/PageListModel.cs @@ -0,0 +1,19 @@ +namespace MyWebLog.Features.Pages; + +/// +/// View model for viewing a list of pages +/// +public class PageListModel : MyWebLogModel +{ + public IList Pages { get; init; } + + /// + /// Constructor + /// + /// The pages to display + /// The web log details + public PageListModel(IList pages, WebLogDetails webLog) : base(webLog) + { + Pages = pages; + } +} diff --git a/src/MyWebLog/Features/Shared/MyWebLogController.cs b/src/MyWebLog/Features/Shared/MyWebLogController.cs index c45d05d..f26b38b 100644 --- a/src/MyWebLog/Features/Shared/MyWebLogController.cs +++ b/src/MyWebLog/Features/Shared/MyWebLogController.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace MyWebLog.Features.Shared; +/// +/// Base class for myWebLog controllers +/// public abstract class MyWebLogController : Controller { /// @@ -14,6 +18,11 @@ public abstract class MyWebLogController : Controller /// protected WebLogDetails WebLog => WebLogCache.Get(HttpContext); + /// + /// The ID of the currently authenticated user + /// + protected string UserId => User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value ?? ""; + /// /// Constructor /// diff --git a/src/MyWebLog/Features/Shared/TagHelpers/YesNoTagHelper.cs b/src/MyWebLog/Features/Shared/TagHelpers/YesNoTagHelper.cs new file mode 100644 index 0000000..945c561 --- /dev/null +++ b/src/MyWebLog/Features/Shared/TagHelpers/YesNoTagHelper.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace MyWebLog.Features.Shared.TagHelpers; + +/// +/// Write a Yes or No based on a boolean value +/// +public class YesNoTagHelper : TagHelper +{ + /// + /// The attribute in question + /// + [HtmlAttributeName("asp-for")] + public bool For { get; set; } = false; + + /// + /// Optional; if set, that value will be wrapped with <strong> instead of <span> + /// + [HtmlAttributeName("asp-strong-if")] + public bool? StrongIf { get; set; } + + /// + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = For == StrongIf ? "strong" : "span"; + output.TagMode = TagMode.StartTagAndEndTag; + output.Content.Append(For ? Resources.Yes : Resources.No); + } +} diff --git a/src/MyWebLog/Features/_ViewImports.cshtml b/src/MyWebLog/Features/_ViewImports.cshtml index 65c93a1..5538471 100644 --- a/src/MyWebLog/Features/_ViewImports.cshtml +++ b/src/MyWebLog/Features/_ViewImports.cshtml @@ -4,3 +4,4 @@ @using MyWebLog.Properties @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, MyWebLog diff --git a/src/MyWebLog/MyWebLog.csproj b/src/MyWebLog/MyWebLog.csproj index 0f4f91a..325e63e 100644 --- a/src/MyWebLog/MyWebLog.csproj +++ b/src/MyWebLog/MyWebLog.csproj @@ -11,6 +11,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MyWebLog/Properties/Resources.Designer.cs b/src/MyWebLog/Properties/Resources.Designer.cs index 8396edf..7046e4e 100644 --- a/src/MyWebLog/Properties/Resources.Designer.cs +++ b/src/MyWebLog/Properties/Resources.Designer.cs @@ -60,6 +60,33 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Actions. + /// + public static string Actions { + get { + return ResourceManager.GetString("Actions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add a New Category. + /// + public static string AddANewCategory { + get { + return ResourceManager.GetString("AddANewCategory", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add a New Page. + /// + public static string AddANewPage { + get { + return ResourceManager.GetString("AddANewPage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Admin. /// @@ -87,6 +114,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Create a New Page. + /// + public static string CreateANewPage { + get { + return ResourceManager.GetString("CreateANewPage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Dashboard. /// @@ -96,6 +132,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to MMMM d, yyyy. + /// + public static string DateFormatString { + get { + return ResourceManager.GetString("DateFormatString", resourceCulture); + } + } + /// /// Looks up a localized string similar to Default Page. /// @@ -114,6 +159,24 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Edit. + /// + public static string Edit { + get { + return ResourceManager.GetString("Edit", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit Page. + /// + public static string EditPage { + get { + return ResourceManager.GetString("EditPage", resourceCulture); + } + } + /// /// Looks up a localized string similar to E-mail Address. /// @@ -132,6 +195,24 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to In List?. + /// + public static string InListQuestion { + get { + return ResourceManager.GetString("InListQuestion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Last Updated. + /// + public static string LastUpdated { + get { + return ResourceManager.GetString("LastUpdated", resourceCulture); + } + } + /// /// Looks up a localized string similar to Log Off. /// @@ -159,6 +240,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Modify Settings. + /// + public static string ModifySettings { + get { + return ResourceManager.GetString("ModifySettings", resourceCulture); + } + } + /// /// Looks up a localized string similar to Name. /// @@ -168,6 +258,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to No. + /// + public static string No { + get { + return ResourceManager.GetString("No", resourceCulture); + } + } + /// /// Looks up a localized string similar to Pages. /// @@ -177,6 +276,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Page Text. + /// + public static string PageText { + get { + return ResourceManager.GetString("PageText", resourceCulture); + } + } + /// /// Looks up a localized string similar to Password. /// @@ -186,6 +294,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Permalink. + /// + public static string Permalink { + get { + return ResourceManager.GetString("Permalink", resourceCulture); + } + } + /// /// Looks up a localized string similar to Posts. /// @@ -222,6 +339,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Show in Page List. + /// + public static string ShowInPageList { + get { + return ResourceManager.GetString("ShowInPageList", resourceCulture); + } + } + /// /// Looks up a localized string similar to Shown in Page List. /// @@ -276,6 +402,15 @@ namespace MyWebLog.Properties { } } + /// + /// Looks up a localized string similar to Title. + /// + public static string Title { + get { + return ResourceManager.GetString("Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Top Level. /// @@ -284,5 +419,41 @@ namespace MyWebLog.Properties { return ResourceManager.GetString("TopLevel", resourceCulture); } } + + /// + /// Looks up a localized string similar to View All. + /// + public static string ViewAll { + get { + return ResourceManager.GetString("ViewAll", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Web Log Settings. + /// + public static string WebLogSettings { + get { + return ResourceManager.GetString("WebLogSettings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Write a New Post. + /// + public static string WriteANewPost { + get { + return ResourceManager.GetString("WriteANewPost", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + public static string Yes { + get { + return ResourceManager.GetString("Yes", resourceCulture); + } + } } } diff --git a/src/MyWebLog/Properties/Resources.resx b/src/MyWebLog/Properties/Resources.resx index d56cd09..1b52285 100644 --- a/src/MyWebLog/Properties/Resources.resx +++ b/src/MyWebLog/Properties/Resources.resx @@ -117,6 +117,15 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Actions + + + Add a New Category + + + Add a New Page + Admin @@ -126,21 +135,39 @@ Categories + + Create a New Page + Dashboard + + MMMM d, yyyy + Default Page Drafts + + Edit + + + Edit Page + E-mail Address First Page of Posts + + In List? + + + Last Updated + Log Off @@ -150,15 +177,27 @@ Log On to + + Modify Settings + Name + + No + Pages + + Page Text + Password + + Permalink + Posts @@ -171,6 +210,9 @@ Save Changes + + Show in Page List + Shown in Page List @@ -189,7 +231,22 @@ Time Zone + + Title + Top Level + + View All + + + Web Log Settings + + + Write a New Post + + + Yes + \ No newline at end of file -- 2.45.1 From 7e5e6930086fc2b1c6a6c27619bdcf818650b53c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 6 Mar 2022 23:21:05 -0500 Subject: [PATCH 010/102] WIP on Bit Badger theme Building it in-place for now; will move it to its own assembly before going live --- .../Extensions/PageExtensions.cs | 2 +- ....cs => 20220307034307_Initial.Designer.cs} | 5 +- ...6_Initial.cs => 20220307034307_Initial.cs} | 1 + .../WebLogDbContextModelSnapshot.cs | 3 + src/MyWebLog.Data/Page.cs | 5 + .../Features/Pages/SinglePageModel.cs | 5 + src/MyWebLog/Features/Posts/PostController.cs | 4 +- .../Features/Shared/MyWebLogController.cs | 2 + .../Shared/TagHelpers/ImageTagHelper.cs | 37 + .../Shared/TagHelpers/LinkTagHelper.cs | 55 ++ src/MyWebLog/MyWebLog.csproj | 8 + src/MyWebLog/Program.cs | 2 +- .../BitBadger/Shared/_AppSidebar.cshtml | 29 + .../Themes/BitBadger/Shared/_Layout.cshtml | 56 ++ .../Themes/BitBadger/SinglePage.cshtml | 13 + src/MyWebLog/Themes/BitBadger/SolutionInfo.cs | 147 ++++ .../Themes/BitBadger/Solutions.cshtml | 43 ++ src/MyWebLog/Themes/BitBadger/solutions.json | 683 ++++++++++++++++++ .../Default/Shared/_DefaultFooter.cshtml | 2 +- .../Themes/Default/Shared/_Layout.cshtml | 3 +- src/MyWebLog/Themes/_ViewImports.cshtml | 2 + src/MyWebLog/wwwroot/css/BitBadger/style.css | 262 +++++++ .../wwwroot/img/BitBadger/bit-badger-auth.png | Bin 0 -> 23158 bytes .../wwwroot/img/BitBadger/bitbadger.png | Bin 0 -> 17821 bytes .../wwwroot/img/BitBadger/facebook.png | Bin 0 -> 6518 bytes .../wwwroot/img/BitBadger/favicon.ico | Bin 0 -> 9528 bytes .../img/BitBadger/screenshots/bay-vista.png | Bin 0 -> 55577 bytes .../img/BitBadger/screenshots/cassy-fiano.png | Bin 0 -> 58589 bytes .../screenshots/dr-melissa-clouthier.png | Bin 0 -> 70797 bytes .../emerald-mountain-christian-school.png | Bin 0 -> 71356 bytes .../BitBadger/screenshots/futility-closet.png | Bin 0 -> 92669 bytes .../BitBadger/screenshots/hard-corps-wife.png | Bin 0 -> 71627 bytes .../BitBadger/screenshots/liberty-pundits.png | Bin 0 -> 69306 bytes .../BitBadger/screenshots/mindy-mackenzie.png | Bin 0 -> 38444 bytes .../screenshots/my-prayer-journal.png | Bin 0 -> 23468 bytes .../wwwroot/img/BitBadger/screenshots/nsx.png | Bin 0 -> 47811 bytes .../BitBadger/screenshots/olivet-baptist.png | Bin 0 -> 15508 bytes .../screenshots/photography-by-michelle.png | Bin 0 -> 91626 bytes .../BitBadger/screenshots/prayer-tracker.png | Bin 0 -> 42936 bytes .../screenshots/riehl-world-news.png | Bin 0 -> 73056 bytes .../img/BitBadger/screenshots/tcms.png | Bin 0 -> 34726 bytes .../img/BitBadger/screenshots/tech-blog.png | Bin 0 -> 61486 bytes .../BitBadger/screenshots/the-shark-tank.png | Bin 0 -> 97351 bytes .../screenshots/virtual-prayer-room.png | Bin 0 -> 44572 bytes .../wwwroot/img/BitBadger/twitter.png | Bin 0 -> 10348 bytes 45 files changed, 1362 insertions(+), 7 deletions(-) rename src/MyWebLog.Data/Migrations/{20220227160816_Initial.Designer.cs => 20220307034307_Initial.Designer.cs} (99%) rename src/MyWebLog.Data/Migrations/{20220227160816_Initial.cs => 20220307034307_Initial.cs} (99%) create mode 100644 src/MyWebLog/Features/Shared/TagHelpers/ImageTagHelper.cs create mode 100644 src/MyWebLog/Features/Shared/TagHelpers/LinkTagHelper.cs create mode 100644 src/MyWebLog/Themes/BitBadger/Shared/_AppSidebar.cshtml create mode 100644 src/MyWebLog/Themes/BitBadger/Shared/_Layout.cshtml create mode 100644 src/MyWebLog/Themes/BitBadger/SinglePage.cshtml create mode 100644 src/MyWebLog/Themes/BitBadger/SolutionInfo.cs create mode 100644 src/MyWebLog/Themes/BitBadger/Solutions.cshtml create mode 100644 src/MyWebLog/Themes/BitBadger/solutions.json create mode 100644 src/MyWebLog/wwwroot/css/BitBadger/style.css create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/bit-badger-auth.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/bitbadger.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/facebook.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/favicon.ico create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/bay-vista.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/cassy-fiano.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/dr-melissa-clouthier.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/emerald-mountain-christian-school.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/futility-closet.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/hard-corps-wife.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/liberty-pundits.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/mindy-mackenzie.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/my-prayer-journal.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/nsx.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/olivet-baptist.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/photography-by-michelle.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/prayer-tracker.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/riehl-world-news.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/tcms.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/tech-blog.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/the-shark-tank.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/screenshots/virtual-prayer-room.png create mode 100644 src/MyWebLog/wwwroot/img/BitBadger/twitter.png diff --git a/src/MyWebLog.Data/Extensions/PageExtensions.cs b/src/MyWebLog.Data/Extensions/PageExtensions.cs index 4d8c26f..ea64a82 100644 --- a/src/MyWebLog.Data/Extensions/PageExtensions.cs +++ b/src/MyWebLog.Data/Extensions/PageExtensions.cs @@ -55,7 +55,7 @@ public static class PageExtensions /// The page number to retrieve /// The pages public static async Task> FindPageOfPages(this DbSet db, int pageNbr) => - await db.Skip((pageNbr - 1) * 50).Take(25).ToListAsync().ConfigureAwait(false); + await db.OrderBy(p => p.Title).Skip((pageNbr - 1) * 25).Take(25).ToListAsync().ConfigureAwait(false); /// /// Retrieve a page by its ID (tracked) diff --git a/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs b/src/MyWebLog.Data/Migrations/20220307034307_Initial.Designer.cs similarity index 99% rename from src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs rename to src/MyWebLog.Data/Migrations/20220307034307_Initial.Designer.cs index f9df601..37c93a6 100644 --- a/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs +++ b/src/MyWebLog.Data/Migrations/20220307034307_Initial.Designer.cs @@ -11,7 +11,7 @@ using MyWebLog.Data; namespace MyWebLog.Data.Migrations { [DbContext(typeof(WebLogDbContext))] - [Migration("20220227160816_Initial")] + [Migration("20220307034307_Initial")] partial class Initial { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -123,6 +123,9 @@ namespace MyWebLog.Data.Migrations b.Property("ShowInPageList") .HasColumnType("INTEGER"); + b.Property("Template") + .HasColumnType("TEXT"); + b.Property("Text") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs b/src/MyWebLog.Data/Migrations/20220307034307_Initial.cs similarity index 99% rename from src/MyWebLog.Data/Migrations/20220227160816_Initial.cs rename to src/MyWebLog.Data/Migrations/20220307034307_Initial.cs index cf155f5..6381fc9 100644 --- a/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs +++ b/src/MyWebLog.Data/Migrations/20220307034307_Initial.cs @@ -87,6 +87,7 @@ namespace MyWebLog.Data.Migrations PublishedOn = table.Column(type: "TEXT", nullable: false), UpdatedOn = table.Column(type: "TEXT", nullable: false), ShowInPageList = table.Column(type: "INTEGER", nullable: false), + Template = table.Column(type: "TEXT", nullable: true), Text = table.Column(type: "TEXT", nullable: false) }, constraints: table => diff --git a/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs b/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs index 8b1df7b..bd813ee 100644 --- a/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs +++ b/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs @@ -121,6 +121,9 @@ namespace MyWebLog.Data.Migrations b.Property("ShowInPageList") .HasColumnType("INTEGER"); + b.Property("Template") + .HasColumnType("TEXT"); + b.Property("Text") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/MyWebLog.Data/Page.cs b/src/MyWebLog.Data/Page.cs index e1b3dd7..7233fdb 100644 --- a/src/MyWebLog.Data/Page.cs +++ b/src/MyWebLog.Data/Page.cs @@ -45,6 +45,11 @@ public class Page /// public bool ShowInPageList { get; set; } = false; + /// + /// The template to use when rendering this page + /// + public string? Template { get; set; } = null; + /// /// The current text of the page /// diff --git a/src/MyWebLog/Features/Pages/SinglePageModel.cs b/src/MyWebLog/Features/Pages/SinglePageModel.cs index 6138cac..e9913c4 100644 --- a/src/MyWebLog/Features/Pages/SinglePageModel.cs +++ b/src/MyWebLog/Features/Pages/SinglePageModel.cs @@ -10,6 +10,11 @@ public class SinglePageModel : MyWebLogModel /// public Page Page { get; init; } + /// + /// Is this the home page? + /// + public bool IsHome => Page.Id == WebLog.DefaultPage; + /// /// Constructor /// diff --git a/src/MyWebLog/Features/Posts/PostController.cs b/src/MyWebLog/Features/Posts/PostController.cs index 90054f5..91b34d7 100644 --- a/src/MyWebLog/Features/Posts/PostController.cs +++ b/src/MyWebLog/Features/Posts/PostController.cs @@ -21,7 +21,7 @@ public class PostController : MyWebLogController if (WebLog.DefaultPage == "posts") return await PageOfPosts(1); var page = await Db.Pages.FindById(WebLog.DefaultPage); - return page is null ? NotFound() : ThemedView("SinglePage", new SinglePageModel(page, WebLog)); + return page is null ? NotFound() : ThemedView(page.Template ?? "SinglePage", new SinglePageModel(page, WebLog)); } [HttpGet("~/page/{pageNbr:int}")] @@ -42,7 +42,7 @@ public class PostController : MyWebLogController var page = await Db.Pages.FindByPermalink(permalink); if (page != null) { - return ThemedView("SinglePage", new SinglePageModel(page, WebLog)); + return ThemedView(page.Template ?? "SinglePage", new SinglePageModel(page, WebLog)); } // TOOD: search prior permalinks for posts and pages diff --git a/src/MyWebLog/Features/Shared/MyWebLogController.cs b/src/MyWebLog/Features/Shared/MyWebLogController.cs index f26b38b..b743564 100644 --- a/src/MyWebLog/Features/Shared/MyWebLogController.cs +++ b/src/MyWebLog/Features/Shared/MyWebLogController.cs @@ -34,6 +34,8 @@ public abstract class MyWebLogController : Controller protected ViewResult ThemedView(string template, object model) { + // TODO: get actual version + ViewBag.Version = "2"; return View(template, model); } } diff --git a/src/MyWebLog/Features/Shared/TagHelpers/ImageTagHelper.cs b/src/MyWebLog/Features/Shared/TagHelpers/ImageTagHelper.cs new file mode 100644 index 0000000..db07dae --- /dev/null +++ b/src/MyWebLog/Features/Shared/TagHelpers/ImageTagHelper.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using System.Text.Encodings.Web; + +namespace MyWebLog.Features.Shared.TagHelpers; + +/// +/// Image tag helper to load a theme's image +/// +[HtmlTargetElement("img", Attributes = "asp-theme")] +public class ImageTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.ImageTagHelper +{ + /// + /// The theme for which the image should be loaded + /// + [HtmlAttributeName("asp-theme")] + public string Theme { get; set; } = ""; + + /// + public ImageTagHelper(IFileVersionProvider fileVersionProvider, HtmlEncoder htmlEncoder, + IUrlHelperFactory urlHelperFactory) + : base(fileVersionProvider, htmlEncoder, urlHelperFactory) { } + + /// + public override void Process(TagHelperContext context, TagHelperOutput output) + { + if (Theme == "") + { + base.Process(context, output); + return; + } + + output.Attributes.SetAttribute("src", $"~/img/{Theme}/{context.AllAttributes["src"]?.Value}"); + ProcessUrlAttribute("src", output); + } +} diff --git a/src/MyWebLog/Features/Shared/TagHelpers/LinkTagHelper.cs b/src/MyWebLog/Features/Shared/TagHelpers/LinkTagHelper.cs new file mode 100644 index 0000000..dc25fa2 --- /dev/null +++ b/src/MyWebLog/Features/Shared/TagHelpers/LinkTagHelper.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc.Razor.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using System.Text.Encodings.Web; + +namespace MyWebLog.Features.Shared.TagHelpers; + +/// +/// Tag helper to link stylesheets for a theme +/// +[HtmlTargetElement("link", Attributes = "asp-theme")] +public class LinkTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.LinkTagHelper +{ + /// + /// The theme for which a style sheet should be loaded + /// + [HtmlAttributeName("asp-theme")] + public string Theme { get; set; } = ""; + + /// + /// The style sheet to be loaded (defaults to "style") + /// + [HtmlAttributeName("asp-style")] + public string Style { get; set; } = "style"; + + /// + public LinkTagHelper(IWebHostEnvironment hostingEnvironment, TagHelperMemoryCacheProvider cacheProvider, + IFileVersionProvider fileVersionProvider, HtmlEncoder htmlEncoder, JavaScriptEncoder javaScriptEncoder, + IUrlHelperFactory urlHelperFactory) + : base(hostingEnvironment, cacheProvider, fileVersionProvider, htmlEncoder, javaScriptEncoder, urlHelperFactory) + { } + + /// + public override void Process(TagHelperContext context, TagHelperOutput output) + { + if (Theme == "") + { + base.Process(context, output); + return; + } + + switch (context.AllAttributes["rel"]?.Value.ToString()) + { + case "stylesheet": + output.Attributes.SetAttribute("href", $"~/css/{Theme}/{Style}.css"); + break; + case "icon": + output.Attributes.SetAttribute("type", "image/x-icon"); + output.Attributes.SetAttribute("href", $"~/img/{Theme}/favicon.ico"); + break; + } + ProcessUrlAttribute("href", output); + } +} diff --git a/src/MyWebLog/MyWebLog.csproj b/src/MyWebLog/MyWebLog.csproj index 325e63e..31a7042 100644 --- a/src/MyWebLog/MyWebLog.csproj +++ b/src/MyWebLog/MyWebLog.csproj @@ -6,6 +6,14 @@ enable + + + + + + + + diff --git a/src/MyWebLog/Program.cs b/src/MyWebLog/Program.cs index 2a16434..c27e550 100644 --- a/src/MyWebLog/Program.cs +++ b/src/MyWebLog/Program.cs @@ -43,7 +43,7 @@ builder.Services.AddDbContext(o => { // TODO: can get from DI? var db = WebLogCache.HostToDb(new HttpContextAccessor().HttpContext!); - // "Data Source=Db/empty.db" + // "empty"; o.UseSqlite($"Data Source=Db/{db}.db"); }); diff --git a/src/MyWebLog/Themes/BitBadger/Shared/_AppSidebar.cshtml b/src/MyWebLog/Themes/BitBadger/Shared/_AppSidebar.cshtml new file mode 100644 index 0000000..e9a27bf --- /dev/null +++ b/src/MyWebLog/Themes/BitBadger/Shared/_AppSidebar.cshtml @@ -0,0 +1,29 @@ +@{ + var data = await SolutionInfo.GetAll(); + string[] cats = new[] { "Web Sites and Applications", "WordPress", "Static Sites", "Personal" }; + IEnumerable solutionsForCat(string cat) => + data.Where(it => it.Category == cat && it.FrontPage.Display).OrderBy(it => it.FrontPage.Order ?? 99); + string aboutTitle(string title) => $"{title} | Bit Badger Solutions"; +} + diff --git a/src/MyWebLog/Themes/BitBadger/Shared/_Layout.cshtml b/src/MyWebLog/Themes/BitBadger/Shared/_Layout.cshtml new file mode 100644 index 0000000..a167b5a --- /dev/null +++ b/src/MyWebLog/Themes/BitBadger/Shared/_Layout.cshtml @@ -0,0 +1,56 @@ +@model MyWebLogModel + + + + + + + @ViewBag.Title « @Model.WebLog.Name + + + + + + +
+ @RenderBody() +
+ + + diff --git a/src/MyWebLog/Themes/BitBadger/SinglePage.cshtml b/src/MyWebLog/Themes/BitBadger/SinglePage.cshtml new file mode 100644 index 0000000..0c4c9f2 --- /dev/null +++ b/src/MyWebLog/Themes/BitBadger/SinglePage.cshtml @@ -0,0 +1,13 @@ +@using MyWebLog.Features.Pages +@model SinglePageModel +@{ + Layout = "_Layout"; + ViewBag.Title = Model.Page.Title; +} +
+
+ @if (!Model.IsHome) {

@Model.Page.Title

} + @Html.Raw(Model.Page.Text) +
+ @if (Model.IsHome) { @await Html.PartialAsync("_AppSidebar") } +
diff --git a/src/MyWebLog/Themes/BitBadger/SolutionInfo.cs b/src/MyWebLog/Themes/BitBadger/SolutionInfo.cs new file mode 100644 index 0000000..631f4ee --- /dev/null +++ b/src/MyWebLog/Themes/BitBadger/SolutionInfo.cs @@ -0,0 +1,147 @@ +using System.Reflection; +using System.Text.Json; + +namespace MyWebLog.Themes.BitBadger; + +/// +/// A technology used in a solution +/// +public class Technology +{ + /// + /// The name of the technology + /// + public string Name { get; set; } = ""; + + /// + /// Why this technology was used in this project + /// + public string Purpose { get; set; } = ""; + + /// + /// Whether this project currently uses this technology + /// + public bool? IsCurrent { get; set; } = null; +} + +/// +/// Information about the solutions displayed on the front page +/// +public class FrontPageInfo +{ + /// + /// Whether the solution should be on the front page sidebar + /// + public bool Display { get; set; } = false; + + /// + /// The order in which this solution should be displayed + /// + public byte? Order { get; set; } = null; + + /// + /// The description text for the front page sidebar + /// + public string? Text { get; set; } = null; +} + +/// +/// Information about a solution +/// +public class SolutionInfo +{ + /// + /// The name of the solution + /// + public string Name { get; set; } = ""; + + /// + /// The URL slug for the page for this solution + /// + public string Slug { get; set; } = ""; + + /// + /// The URL for the solution (not the page describing it) + /// + public string Url { get; set; } = ""; + + /// + /// The category into which this solution falls + /// + public string Category { get; set; } = ""; + + /// + /// A short summary of the solution + /// + public string? Summary { get; set; } = null; + + /// + /// Whether this solution is inactive + /// + public bool? IsInactive { get; set; } = null; + + /// + /// Whether this solution is active + /// + public bool IsActive => !(IsInactive ?? false); + + /// + /// Whether a link should not be generated to the URL for this solution + /// + public bool? DoNotLink { get; set; } = null; + + /// + /// Whether a link should be generated to this solution + /// + public bool LinkToSite => !(DoNotLink ?? false); + + /// + /// Whether an "About" link should be generated for this solution + /// + public bool? SkipAboutLink { get; set; } = null; + + /// + /// Whether an "About" link should be generated for this solution + /// + public bool LinkToAboutPage => !(SkipAboutLink ?? false); + + /// + /// Whether to generate a link to an archive site + /// + public bool? LinkToArchive { get; set; } = null; + + /// + /// The URL of the archive site for this solution + /// + public string? ArchiveUrl { get; set; } = null; + + /// + /// Home page sidebar display information + /// + public FrontPageInfo FrontPage { get; set; } = default!; + + /// + /// Technologies used for this solution + /// + public ICollection Technologies { get; set; } = new List(); + + /// + /// Cache for reading solution info + /// + private static readonly Lazy?>> _slnInfo = new(() => + { + var asm = Assembly.GetAssembly(typeof(SolutionInfo)) + ?? throw new ArgumentNullException("Could not load the containing assembly"); + using var stream = asm.GetManifestResourceStream("MyWebLog.Themes.BitBadger.solutions.json") + ?? throw new ArgumentNullException("Could not load the solution data"); + return JsonSerializer.DeserializeAsync>(stream); + }); + + /// + /// Get all known solutions + /// + /// + /// if any required object is null + public static async Task> GetAll() => + await _slnInfo.Value ?? throw new ArgumentNullException("Could not deserialize solution data"); +} diff --git a/src/MyWebLog/Themes/BitBadger/Solutions.cshtml b/src/MyWebLog/Themes/BitBadger/Solutions.cshtml new file mode 100644 index 0000000..7b9ac84 --- /dev/null +++ b/src/MyWebLog/Themes/BitBadger/Solutions.cshtml @@ -0,0 +1,43 @@ +@{ + Layout = "_Layout"; + ViewBag.Title = "All Solutions"; + + var data = await SolutionInfo.GetAll(); + var active = data.Where(it => it.IsActive && it.LinkToAboutPage).OrderBy(it => it.Slug); + var inactive = data.Where(it => !it.IsActive && it.LinkToAboutPage).OrderBy(it => it.Slug); +} +
+

All Solutions

+

Active Solutions

+ @foreach (var sln in active) + { +

+ @sln.Name ~ About + @if (sln.IsActive) + { + ~ Visit + } + else if (sln.LinkToArchive ?? false) + { + ~ Visit (archive) + } +
@Html.Raw(sln.Summary) +

+ } +

Past Solutions

+ @foreach (var sln in inactive) + { +

+ @sln.Name ~ About + @if (sln.IsActive) + { + ~ Visit + } + else if (sln.LinkToArchive ?? false) + { + ~ Visit (archive) + } +
@Html.Raw(sln.Summary) +

+ } +
diff --git a/src/MyWebLog/Themes/BitBadger/solutions.json b/src/MyWebLog/Themes/BitBadger/solutions.json new file mode 100644 index 0000000..36a63a0 --- /dev/null +++ b/src/MyWebLog/Themes/BitBadger/solutions.json @@ -0,0 +1,683 @@ +[ + { + "Name": "A Word from the Word", + "Slug": "a-word-from-the-word", + "Url": "https://devotions.summershome.org", + "Category": "Personal", + "SkipAboutLink": true, + "FrontPage": { + "Display": true, + "Order": 2, + "Text": "Devotions by Daniel" + } + }, + { + "Name": "Bay Vista Baptist Church", + "Slug": "bay-vista", + "Url": "https://bayvista.org", + "Category": "Static Sites", + "Summary": "Southern Baptist church in Biloxi, Mississippi", + "FrontPage": { + "Display": true, + "Order": 1, + "Text": "Biloxi, Mississippi" + }, + "Technologies": [ + { + "Name": "Hugo", + "Purpose": "static site generation", + "IsCurrent": true + }, + { + "Name": "Azure", + "Purpose": "podcast file storage, automated builds, and static site hosting", + "IsCurrent": true + }, + { + "Name": "GitHub", + "Purpose": "source code control", + "IsCurrent": true + }, + { + "Name": "Hexo", + "Purpose": "static site generation" + }, + { + "Name": "Jekyll", + "Purpose": "static site generation" + }, + { + "Name": "WordPress", + "Purpose": "content management" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + } + ] + }, + { + "Name": "Cassy Fiano", + "Slug": "cassy-fiano", + "Url": "http://www.cassyfiano.com", + "Category": "WordPress", + "Summary": "A “rising star” conservative blogger", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging (with a custom theme)" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + }, + { + "Name": "Rackspace Cloud", + "Purpose": "backup and recovery" + }, + { + "Name": "Azure", + "Purpose": "backup and recovery" + } + ] + }, + { + "Name": "Daniel J. Summers", + "Slug": "daniel-j-summers", + "Url": "https://daniel.summershome.org", + "Category": "Personal", + "SkipAboutLink": true, + "FrontPage": { + "Display": true, + "Order": 1, + "Text": "Daniel’s personal blog" + } + }, + { + "Name": "Dr. Melissa Clouthier", + "Slug": "dr-melissa-clouthier", + "Url": "http://melissablogs.com", + "Category": "WordPress", + "Summary": "Politics, health, podcasts and more", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging (with a custom theme)" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + }, + { + "Name": "Rackspace Cloud", + "Purpose": "backup and recovery" + }, + { + "Name": "Azure", + "Purpose": "backup and recovery" + } + ] + }, + { + "Name": "Emerald Mountain Christian School", + "Slug": "emerald-mountain-christian-school", + "Url": "http://www.emeraldmountainchristianschool.org", + "Category": "Web Sites and Applications", + "Summary": "Classical, Christ-centered education near Wetumpka, Alabama", + "IsInactive": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "PHP", + "Purpose": "page generation and interactivity" + }, + { + "Name": "ASP.NET MVC", + "Purpose": "page generation and interactivity" + }, + { + "Name": "PostgreSQL", + "Purpose": "data storage" + }, + { + "Name": "Rackspace Cloud", + "Purpose": "hosting" + }, + { + "Name": "Azure", + "Purpose": "hosting" + } + ] + }, + { + "Name": "Futility Closet", + "Slug": "futility-closet", + "Url": "https://www.futilitycloset.com", + "Category": "WordPress", + "Summary": "An idler’s miscellany of compendious amusements", + "FrontPage": { + "Display": true, + "Order": 1 + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging", + "IsCurrent": true + }, + { + "Name": "nginx", + "Purpose": "the web server", + "IsCurrent": true + }, + { + "Name": "MySQL", + "Purpose": "data storage", + "IsCurrent": true + }, + { + "Name": "Digital Ocean", + "Purpose": "web site hosting", + "IsCurrent": true + }, + { + "Name": "Azure", + "Purpose": "backup and recovery", + "IsCurrent": true + }, + { + "Name": "Rackspace Cloud", + "Purpose": "web site hosting" + } + ] + }, + { + "Name": "Hard Corps Wife", + "Slug": "hard-corps-wife", + "Url": "http://www.hardcorpswife.com", + "Category": "WordPress", + "Summary": "Cassy’s life as a Marine wife", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + }, + { + "Name": "Rackspace Cloud", + "Purpose": "backup and recovery" + } + ] + }, + { + "Name": "Liberty Pundits", + "Slug": "liberty-pundits", + "Url": "http://libertypundits.net", + "Category": "WordPress", + "Summary": "The home for conservatives", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging" + }, + { + "Name": "PHP", + "Purpose": "custom data migration software" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + } + ] + }, + { + "Name": "Linux Resources", + "Slug": "linux-resources", + "Url": "https://blog.bitbadger.solutions/linux/", + "Category": "Web Sites and Applications", + "SkipAboutLink": true, + "FrontPage": { + "Display": true, + "Order": 99, + "Text": "Handy information for Linux folks" + } + }, + { + "Name": "Mindy Mackenzie", + "Slug": "mindy-mackenzie", + "Url": "https://mindymackenzie.com", + "Category": "WordPress", + "Summary": "Wall Street Journal best-selling author and C-suite advisor", + "FrontPage": { + "Display": true, + "Order": 2, + "Text": "WSJ-best-selling author of The Courage Solution" + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging and content management", + "IsCurrent": true + }, + { + "Name": "nginx", + "Purpose": "the web server", + "IsCurrent": true + }, + { + "Name": "MySQL", + "Purpose": "data storage", + "IsCurrent": true + }, + { + "Name": "Digital Ocean", + "Purpose": "web site hosting", + "IsCurrent": true + }, + { + "Name": "Azure", + "Purpose": "backup and recovery", + "IsCurrent": true + } + ] + }, + { + "Name": "myPrayerJournal", + "Slug": "my-prayer-journal", + "Url": "https://prayerjournal.me", + "Category": "Web Sites and Applications", + "Summary": "Minimalist personal prayer journal", + "FrontPage": { + "Display": true, + "Order": 2 + }, + "Technologies": [ + { + "Name": "htmx", + "Purpose": "front end interactivity", + "IsCurrent": true + }, + { + "Name": "Giraffe", + "Purpose": "the back end", + "IsCurrent": true + }, + { + "Name": "LiteDB", + "Purpose": "data storage", + "IsCurrent": true + }, + { + "Name": "GitHub", + "Purpose": "source code control", + "IsCurrent": true + }, + { + "Name": "GitHub Pages", + "Purpose": "documentation", + "IsCurrent": true + }, + { + "Name": "Vue.js", + "Purpose": "the front end" + }, + { + "Name": "RavenDB", + "Purpose": "data storage" + }, + { + "Name": "PostgreSQL", + "Purpose": "data storage" + } + ] + }, + { + "Name": "Not So Extreme Makeover: Community Edition", + "Slug": "nsx", + "Url": "http://notsoextreme.org", + "Category": "Web Sites and Applications", + "Summary": "Public site for the makeover; provides event-driven management of volunteers, donations, and families needing help", + "IsInactive": true, + "DoNotLink": true, + "LinkToArchive": true, + "ArchiveUrl": "https://nsx.archive.bitbadger.solutions", + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "content management" + }, + { + "Name": "PHP", + "Purpose": "for NSXapp" + }, + { + "Name": "MySQL", + "Purpose": "WordPress data storage" + }, + { + "Name": "PostgreSQL", + "Purpose": "NSXapp data storage" + } + ] + }, + { + "Name": "Olivet Baptist Church", + "Slug": "olivet-baptist", + "Url": "https://olivet-baptist.org", + "Category": "Static Sites", + "Summary": "Southern Baptist church in Gulfport, Mississippi", + "IsInactive": true, + "DoNotLink": true, + "LinkToArchive": true, + "ArchiveUrl": "https://olivet.archive.bitbadger.solutions", + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "Azure", + "Purpose": "podcast file storage and archive site hosting", + "IsCurrent": true + }, + { + "Name": "Vue.js", + "Purpose": "the user interface for the PWA" + }, + { + "Name": "Hexo", + "Purpose": "for generating the site pages" + }, + { + "Name": "WordPress", + "Purpose": "content management" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + } + ] + }, + { + "Name": "Photography by Michelle", + "Slug": "photography-by-michelle", + "Url": "https://www.summershome.org", + "Category": "Web Sites and Applications", + "Summary": "Photography services in Albuquerque, New Mexico", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "ASP.NET MVC", + "Purpose": "content management / gallery creation API" + }, + { + "Name": "PostgreSQL", + "Purpose": "data storage" + }, + { + "Name": "C# / Windows Forms", + "Purpose": "desktop gallery application" + }, + { + "Name": "WordPress", + "Purpose": "content management" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + } + ] + }, + { + "Name": "PrayerTracker", + "Slug": "prayer-tracker", + "Url": "https://prayer.bitbadger.solutions", + "Category": "Web Sites and Applications", + "Summary": "Provides an ongoing, centralized prayer list for Sunday School classes and other groups", + "FrontPage": { + "Display": true, + "Order": 1, + "Text": "A prayer request tracking website (Free for any church or Sunday School class!)" + }, + "Technologies": [ + { + "Name": "Giraffe", + "Purpose": "server-side logic and dynamic page generation", + "IsCurrent": true + }, + { + "Name": "PostgreSQL", + "Purpose": "data storage", + "IsCurrent": true + }, + { + "Name": "GitHub", + "Purpose": "source code control", + "IsCurrent": true + }, + { + "Name": "GitHub Pages", + "Purpose": "documentation hosting", + "IsCurrent": true + }, + { + "Name": "MongoDB", + "Purpose": "data storage" + }, + { + "Name": "ASP.NET MVC", + "Purpose": "dynamic content generation" + }, + { + "Name": "Database Abstraction", + "Purpose": "data access" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + }, + { + "Name": "PHP", + "Purpose": "dynamic content generation" + } + ] + }, + { + "Name": "Riehl World News", + "Slug": "riehl-world-news", + "Url": "http://riehlworldview.com", + "Category": "WordPress", + "Summary": "Riehl news for real people", + "FrontPage": { + "Display": true, + "Order": 3 + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging", + "IsCurrent": true + }, + { + "Name": "MySQL", + "Purpose": "data storage", + "IsCurrent": true + }, + { + "Name": "Azure", + "Purpose": "backup and recovery", + "IsCurrent": true + }, + { + "Name": "F#", + "Purpose": "custom archive static page generation" + } + ] + }, + { + "Name": "The Clearinghouse Management System", + "Slug": "tcms", + "Url": "http://tcms.us", + "Category": "Web Sites and Applications", + "Summary": "Assists a needs clearinghouse in connecting people with needs to people that can help meet those needs", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "PHP", + "Purpose": "the TCMS application logic" + }, + { + "Name": "WordPress", + "Purpose": "publicly-facing pages and authentication" + }, + { + "Name": "PostgreSQL", + "Purpose": "application data storage" + }, + { + "Name": "MySQL", + "Purpose": "WordPress data storage" + } + ] + }, + { + "Name": "The Bit Badger Blog", + "Slug": "tech-blog", + "Url": "https://blog.bitbadger.solutions", + "Category": "Static Sites", + "Summary": "Geek stuff from Bit Badger Solutions", + "FrontPage": { + "Display": true, + "Order": 3, + "Text": "Technical information (“geek stuff”) from Bit Badger Solutions" + }, + "Technologies": [ + { + "Name": "Hexo", + "Purpose": "static site generation", + "IsCurrent": true + }, + { + "Name": "Azure", + "Purpose": "static site hosting", + "IsCurrent": true + }, + { + "Name": "GitHub", + "Purpose": "source code control", + "IsCurrent": true + }, + { + "Name": "Custom Software", + "Purpose": "content management" + }, + { + "Name": "WordPress", + "Purpose": "content management" + }, + { + "Name": "BlogEngine.NET", + "Purpose": "content management" + }, + { + "Name": "Orchard", + "Purpose": "content management" + }, + { + "Name": "myWebLog", + "Purpose": "content management" + }, + { + "Name": "Jekyll", + "Purpose": "static site generation" + }, + { + "Name": "MySQL", + "Purpose": "data storage" + }, + { + "Name": "SQL Sever", + "Purpose": "data storage" + }, + { + "Name": "RethinkDB", + "Purpose": "data storage" + } + ] + }, + { + "Name": "The Shark Tank", + "Slug": "the-shark-tank", + "Url": "http://shark-tank.net", + "Category": "WordPress", + "Summary": "Florida’s political feeding frenzy", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "WordPress", + "Purpose": "blogging" + } + ] + }, + { + "Name": "Virtual Prayer Room", + "Slug": "virtual-prayer-room", + "Url": "https://virtualprayerroom.us", + "Category": "Web Sites and Applications", + "Summary": "Gives prayer warriors access to requests from wherever they may be, and sends them daily updates", + "IsInactive": true, + "DoNotLink": true, + "FrontPage": { + "Display": false + }, + "Technologies": [ + { + "Name": "PHP", + "Purpose": "the application logic" + }, + { + "Name": "PostgreSQL", + "Purpose": "data storage" + } + ] + } +] diff --git a/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml b/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml index b9f86bb..d32c8fa 100644 --- a/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml +++ b/src/MyWebLog/Themes/Default/Shared/_DefaultFooter.cshtml @@ -1,6 +1,6 @@ 

- myWebLog + myWebLog
diff --git a/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml index e70c2cc..37a8fbd 100644 --- a/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml +++ b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml @@ -2,11 +2,12 @@ + - + @await RenderSectionAsync("Style", false) @ViewBag.Title « @Model.WebLog.Name diff --git a/src/MyWebLog/Themes/_ViewImports.cshtml b/src/MyWebLog/Themes/_ViewImports.cshtml index eaf3b3e..16800cf 100644 --- a/src/MyWebLog/Themes/_ViewImports.cshtml +++ b/src/MyWebLog/Themes/_ViewImports.cshtml @@ -1 +1,3 @@ @namespace MyWebLog.Themes + +@addTagHelper *, MyWebLog diff --git a/src/MyWebLog/wwwroot/css/BitBadger/style.css b/src/MyWebLog/wwwroot/css/BitBadger/style.css new file mode 100644 index 0000000..b52743a --- /dev/null +++ b/src/MyWebLog/wwwroot/css/BitBadger/style.css @@ -0,0 +1,262 @@ +html { + background-color: lightgray; +} + +body { + margin: 0px; + font-family: "Raleway", "Segoe UI", Ubuntu, Tahoma, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + background-color: #FFFAFA; +} + +a { + color: navy; + text-decoration: none; +} + +a:hover { + border-bottom: dotted 1px navy; +} + +a img { + border: 0; +} + +acronym { + border-bottom: dotted 1px black; +} + +header, h1, h2, h3, footer a { + font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; +} + +h1 { + text-align: center; + margin: 1.4rem 0; + font-size: 2rem; +} + +h2 { + margin: 1.2rem 0; +} + +h3 { + margin: 1rem 0; +} + +h2, h3 { + border-bottom: solid 2px navy; +} + +@media all and (min-width:40rem) { + h2, h3 { + width: 80%; + } +} + +p { + margin: 1rem 0; +} + +#content { + margin: 0 1rem; +} + +.content { + font-size: 1.1rem; +} + +.auto { + margin: 0 auto; +} + +@media all and (min-width: 68rem) { + .content { + width: 66rem; + } +} + +.hdr { + font-size: 14pt; + font-weight: bold; +} + +.strike { + text-decoration: line-through; +} + +.alignleft { + float: left; + padding-right: 5px; +} + +ul { + padding-left: 40px; +} + +li { + list-style-type: disc; +} + +.app-info { + display: flex; + flex-flow: row-reverse wrap; + justify-content: center; +} + +abbr[title] { + text-decoration: none; + border-bottom: dotted 1px rgba(0, 0, 0, .5) +} +/* Header Style */ +.site-header { + height: 100px; + display: flex; + flex-direction: row; + justify-content: space-between; + background-image: linear-gradient(to bottom, lightgray, #FFFAFA); +} + +.site-header a, .site-header a:visited { + color: black; +} + +.site-header a:hover { + border-bottom: none; +} + +.site-header .header-title { + font-size: 3rem; + font-weight: bold; + line-height: 100px; + text-align: center; +} + +.site-header .header-spacer { + flex-grow: 3; +} + +.site-header .header-social { + padding: 25px .8rem 0 0; +} + +.site-header .header-social img { + width: 50px; + height: 50px; +} + +@media all and (max-width:40rem) { + .site-header { + height: auto; + flex-direction: column; + align-items: center; + } + + .site-header .header-title { + line-height: 3rem; + } + + .site-header .header-spacer { + display: none; + } +} +/* Home Page Styles */ +@media all and (min-width: 80rem) { + .home { + display: flex; + flex-flow: row; + align-items: flex-start; + justify-content: space-around; + } +} + +.home-lead { + font-size: 1.3rem; + text-align: center; +} + +/* Home Page Sidebar Styles */ +.app-sidebar { + text-align: center; + border-top: dotted 1px lightgray; + padding-top: 1rem; + font-size: .9rem; + display: flex; + flex-flow: row wrap; + justify-content: space-around; +} + +.app-sidebar > div { + width: 20rem; + padding-bottom: 1rem; +} + +@media all and (min-width: 68rem) { + .app-sidebar { + width: 66rem; + margin: auto; + } +} + +@media all and (min-width: 80rem) { + .app-sidebar { + width: 12rem; + border-top: none; + border-left: dotted 1px lightgray; + padding-top: 0; + padding-left: 2rem; + flex-direction: column; + } + + .app-sidebar > div { + width: auto; + } +} + +.app-sidebar a { + font-size: 10pt; + font-family: sans-serif; +} + +.app-sidebar-head { + font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + font-weight: bold; + color: maroon; + margin-bottom: .8rem; + padding: 3px 12px; + border-bottom: solid 2px lightgray; + font-size: 1rem; +} + +.app-sidebar-name, .app-sidebar-description { + margin: 0; + padding: 0; +} + +.app-sidebar-description { + font-style: italic; + color: #555555; + padding-bottom: .6rem; +} +/* Solutions Page Styles */ +.app-name { + font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + font-size: 1.3rem; + font-weight: bold; + color: maroon; +} + +/* Footer Styles */ +footer.site-footer { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + padding: 20px 15px 10px 15px; + font-size: 1rem; + color: black; + clear: both; + background-image: linear-gradient(to bottom, #FFFAFA, lightgray); +} + +footer.site-footer a:link, footer.site-footer a:visited { + color: black; +} diff --git a/src/MyWebLog/wwwroot/img/BitBadger/bit-badger-auth.png b/src/MyWebLog/wwwroot/img/BitBadger/bit-badger-auth.png new file mode 100644 index 0000000000000000000000000000000000000000..42c407cbbd1072aa37412613c41d38521916c77a GIT binary patch literal 23158 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" } |> Seq.iter result.WriteLine + + /// A filter to retrieve the value of a meta item from a list + // (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`) + type ValueFilter () = + static member Value (_ : Context, items : MetaItem list, name : string) = + match items |> List.tryFind (fun it -> it.name = name) with + | Some item -> item.value + | None -> $"-- {name} not found --" /// Create the default information for a new web log @@ -192,16 +200,18 @@ let main args = // Set up DotLiquid Template.RegisterFilter typeof + Template.RegisterFilter typeof Template.RegisterTag "user_links" [ // Domain types - typeof; typeof + typeof; typeof; typeof // View models typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof // Framework types - typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof ] |> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |])) diff --git a/src/MyWebLog/themes/admin/log-on.liquid b/src/MyWebLog/themes/admin/log-on.liquid index 49df1a7..d506042 100644 --- a/src/MyWebLog/themes/admin/log-on.liquid +++ b/src/MyWebLog/themes/admin/log-on.liquid @@ -2,6 +2,9 @@
+ {% if model.return_to %} + + {% endif %}
diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index b3056b9..b22ae1d 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -31,7 +31,7 @@ Delete - {{ post.author_name }} + {{ model.authors | value: post.author_id }} {{ post.status }} {{ post.tags | join: ", " }} diff --git a/src/MyWebLog/themes/default/index.liquid b/src/MyWebLog/themes/default/index.liquid index c3c2fa1..4540ab1 100644 --- a/src/MyWebLog/themes/default/index.liquid +++ b/src/MyWebLog/themes/default/index.liquid @@ -14,10 +14,30 @@

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

{{ post.text }} + {%- assign category_count = post.category_ids | size -%} + {%- assign tag_count = post.tags | size -%} + {% if category_count > 0 or tag_count > 0 %} +
+

+ {%- if category_count > 0 -%} + {%- for cat in post.category_ids -%} + {%- assign cat_names = model.categories | value: cat | split: "," | concat: cat_names -%} + {%- endfor -%} + Categorized under: {{ cat_names | reverse | join: ", " }}
+ {%- assign cat_names = "" -%} + {% endif -%} + {%- if tag_count > 0 %} + Tagged: {{ post.tags | join: ", " }} + {% endif -%} +

+
+ {% endif %} +
{% endfor %} - \ No newline at end of file + -- 2.45.1 From f1249440b177168e4bd2cea22f726f115915c7c7 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 25 Apr 2022 13:36:16 -0400 Subject: [PATCH 022/102] Add metadata support - Begin WIP on Bit Badger theme --- src/MyWebLog.Data/Data.fs | 2 + src/MyWebLog.Domain/DataTypes.fs | 9 + src/MyWebLog.Domain/SupportTypes.fs | 7 + src/MyWebLog.Domain/ViewModels.fs | 42 +++- src/MyWebLog/Handlers.fs | 9 +- src/MyWebLog/Program.fs | 4 +- src/MyWebLog/themes/admin/page-edit.liquid | 44 ++++ .../themes/bit-badger/home-page.liquid | 98 ++++++++ src/MyWebLog/themes/bit-badger/layout.liquid | 45 ++++ .../themes/bit-badger/single-page.liquid | 5 + src/MyWebLog/wwwroot/themes/admin/admin.js | 89 +++++++ .../themes/bit-badger/bit-badger-auth.png | Bin 0 -> 23158 bytes .../wwwroot/themes/bit-badger/bitbadger.png | Bin 0 -> 17821 bytes .../wwwroot/themes/bit-badger/facebook.png | Bin 0 -> 6518 bytes .../wwwroot/themes/bit-badger/favicon.ico | Bin 0 -> 9528 bytes .../bit-badger/screenshots/bay-vista.png | Bin 0 -> 55577 bytes .../bit-badger/screenshots/cassy-fiano.png | Bin 0 -> 58589 bytes .../screenshots/dr-melissa-clouthier.png | Bin 0 -> 70797 bytes .../emerald-mountain-christian-school.png | Bin 0 -> 71356 bytes .../screenshots/futility-closet.png | Bin 0 -> 92669 bytes .../screenshots/hard-corps-wife.png | Bin 0 -> 71627 bytes .../screenshots/liberty-pundits.png | Bin 0 -> 69306 bytes .../screenshots/mindy-mackenzie.png | Bin 0 -> 38444 bytes .../screenshots/my-prayer-journal.png | Bin 0 -> 23468 bytes .../themes/bit-badger/screenshots/nsx.png | Bin 0 -> 47811 bytes .../bit-badger/screenshots/olivet-baptist.png | Bin 0 -> 15508 bytes .../screenshots/photography-by-michelle.png | Bin 0 -> 91626 bytes .../bit-badger/screenshots/prayer-tracker.png | Bin 0 -> 42936 bytes .../screenshots/riehl-world-news.png | Bin 0 -> 73056 bytes .../themes/bit-badger/screenshots/tcms.png | Bin 0 -> 34726 bytes .../bit-badger/screenshots/tech-blog.png | Bin 0 -> 61486 bytes .../bit-badger/screenshots/the-shark-tank.png | Bin 0 -> 97351 bytes .../screenshots/virtual-prayer-room.png | Bin 0 -> 44572 bytes .../wwwroot/themes/bit-badger/style.css | 217 ++++++++++++++++++ .../wwwroot/themes/bit-badger/twitter.png | Bin 0 -> 10348 bytes 35 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 src/MyWebLog/themes/bit-badger/home-page.liquid create mode 100644 src/MyWebLog/themes/bit-badger/layout.liquid create mode 100644 src/MyWebLog/themes/bit-badger/single-page.liquid create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/bit-badger-auth.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/bitbadger.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/facebook.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/favicon.ico create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/bay-vista.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/cassy-fiano.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/dr-melissa-clouthier.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/emerald-mountain-christian-school.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/futility-closet.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/hard-corps-wife.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/liberty-pundits.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/mindy-mackenzie.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/my-prayer-journal.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/nsx.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/olivet-baptist.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/photography-by-michelle.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/prayer-tracker.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/riehl-world-news.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/tcms.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/tech-blog.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/the-shark-tank.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/screenshots/virtual-prayer-room.png create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/style.css create mode 100644 src/MyWebLog/wwwroot/themes/bit-badger/twitter.png diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index efdb6e6..e335d33 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -342,8 +342,10 @@ module Page = "permalink", page.permalink "updatedOn", page.updatedOn "showInPageList", page.showInPageList + "template", page.template "text", page.text "priorPermalinks", page.priorPermalinks + "metadata", page.metadata "revisions", page.revisions ] write; withRetryDefault; ignoreResult diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 27cb1a2..89147f2 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -1,6 +1,7 @@ namespace MyWebLog open System +open MyWebLog /// A category under which a post may be identified [] @@ -119,6 +120,9 @@ type Page = /// The current text of the page text : string + /// Metadata for this page + metadata : MetaItem list + /// Permalinks at which this page may have been previously served (useful for migrated content) priorPermalinks : Permalink list @@ -141,6 +145,7 @@ module Page = showInPageList = false template = None text = "" + metadata = [] priorPermalinks = [] revisions = [] } @@ -182,6 +187,9 @@ type Post = /// The tags for the post tags : string list + /// Metadata for the post + metadata : MetaItem list + /// Permalinks at which this post may have been previously served (useful for migrated content) priorPermalinks : Permalink list @@ -205,6 +213,7 @@ module Post = text = "" categoryIds = [] tags = [] + metadata = [] priorPermalinks = [] revisions = [] } diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index f36f65a..f5127c8 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -98,7 +98,14 @@ type MetaItem = value : string } +/// Functions to support metadata items +module MetaItem = + /// An empty metadata item + let empty = + { name = ""; value = "" } + + /// A revision of a page or post [] type Revision = diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index cee7e04..b9b73e3 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -1,7 +1,6 @@ namespace MyWebLog.ViewModels open System -open System.Collections.Generic open MyWebLog /// Details about a category, used to display category lists @@ -136,13 +135,21 @@ type EditPageModel = /// The text of the page text : string + + /// Names of metadata items + metaNames : string[] + + /// Values of metadata items + metaValues : string[] } + /// Create an edit model from an existing page static member fromPage (page : Page) = let latest = match page.revisions |> List.sortByDescending (fun r -> r.asOf) |> List.tryHead with | Some rev -> rev | None -> Revision.empty + let page = if page.metadata |> List.isEmpty then { page with metadata = [ MetaItem.empty ] } else page { pageId = PageId.toString page.id title = page.title permalink = Permalink.toString page.permalink @@ -150,6 +157,8 @@ type EditPageModel = isShownInPageList = page.showInPageList source = MarkupText.sourceType latest.text text = MarkupText.text latest.text + metaNames = page.metadata |> List.map (fun m -> m.name) |> Array.ofList + metaValues = page.metadata |> List.map (fun m -> m.value) |> Array.ofList } @@ -182,6 +191,12 @@ type EditPostModel = /// Whether this post should be published doPublish : bool + + /// Names of metadata items + metaNames : string[] + + /// Values of metadata items + metaValues : string[] } /// Create an edit model from an existing past static member fromPost (post : Post) = @@ -189,15 +204,18 @@ type EditPostModel = 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 + let post = if post.metadata |> List.isEmpty then { post with metadata = [ MetaItem.empty ] } else post + { 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 + metaNames = post.metadata |> List.map (fun m -> m.name) |> Array.ofList + metaValues = post.metadata |> List.map (fun m -> m.value) |> Array.ofList } @@ -251,6 +269,9 @@ type PostListItem = /// Tags for the post tags : string list + + /// Metadata for the post + meta : MetaItem list } /// Create a post list item from a post @@ -265,6 +286,7 @@ type PostListItem = text = post.text categoryIds = post.categoryIds |> List.map CategoryId.toString tags = post.tags + meta = post.metadata } diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs index c8d5276..86bc180 100644 --- a/src/MyWebLog/Handlers.fs +++ b/src/MyWebLog/Handlers.fs @@ -361,10 +361,13 @@ module Page = } match result with | Some (title, page) -> + let model = EditPageModel.fromPage page return! Hash.FromAnonymousObject {| csrf = csrfToken ctx - model = EditPageModel.fromPage page + model = model + metadata = Array.zip model.metaNames model.metaValues + |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) page_title = title templates = templatesForTheme ctx "page" |} @@ -408,6 +411,10 @@ module Page = showInPageList = model.isShownInPageList template = match model.template with "" -> None | tmpl -> Some tmpl text = MarkupText.toHtml revision.text + metadata = Seq.zip model.metaNames model.metaValues + |> Seq.filter (fun it -> fst it > "") + |> Seq.map (fun it -> { name = fst it; value = snd it }) + |> List.ofSeq revisions = revision :: page.revisions } do! (match model.pageId with "new" -> Data.Page.add | _ -> Data.Page.update) page conn diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 9ad58a3..f931fa2 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -25,8 +25,8 @@ type WebLogMiddleware (next : RequestDelegate) = /// DotLiquid filters module DotLiquidBespoke = - open DotLiquid open System.IO + open DotLiquid /// A filter to generate nav links, highlighting the active link (exact match) type NavLinkFilter () = @@ -166,7 +166,7 @@ let main args = builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(fun opts -> - opts.ExpireTimeSpan <- TimeSpan.FromMinutes 20. + opts.ExpireTimeSpan <- TimeSpan.FromMinutes 60. opts.SlidingExpiration <- true opts.AccessDeniedPath <- "/forbidden") let _ = builder.Services.AddLogging () diff --git a/src/MyWebLog/themes/admin/page-edit.liquid b/src/MyWebLog/themes/admin/page-edit.liquid index c5eb1a1..c94e7e3 100644 --- a/src/MyWebLog/themes/admin/page-edit.liquid +++ b/src/MyWebLog/themes/admin/page-edit.liquid @@ -56,6 +56,50 @@ +
+
+
+ + Metadata + + +
+
+ {%- for meta in metadata %} +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ {% endfor -%} +
+ + +
+
+
+
diff --git a/src/MyWebLog/themes/bit-badger/home-page.liquid b/src/MyWebLog/themes/bit-badger/home-page.liquid new file mode 100644 index 0000000..9b83987 --- /dev/null +++ b/src/MyWebLog/themes/bit-badger/home-page.liquid @@ -0,0 +1,98 @@ +
+
+ {{ page.text }} +
+ +
diff --git a/src/MyWebLog/themes/bit-badger/layout.liquid b/src/MyWebLog/themes/bit-badger/layout.liquid new file mode 100644 index 0000000..b71ab5a --- /dev/null +++ b/src/MyWebLog/themes/bit-badger/layout.liquid @@ -0,0 +1,45 @@ + + + + + {{ page_title }} » Bit Badger Solutions + + + + + + {{ content }} + + + \ No newline at end of file diff --git a/src/MyWebLog/themes/bit-badger/single-page.liquid b/src/MyWebLog/themes/bit-badger/single-page.liquid new file mode 100644 index 0000000..23adf69 --- /dev/null +++ b/src/MyWebLog/themes/bit-badger/single-page.liquid @@ -0,0 +1,5 @@ +
+

{{ page.title }}

+ {{ page.text }} +


« Home

+
diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index b030360..b99d5a1 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -1,4 +1,93 @@ const Admin = { + /** The next index for a metadata item */ + nextMetaIndex : 0, + + /** + * Set the next meta item index + * @param idx The index to set + */ + // Calling a function with a Liquid variable does not look like an error in the IDE... + setNextMetaIndex(idx) { + this.nextMetaIndex = idx + }, + + /** + * Add a new row for metadata entry + */ + addMetaItem() { + // Remove button + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.className = "btn btn-sm btn-danger" + removeBtn.innerHTML = "−" + removeBtn.setAttribute("onclick", `Admin.removeMetaItem(${this.nextMetaIndex})`) + + const removeCol = document.createElement("div") + removeCol.className = "col-1 text-center align-self-center" + removeCol.appendChild(removeBtn) + + // Name + const nameField = document.createElement("input") + nameField.type = "text" + nameField.name = "metaNames" + nameField.id = `metaNames_${this.nextMetaIndex}` + nameField.className = "form-control" + nameField.placeholder = "Name" + + const nameLabel = document.createElement("label") + nameLabel.htmlFor = nameField.id + nameLabel.innerText = nameField.placeholder + + const nameFloat = document.createElement("div") + nameFloat.className = "form-floating" + nameFloat.appendChild(nameField) + nameFloat.appendChild(nameLabel) + + const nameCol = document.createElement("div") + nameCol.className = "col-3" + nameCol.appendChild(nameFloat) + + // Value + const valueField = document.createElement("input") + valueField.type = "text" + valueField.name = "metaValues" + valueField.id = `metaValues_${this.nextMetaIndex}` + valueField.className = "form-control" + valueField.placeholder = "Value" + + const valueLabel = document.createElement("label") + valueLabel.htmlFor = valueField.id + valueLabel.innerText = valueField.placeholder + + const valueFloat = document.createElement("div") + valueFloat.className = "form-floating" + valueFloat.appendChild(valueField) + valueFloat.appendChild(valueLabel) + + const valueCol = document.createElement("div") + valueCol.className = "col-8" + valueCol.appendChild(valueFloat) + + // Put it all together + const newRow = document.createElement("div") + newRow.className = "row mb-3" + newRow.id = `meta_${this.nextMetaIndex}` + newRow.appendChild(removeCol) + newRow.appendChild(nameCol) + newRow.appendChild(valueCol) + + document.getElementById("metaItems").appendChild(newRow) + this.nextMetaIndex++ + }, + + /** + * Remove a metadata item + * @param idx The index of the metadata item to remove + */ + removeMetaItem(idx) { + document.getElementById(`meta_${idx}`).remove() + }, + /** * Confirm and delete a category * @param id The ID of the category to be deleted diff --git a/src/MyWebLog/wwwroot/themes/bit-badger/bit-badger-auth.png b/src/MyWebLog/wwwroot/themes/bit-badger/bit-badger-auth.png new file mode 100644 index 0000000000000000000000000000000000000000..42c407cbbd1072aa37412613c41d38521916c77a GIT binary patch literal 23158 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
diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index b22ae1d..6a33ec3 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -5,7 +5,7 @@ Date - Title + Title Author Status Tags @@ -14,7 +14,7 @@ {% for post in model.posts -%} - + {% if post.published_on.has_value -%} {{ post.published_on | date: "MMMM d, yyyy" }} {%- else -%} @@ -31,9 +31,9 @@ Delete - {{ model.authors | value: post.author_id }} + {{ model.authors | value: post.author_id }} {{ post.status }} - {{ post.tags | join: ", " }} + {{ post.tags | join: ", " }} {%- endfor %} diff --git a/src/MyWebLog/themes/admin/settings.liquid b/src/MyWebLog/themes/admin/settings.liquid index 23ecde2..116536d 100644 --- a/src/MyWebLog/themes/admin/settings.liquid +++ b/src/MyWebLog/themes/admin/settings.liquid @@ -4,7 +4,7 @@
-
+
@@ -16,15 +16,25 @@
-
-
-
+
+
+
+ + +
+
Time Zone
-
+
+
-
comma-delimited
-
-
-
- Categories - {% for cat in categories %} -
- - -
- {% endfor %} -
-
-
-
-
{% if model.status == "Draft" %}
{% endif %} - -
-
-
-
-
+ +
+
Metadata
-
-
- {% if model.status == "Published" %} -
-
-
+ {% if model.status == "Published" %} +
Maintenance
-
+
-
+
-
-
+
+
-
+ {% endif %}
- {% endif %} +
+
+ Categories + {% for cat in categories %} +
+ + +
+ {% endfor %} +
+
+
diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.css b/src/MyWebLog/wwwroot/themes/admin/admin.css index 32cb332..78d0dd3 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.css +++ b/src/MyWebLog/wwwroot/themes/admin/admin.css @@ -33,6 +33,9 @@ a:link, a:visited { a:link:hover, a:visited:hover { text-decoration: underline; } +a.btn:link:hover, a.btn:visited:hover { + text-decoration: none; +} a.text-danger:link:hover, a.text-danger:visited:hover { text-decoration: none; background-color: var(--bs-danger); diff --git a/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css b/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css index d256030..9103a5c 100644 --- a/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css +++ b/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css @@ -226,9 +226,11 @@ footer.part-3 { } .float-left { float: left; + padding-right: .5rem; } .float-right { float: right; + padding-left: .5rem; } .small-caps { font-variant: small-caps; -- 2.45.1 From afca5edfdd4e1882bba531d0fd7411b45747d570 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 29 Apr 2022 19:40:36 -0400 Subject: [PATCH 031/102] - Generate RSS feed - Add category/tag post list handlers - Add post newer/older links - Add user info edit page - Add support for all-of-the-above to personal theme --- src/MyWebLog.Data/Data.fs | 101 ++++++- src/MyWebLog.Data/MyWebLog.Data.fsproj | 2 +- src/MyWebLog.Domain/ViewModels.fs | 32 ++ src/MyWebLog/Handlers.fs | 284 +++++++++++++++--- src/MyWebLog/Program.fs | 6 +- src/MyWebLog/themes/admin/layout.liquid | 1 + src/MyWebLog/themes/admin/user-edit.liquid | 64 ++++ .../themes/daniel-j-summers/index.liquid | 14 +- .../daniel-j-summers/single-post.liquid | 16 +- 9 files changed, 453 insertions(+), 67 deletions(-) create mode 100644 src/MyWebLog/themes/admin/user-edit.liquid diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 0feeefa..47eab12 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -82,14 +82,16 @@ module Startup = indexCreate "priorPermalinks" [ Multi ] write; withRetryOnce; ignoreResult conn } - // Post needs index by category (used for counting posts) - if Table.Post = table && not (indexes |> List.contains "categoryIds") then - log.LogInformation $"Creating index {table}.categoryIds..." - do! rethink { - withTable table - indexCreate "categoryIds" [ Multi ] - write; withRetryOnce; ignoreResult conn - } + // Post needs indexes by category and tag (used for counting and retrieving posts) + if Table.Post = table then + for idx in [ "categoryIds"; "tags" ] do + if not (List.contains idx indexes) then + log.LogInformation $"Creating index {table}.{idx}..." + do! rethink { + withTable table + indexCreate idx [ Multi ] + write; withRetryOnce; ignoreResult conn + } // Users log on with e-mail if Table.WebLogUser = table && not (indexes |> List.contains "logOn") then log.LogInformation $"Creating index {table}.logOn..." @@ -194,6 +196,7 @@ module Category = withTable Table.Post getAll catIds "categoryIds" filter "status" Published + distinct count result; withRetryDefault conn } @@ -395,6 +398,8 @@ module Page = /// Functions to manipulate posts module Post = + open System + /// Add a post let add (post : Post) = rethink { @@ -445,6 +450,22 @@ module Post = } |> tryFirst + /// Find posts to be displayed on a category list page + let findPageOfCategorizedPosts (webLogId : WebLogId) (catIds : CategoryId list) (pageNbr : int64) postsPerPage = + let pg = int pageNbr + rethink { + withTable Table.Post + getAll (catIds |> List.map (fun it -> it :> obj)) "categoryIds" + filter "webLogId" webLogId + filter "status" Published + without [ "priorPermalinks"; "revisions" ] + distinct + orderByDescending "publishedOn" + skip ((pg - 1) * postsPerPage) + limit (postsPerPage + 1) + result; withRetryDefault + } + /// Find posts to be displayed on an admin page let findPageOfPosts (webLogId : WebLogId) (pageNbr : int64) postsPerPage = let pg = int pageNbr @@ -472,6 +493,46 @@ module Post = result; withRetryDefault } + /// Find posts to be displayed on a tag list page + let findPageOfTaggedPosts (webLogId : WebLogId) (tag : string) (pageNbr : int64) postsPerPage = + let pg = int pageNbr + rethink { + withTable Table.Post + getAll [ tag ] "tags" + filter "webLogId" webLogId + filter "status" Published + without [ "priorPermalinks"; "revisions" ] + orderByDescending "publishedOn" + skip ((pg - 1) * postsPerPage) + limit (postsPerPage + 1) + result; withRetryDefault + } + + /// Find the next older and newer post for the given post + let findSurroundingPosts (webLogId : WebLogId) (publishedOn : DateTime) conn = backgroundTask { + let! older = + rethink { + withTable Table.Post + getAll [ webLogId ] (nameof webLogId) + filter (fun row -> row.G("publishedOn").Lt publishedOn :> obj) + orderByDescending "publishedOn" + limit 1 + result; withRetryDefault + } + |> tryFirst <| conn + let! newer = + rethink { + withTable Table.Post + getAll [ webLogId ] (nameof webLogId) + filter (fun row -> row.G("publishedOn").Gt publishedOn :> obj) + orderBy "publishedOn" + limit 1 + result; withRetryDefault + } + |> tryFirst <| conn + return older, newer + } + /// Update a post (all fields are updated) let update (post : Post) = rethink { @@ -542,6 +603,14 @@ module WebLogUser = } |> tryFirst + /// Find a user by their ID + let findById (userId : WebLogUserId) = + rethink { + withTable Table.WebLogUser + get userId + resultOption; withRetryOptionDefault + } + /// Get a user ID -> name dictionary for the given user IDs let findNames (webLogId : WebLogId) conn (userIds : WebLogUserId list) = backgroundTask { let! users = rethink { @@ -552,3 +621,19 @@ module WebLogUser = } return users |> List.map (fun u -> { name = WebLogUserId.toString u.id; value = WebLogUser.displayName u }) } + + /// Update a user + let update (user : WebLogUser) = + rethink { + withTable Table.WebLogUser + get user.id + update [ + "firstName", user.firstName :> obj + "lastName", user.lastName + "preferredName", user.preferredName + "passwordHash", user.passwordHash + "salt", user.salt + ] + write; withRetryDefault; ignoreResult + } + \ No newline at end of file diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index ca2d46e..34ddfd4 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -14,7 +14,7 @@ - + diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 18df515..ab2c521 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -255,6 +255,32 @@ type EditPostModel = } +/// View model to edit a user +[] +type EditUserModel = + { /// The user's first name + firstName : string + + /// The user's last name + lastName : string + + /// The user's preferred name + preferredName : string + + /// A new password for the user + newPassword : string + + /// A new password for the user, confirmed + newPasswordConfirm : string + } + /// Create an edit model from a user + static member fromUser (user : WebLogUser) = + { firstName = user.firstName + lastName = user.lastName + preferredName = user.preferredName + newPassword = "" + newPasswordConfirm = "" + } /// The model to use to allow a user to log on [] type LogOnModel = @@ -342,8 +368,14 @@ type PostDisplay = /// The link to view newer (more recent) posts newerLink : string option + /// The name of the next newer post (single-post only) + newerName : string option + /// The link to view older (less recent) posts olderLink : string option + + /// The name of the next older post (single-post only) + olderName : string option } diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs index 2fff220..08eb4b2 100644 --- a/src/MyWebLog/Handlers.fs +++ b/src/MyWebLog/Handlers.fs @@ -1,15 +1,16 @@ [] module MyWebLog.Handlers +open System +open System.Net +open System.Threading.Tasks +open System.Web open DotLiquid open Giraffe open Microsoft.AspNetCore.Http open MyWebLog open MyWebLog.ViewModels open RethinkDb.Driver.Net -open System -open System.Net -open System.Threading.Tasks /// Handlers for error conditions module Error = @@ -99,7 +100,7 @@ module private Helpers = let mutable private generatorString : string option = None /// Get the generator string - let private generator (ctx : HttpContext) = + let generator (ctx : HttpContext) = if Option.isNone generatorString then let cfg = ctx.RequestServices.GetRequiredService () generatorString <- Option.ofObj cfg["Generator"] @@ -454,55 +455,78 @@ module Page = | None -> return! Error.notFound next ctx } - + /// Handlers to manipulate posts module Post = + open System.IO + open System.ServiceModel.Syndication + open System.Xml + + /// Split the "rest" capture for categories and tags into the page number and category/tag URL parts + let private pathAndPageNumber (ctx : HttpContext) = + let slugs = (string ctx.Request.RouteValues["slug"]).Split "/" |> Array.filter (fun it -> it <> "") + let pageIdx = Array.IndexOf (slugs, "page") + let pageNbr = if pageIdx > 0 then (int64 slugs[pageIdx + 1]) else 1L + let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs + pageNbr, String.Join ("/", slugParts) + /// The type of post list being prepared type ListType = + | AdminList | CategoryList - | TagList | PostList | SinglePost - | AdminList + | TagList + /// Get all authors for a list of posts as metadata items + let private getAuthors (webLog : WebLog) (posts : Post list) conn = + posts + |> List.map (fun p -> p.authorId) + |> List.distinct + |> Data.WebLogUser.findNames webLog.id conn + /// Convert a list of posts into items ready to be displayed - let private preparePostList (webLog : WebLog) (posts : Post list) listType pageNbr perPage ctx conn = task { - let! authors = - posts - |> List.map (fun p -> p.authorId) - |> List.distinct - |> Data.WebLogUser.findNames webLog.id conn + let private preparePostList webLog posts listType url pageNbr perPage ctx conn = task { + let! authors = getAuthors webLog posts conn let postItems = posts |> Seq.ofList |> Seq.truncate perPage |> Seq.map (PostListItem.fromPost webLog) |> Array.ofSeq + let! olderPost, newerPost = + match listType with + | SinglePost -> Data.Post.findSurroundingPosts webLog.id (List.head posts).publishedOn.Value conn + | _ -> Task.FromResult (None, None) let newerLink = match listType, pageNbr with - | SinglePost, _ -> Some "TODO: retrieve prior post" + | SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink) | _, 1L -> None | PostList, 2L when webLog.defaultPage = "posts" -> Some "" | PostList, _ -> Some $"page/{pageNbr - 1L}" - | CategoryList, _ -> Some "TODO" - | TagList, _ -> Some "TODO" + | CategoryList, 2L -> Some $"category/{url}/" + | CategoryList, _ -> Some $"category/{url}/page/{pageNbr - 1L}" + | TagList, 2L -> Some $"tag/{url}/" + | TagList, _ -> Some $"tag/{url}/page/{pageNbr - 1L}" | AdminList, 2L -> Some "posts" | AdminList, _ -> Some $"posts/page/{pageNbr - 1L}" let olderLink = match listType, List.length posts > perPage with - | SinglePost, _ -> Some "TODO: retrieve next post" + | SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink) | _, false -> None | PostList, true -> Some $"page/{pageNbr + 1L}" - | CategoryList, true -> Some $"category/TODO-slug-goes-here/page/{pageNbr + 1L}" - | TagList, true -> Some $"tag/TODO-slug-goes-here/page/{pageNbr + 1L}" + | CategoryList, true -> Some $"category/{url}/page/{pageNbr + 1L}" + | TagList, true -> Some $"tag/{url}/page/{pageNbr + 1L}" | AdminList, true -> Some $"posts/page/{pageNbr + 1L}" let model = { posts = postItems authors = authors subtitle = None newerLink = newerLink + newerName = newerPost |> Option.map (fun p -> p.title) olderLink = olderLink + olderName = olderPost |> Option.map (fun p -> p.title) } return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx |} } @@ -512,15 +536,59 @@ module Post = let webLog = WebLogCache.get ctx let conn = conn ctx let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn - let! hash = preparePostList webLog posts PostList pageNbr webLog.postsPerPage ctx conn + let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx conn let title = match pageNbr, webLog.defaultPage with | 1L, "posts" -> None | _, "posts" -> Some $"Page {pageNbr}" | _, _ -> Some $"Page {pageNbr} « Posts" match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () + if pageNbr = 1L && webLog.defaultPage = "posts" then hash.Add ("is_home", true) return! themedView "index" next ctx hash } + + // GET /category/{slug}/ + // GET /category/{slug}/page/{pageNbr} + let pageOfCategorizedPosts : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.get ctx + let conn = conn ctx + let pageNbr, slug = pathAndPageNumber ctx + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + let catIds = + allCats + |> Seq.ofArray + |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) + |> Seq.map (fun c -> CategoryId c.id) + |> List.ofSeq + match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") + hash.Add ("subtitle", cat.description.Value) + hash.Add ("is_category", true) + return! themedView "index" next ctx hash + | _ -> return! Error.notFound next ctx + } + + // GET /tag/{tag}/ + // GET /tag/{tag}/page/{pageNbr} + let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.get ctx + let conn = conn ctx + let pageNbr, rawTag = pathAndPageNumber ctx + let tag = HttpUtility.UrlDecode rawTag + match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") + hash.Add ("is_tag", true) + return! themedView "index" next ctx hash + | _ -> return! Error.notFound next ctx + } // GET / let home : HttpHandler = fun next ctx -> task { @@ -531,41 +599,108 @@ module Post = match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with | Some page -> return! - Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} + Hash.FromAnonymousObject {| + page = DisplayPage.fromPage webLog page + page_title = page.title + is_home = true + |} |> themedView (defaultArg page.template "single-page") next ctx | None -> return! Error.notFound next ctx } - // GET {**link} - let catchAll : HttpHandler = fun next ctx -> task { + // GET /feed.xml + // (Routing handled by catch-all handler for future configurability) + let generateFeed : HttpHandler = fun next ctx -> backgroundTask { + let conn = conn ctx + let webLog = WebLogCache.get ctx + // TODO: hard-coded number of items + let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1L 10 conn + let! authors = getAuthors webLog posts conn + let cats = CategoryCache.get ctx + + let toItem (post : Post) = + let urlBase = $"https://{webLog.urlBase}/" + let item = SyndicationItem ( + Id = $"{urlBase}{Permalink.toString post.permalink}", + Title = TextSyndicationContent.CreateHtmlContent post.title, + PublishDate = DateTimeOffset post.publishedOn.Value) + item.AddPermalink (Uri item.Id) + let doc = XmlDocument () + let content = doc.CreateElement ("content", "encoded", "http://purl.org/rss/1.0/modules/content/") + content.InnerText <- post.text + .Replace("src=\"/", $"src=\"{urlBase}") + .Replace ("href=\"/", $"href=\"{urlBase}") + item.ElementExtensions.Add content + item.Authors.Add (SyndicationPerson ( + Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value)) + for catId in post.categoryIds do + let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId) + item.Categories.Add (SyndicationCategory (cat.name, $"{urlBase}category/{cat.slug}/", cat.name)) + for tag in post.tags do + let urlTag = tag.Replace (" ", "+") + item.Categories.Add (SyndicationCategory (tag, $"{urlBase}tag/{urlTag}/", $"{tag} (tag)")) + item + + + let feed = SyndicationFeed () + feed.Title <- TextSyndicationContent webLog.name + feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name + feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn + feed.Generator <- generator ctx + feed.Items <- posts |> Seq.ofList |> Seq.map toItem + + use mem = new MemoryStream () + use xml = XmlWriter.Create mem + let formatter = Rss20FeedFormatter feed + formatter.WriteTo xml + xml.Close () + + let _ = mem.Seek (0L, SeekOrigin.Begin) + let rdr = new StreamReader(mem) + let! output = rdr.ReadToEndAsync () + + return! ( setHttpHeader "Content-Type" "text/xml" >=> setStatusCode 200 >=> setBodyFromString output) next ctx + } + + /// Sequence where the first returned value is the proper handler for the link + let private deriveAction ctx : HttpHandler seq = 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 post -> - let! model = preparePostList webLog [ post ] SinglePost 1 1 ctx conn - model.Add ("page_title", post.title) - return! themedView "single-post" next ctx model - | None -> + let await it = (Async.AwaitTask >> Async.RunSynchronously) it + seq { + // Current post + match Data.Post.findByPermalink permalink webLog.id conn |> await with + | Some post -> + let model = preparePostList webLog [ post ] SinglePost "" 1 1 ctx conn |> await + model.Add ("page_title", post.title) + yield fun next ctx -> themedView "single-post" next ctx model + | None -> () // Current page - match! Data.Page.findByPermalink permalink webLog.id conn with + match Data.Page.findByPermalink permalink webLog.id conn |> await with | Some page -> - return! + yield fun next ctx -> Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} |> themedView (defaultArg page.template "single-page") next ctx - | None -> - // 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 + | None -> () + // RSS feed + // TODO: configure this via web log + if Permalink.toString permalink = "feed.xml" then yield generateFeed + // Prior post + match Data.Post.findCurrentPermalink permalink webLog.id conn |> await with + | Some link -> yield redirectTo true $"/{Permalink.toString link}" + | None -> () + // Prior permalink + match Data.Page.findCurrentPermalink permalink webLog.id conn |> await with + | Some link -> yield redirectTo true $"/{Permalink.toString link}" + | None -> () + } + + // GET {**link} + let catchAll : HttpHandler = fun next ctx -> task { + match deriveAction ctx |> Seq.tryHead with + | Some handler -> return! handler next ctx + | None -> return! Error.notFound next ctx } // GET /posts @@ -574,7 +709,7 @@ module Post = let webLog = WebLogCache.get ctx let conn = conn ctx let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn - let! hash = preparePostList webLog posts AdminList pageNbr 25 ctx conn + let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx conn hash.Add ("page_title", "Posts") return! viewForTheme "admin" "post-list" next ctx hash } @@ -746,6 +881,52 @@ module User = return! redirectToGet "/" next ctx } + /// Display the user edit page, with information possibly filled in + let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { + hash.Add ("page_title", "Edit Your Information") + hash.Add ("csrf", csrfToken ctx) + return! viewForTheme "admin" "user-edit" next ctx hash + } + + // GET /user/edit + let edit : HttpHandler = requireUser >=> fun next ctx -> task { + match! Data.WebLogUser.findById (userId ctx) (conn ctx) with + | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx + | None -> return! Error.notFound next ctx + } + + // POST /user/save + let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + if model.newPassword = model.newPasswordConfirm then + let conn = conn ctx + match! Data.WebLogUser.findById (userId ctx) conn with + | Some user -> + let pw, salt = + if model.newPassword = "" then + user.passwordHash, user.salt + else + let newSalt = Guid.NewGuid () + hashedPassword model.newPassword user.userName newSalt, newSalt + let user = + { user with + firstName = model.firstName + lastName = model.lastName + preferredName = model.preferredName + passwordHash = pw + salt = salt + } + do! Data.WebLogUser.update user conn + let pwMsg = if model.newPassword = "" then "" else " and updated your password" + do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } + return! redirectToGet "/user/edit" next ctx + | None -> return! Error.notFound next ctx + else + do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } + return! showEdit (Hash.FromAnonymousObject {| + model = { model with newPassword = ""; newPasswordConfirm = "" } + |}) next ctx + } open Giraffe.EndpointRouting @@ -765,8 +946,9 @@ let endpoints = [ ] subRoute "/categor" [ GET [ - route "ies" Category.all - routef "y/%s/edit" Category.edit + route "ies" Category.all + routef "y/%s/edit" Category.edit + route "y/{**slug}" Post.pageOfCategorizedPosts ] POST [ route "y/save" Category.save @@ -776,6 +958,7 @@ let endpoints = [ subRoute "/page" [ GET [ routef "/%d" Post.pageOfPosts + //routef "/%d/" (fun pg -> redirectTo true $"/page/{pg}") routef "/%s/edit" Page.edit route "s" (Page.all 1) routef "s/page/%d" Page.all @@ -794,13 +977,20 @@ let endpoints = [ route "/save" Post.save ] ] + subRoute "/tag" [ + GET [ + route "/{**slug}" Post.pageOfTaggedPosts + ] + ] subRoute "/user" [ GET [ + route "/edit" User.edit route "/log-on" (User.logOn None) route "/log-off" User.logOff ] POST [ route "/log-on" User.doLogOn + route "/save" User.save ] ] route "{**link}" Post.catchAll diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 49ea0f1..ca515e6 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -207,9 +207,9 @@ let main args = [ // Domain types typeof; typeof; typeof // View models - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof // Framework types typeof; typeof; typeof; typeof typeof diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index 736ad76..87ee26e 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -28,6 +28,7 @@ {%- endif %} -- 2.45.1 From c07f1b11c91293e62053f0f7b00ecb445a3e701f Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 29 Apr 2022 23:46:26 -0400 Subject: [PATCH 032/102] Add permalink import - Fill publish date on post edit page --- src/MyWebLog.Data/Data.fs | 2 +- src/MyWebLog.Domain/DataTypes.fs | 5 ++ src/MyWebLog.Domain/ViewModels.fs | 7 +-- src/MyWebLog/Handlers.fs | 12 ++-- src/MyWebLog/Program.fs | 55 ++++++++++++++++++- src/MyWebLog/themes/admin/post-edit.liquid | 5 +- .../themes/daniel-j-summers/layout.liquid | 19 ++++--- 7 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 47eab12..b2f2637 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -3,7 +3,7 @@ module MyWebLog.Data /// Table names [] -module private Table = +module Table = /// The category table let Category = "Category" diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 89147f2..2f0b6c1 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -264,6 +264,11 @@ module WebLog = /// Convert a permalink to an absolute URL let absoluteUrl webLog = function Permalink link -> $"{webLog.urlBase}{link}" + + /// Convert a date/time to the web log's local date/time + let localTime webLog (date : DateTime) = + let tz = TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone + TimeZoneInfo.ConvertTimeFromUtc (DateTime (date.Ticks, DateTimeKind.Utc), tz) /// A user of the web log diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index ab2c521..18b7cfe 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -232,7 +232,7 @@ type EditPostModel = setUpdated : bool } /// Create an edit model from an existing past - static member fromPost (post : Post) = + static member fromPost webLog (post : Post) = let latest = match post.revisions |> List.sortByDescending (fun r -> r.asOf) |> List.tryHead with | Some rev -> rev @@ -250,7 +250,7 @@ type EditPostModel = metaNames = post.metadata |> List.map (fun m -> m.name) |> Array.ofList metaValues = post.metadata |> List.map (fun m -> m.value) |> Array.ofList setPublished = false - pubOverride = Nullable () + pubOverride = post.publishedOn |> Option.map (WebLog.localTime webLog) |> Option.toNullable setUpdated = false } @@ -338,8 +338,7 @@ type PostListItem = /// Create a post list item from a post static member fromPost (webLog : WebLog) (post : Post) = - let tz = TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone - let inTZ (it : DateTime) = TimeZoneInfo.ConvertTimeFromUtc (DateTime (it.Ticks, DateTimeKind.Utc), tz) + let inTZ = WebLog.localTime webLog { id = PostId.toString post.id authorId = WebLogUserId.toString post.authorId status = PostStatus.toString post.status diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs index 08eb4b2..0039de6 100644 --- a/src/MyWebLog/Handlers.fs +++ b/src/MyWebLog/Handlers.fs @@ -716,23 +716,23 @@ module Post = // GET /post/{id}/edit let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let! result = task { + let webLog = WebLogCache.get 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 + match! Data.Post.findByFullId (PostId postId) webLog.id 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 + let! cats = Data.Category.findAllForView webLog.id conn return! Hash.FromAnonymousObject {| csrf = csrfToken ctx - model = EditPostModel.fromPost post + model = EditPostModel.fromPost webLog post page_title = title categories = cats |} diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index ca515e6..904b5ea 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -72,6 +72,9 @@ module DotLiquidBespoke = /// Create the default information for a new web log module NewWebLog = + open System.IO + open RethinkDb.Driver.FSharp + /// Create the web log information let private createWebLog (args : string[]) (sp : IServiceProvider) = task { @@ -134,7 +137,7 @@ module NewWebLog = ] } conn - Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}"); + printfn $"Successfully initialized database for {args[2]} with URL base {args[1]}" } /// Create a new web log @@ -142,7 +145,50 @@ module NewWebLog = match args |> Array.length with | 5 -> return! createWebLog args sp | _ -> - Console.WriteLine "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]" + printfn "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]" + return! System.Threading.Tasks.Task.CompletedTask + } + + /// Import prior permalinks from a text files with lines in the format "[old] [new]" + let importPriorPermalinks urlBase file (sp : IServiceProvider) = task { + let conn = sp.GetRequiredService () + + match! Data.WebLog.findByHost urlBase conn with + | Some webLog -> + + let mapping = + File.ReadAllLines file + |> Seq.ofArray + |> Seq.map (fun it -> + let parts = it.Split " " + Permalink parts[0], Permalink parts[1]) + + for old, current in mapping do + match! Data.Post.findByPermalink current webLog.id conn with + | Some post -> + let! withLinks = rethink { + withTable Data.Table.Post + get post.id + result conn + } + do! rethink { + withTable Data.Table.Post + get post.id + update [ "priorPermalinks", old :: withLinks.priorPermalinks :> obj] + write; ignoreResult conn + } + printfn $"{Permalink.toString old} -> {Permalink.toString current}" + | None -> printfn $"Cannot find current post for {Permalink.toString current}" + printfn "Done!" + | None -> printfn $"No web log found at {urlBase}" + } + + /// Import permalinks if all is well + let importPermalinks args sp = task { + match args |> Array.length with + | 3 -> return! importPriorPermalinks args[1] args[2] sp + | _ -> + printfn "Usage: MyWebLog import-permalinks [url] [file-name]" return! System.Threading.Tasks.Task.CompletedTask } @@ -219,7 +265,10 @@ let main args = let app = builder.Build () match args |> Array.tryHead with - | Some it when it = "init" -> NewWebLog.create args app.Services |> Async.AwaitTask |> Async.RunSynchronously + | Some it when it = "init" -> + NewWebLog.create args app.Services |> Async.AwaitTask |> Async.RunSynchronously + | Some it when it = "import-permalinks" -> + NewWebLog.importPermalinks args app.Services |> Async.AwaitTask |> Async.RunSynchronously | _ -> let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseMiddleware () diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index bfb1097..ea7eac5 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -97,7 +97,10 @@
+ placeholder="Override Date" + {%- if model.pub_override -%} + value="{{ model.pub_override | date: "yyyy-MM-dd\THH:mm" }}" + {%- endif %}>
diff --git a/src/MyWebLog/themes/daniel-j-summers/layout.liquid b/src/MyWebLog/themes/daniel-j-summers/layout.liquid index e6a6b43..0af2e21 100644 --- a/src/MyWebLog/themes/daniel-j-summers/layout.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/layout.liquid @@ -5,7 +5,13 @@ - {{ page_title | strip_html }}{% if page_title and page_title != "" %} » {% endif %}{{ web_log.name }} + + {%- if is_home %} + {{ web_log.name }}{% if web_log.subtitle %} | {{ web_log.subtitle.value }}{% endif %} + {%- else %} + {{ page_title | strip_html }}{% if page_title and page_title != "" %} » {% endif %}{{ web_log.name }} + {%- endif %} + @@ -79,9 +85,9 @@ 2021 Season — NR
- (5-5 • 3-4 SEC/3rd East)

- Last — L (17-41) vs. 1 Georgia
- Next — 11/20 vs. South Alabama + (7-6 • 4-4 SEC/3rd East)

+ Last — L* (45-48) vs. Purdue
+ Music City Bowl
@@ -100,9 +106,8 @@ 2021 Season — NR
- (3-6 • 2-3 MWC/4th Mountain)

- Last — L (17-31) at Wyoming
- Next — 11/13 vs. Air Force + (3-9 • 2-6 MWC/5th Mountain)

+ Last — L (10-52) at Nevada
-- 2.45.1 From 22ed55c820836427a22798131db8bdb4508d410f Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 30 Apr 2022 22:48:07 -0400 Subject: [PATCH 033/102] Tweak personal site theme --- .../themes/daniel-j-summers/index.liquid | 22 ++++-- .../themes/daniel-j-summers/layout.liquid | 69 ++++++++++--------- .../daniel-j-summers/single-post.liquid | 16 +++-- .../wwwroot/themes/daniel-j-summers/style.css | 19 ++++- 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/MyWebLog/themes/daniel-j-summers/index.liquid b/src/MyWebLog/themes/daniel-j-summers/index.liquid index eb76ad9..e7d2d91 100644 --- a/src/MyWebLog/themes/daniel-j-summers/index.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/index.liquid @@ -12,12 +12,20 @@ {{ post.title }} -

- {{ post.published_on | date: "MMMM d, yyyy" }}   - {{ post.published_on | date: "h:mm tt" | downcase }}   - {{ model.authors | value: post.author_id }} +

{{ post.text }} @@ -41,9 +49,9 @@

Verse of the Day

- + - (ESV)
+ (ESV)
Powered by Bible Gateway
diff --git a/src/MyWebLog/themes/daniel-j-summers/layout.liquid b/src/MyWebLog/themes/daniel-j-summers/layout.liquid index 0af2e21..d4bc29e 100644 --- a/src/MyWebLog/themes/daniel-j-summers/layout.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/layout.liquid @@ -74,43 +74,39 @@

Tennessee Football

-
- - - -
- - T - - - 2021 Season — NR
- - (7-6 • 4-4 SEC/3rd East)

- Last — L* (45-48) vs. Purdue
- Music City Bowl -
-
+
+ + + T + + +
+ 2021 Season — NR
+ + (7-6 • 4-4 SEC/3rd East)

+ Last — L* (45-48) vs. Purdue
+ Music City Bowl +
+

Colorado State Football

-
- - - -
- - CSU Rams Logo - - - 2021 Season — NR
- - (3-9 • 2-6 MWC/5th Mountain)

- Last — L (10-52) at Nevada -
-
+
+ + + CSU Rams Logo + + +
+ 2021 Season — NR
+ + (3-9 • 2-6 MWC/5th Mountain)

+ Last — L (10-52) at Nevada +
+
@@ -145,7 +141,12 @@ Bit Badger Solutions - • Powered by myWebLog + • Powered by myWebLog • + {% if logged_on %} + Dashboard + {% else %} + Log On + {%- endif %}
diff --git a/src/MyWebLog/themes/daniel-j-summers/single-post.liquid b/src/MyWebLog/themes/daniel-j-summers/single-post.liquid index 5b81e6d..f9d0b14 100644 --- a/src/MyWebLog/themes/daniel-j-summers/single-post.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/single-post.liquid @@ -2,16 +2,20 @@

{{ post.title }}

-

+

{{ post.text }}
diff --git a/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css b/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css index 9103a5c..c1cc720 100644 --- a/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css +++ b/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css @@ -54,7 +54,7 @@ blockquote { padding-left: 1rem; } sup, sub { - font-size: .85rem; + font-size: smaller; } sup { vertical-align: text-top; @@ -130,6 +130,11 @@ sub { color: var(--accent-color); background-color: var(--hdr-bkg-color); } +.post-meta { + display: flex; + flex-flow: row wrap; + justify-content: space-evenly; +} .pager { display: flex; flex-flow: row wrap; @@ -160,7 +165,7 @@ sub { } .cat-list li { list-style-type: none; - padding-bottom: .2rem; + padding-bottom: .25rem; } .cat-list ul li ul > li { padding-top: .2rem; @@ -219,6 +224,16 @@ footer.part-3 { .copy a:hover { text-decoration: underline; } +.football-panel { + display: flex; + flex-flow: row nowrap; + justify-content: space-around; + align-items: center; +} +.football-panel div { + text-align: center; + line-height: 1.6rem; +} /* ----- UTILITY CLASSES ----- */ .desktop { -- 2.45.1 From f106f2b10e8e028f4cbb063712a928b2a3745fb5 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 5 May 2022 02:15:36 -0400 Subject: [PATCH 034/102] Add tech blog theme - Add code colorizer Markdig plugin --- src/MyWebLog.Domain/MyWebLog.Domain.fsproj | 1 + src/MyWebLog.Domain/SupportTypes.fs | 6 +- src/MyWebLog/themes/default/index.liquid | 7 +- src/MyWebLog/themes/tech-blog/index.liquid | 44 ++ src/MyWebLog/themes/tech-blog/layout.liquid | 86 ++++ .../themes/tech-blog/single-page.liquid | 5 + .../themes/tech-blog/single-post.liquid | 41 ++ .../themes/tech-blog/img/bitbadger.png | Bin 0 -> 17821 bytes .../wwwroot/themes/tech-blog/img/facebook.png | Bin 0 -> 6518 bytes .../wwwroot/themes/tech-blog/img/rss.png | Bin 0 -> 13761 bytes .../wwwroot/themes/tech-blog/img/twitter.png | Bin 0 -> 10348 bytes .../wwwroot/themes/tech-blog/style.css | 396 ++++++++++++++++++ 12 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 src/MyWebLog/themes/tech-blog/index.liquid create mode 100644 src/MyWebLog/themes/tech-blog/layout.liquid create mode 100644 src/MyWebLog/themes/tech-blog/single-page.liquid create mode 100644 src/MyWebLog/themes/tech-blog/single-post.liquid create mode 100644 src/MyWebLog/wwwroot/themes/tech-blog/img/bitbadger.png create mode 100644 src/MyWebLog/wwwroot/themes/tech-blog/img/facebook.png create mode 100644 src/MyWebLog/wwwroot/themes/tech-blog/img/rss.png create mode 100644 src/MyWebLog/wwwroot/themes/tech-blog/img/twitter.png create mode 100644 src/MyWebLog/wwwroot/themes/tech-blog/style.css diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index 9366bbb..0a0bea0 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index f5127c8..9f3e67e 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -1,7 +1,6 @@ namespace MyWebLog open System -open Markdig /// Support functions for domain definition [] @@ -55,6 +54,9 @@ type CommentStatus = | Spam +open Markdig +open Markdown.ColorCode + /// Types of markup text type MarkupText = /// Markdown text @@ -66,7 +68,7 @@ type MarkupText = module MarkupText = /// Pipeline with most extensions enabled - let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().Build () + let private _pipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build () /// Get the source type for the markup text let sourceType = function Markdown _ -> "Markdown" | Html _ -> "HTML" diff --git a/src/MyWebLog/themes/default/index.liquid b/src/MyWebLog/themes/default/index.liquid index 4540ab1..e05e474 100644 --- a/src/MyWebLog/themes/default/index.liquid +++ b/src/MyWebLog/themes/default/index.liquid @@ -23,11 +23,14 @@

{%- if category_count > 0 -%} + Categorized under: {{ cat_names | reverse | join: ", " }} {%- for cat in post.category_ids -%} - {%- assign cat_names = model.categories | value: cat | split: "," | concat: cat_names -%} + {%- assign this_cat = categories | where: "id", cat | first -%} + {{ this_cat.name }}, + {%- assign cat_names = this_cat.name | concat: cat_names -%} {%- endfor -%} - Categorized under: {{ cat_names | reverse | join: ", " }}
{%- assign cat_names = "" -%} +
{% endif -%} {%- if tag_count > 0 %} Tagged: {{ post.tags | join: ", " }} diff --git a/src/MyWebLog/themes/tech-blog/index.liquid b/src/MyWebLog/themes/tech-blog/index.liquid new file mode 100644 index 0000000..c87015d --- /dev/null +++ b/src/MyWebLog/themes/tech-blog/index.liquid @@ -0,0 +1,44 @@ +{% if is_category or is_tag %} +

{{ page_title }}

+ {%- if subtitle %} +

{{ subtitle }}

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

+ {{ post.published_on | date: "dddd, MMMM d, yyyy" }}
+   + + {{ post.title }} + +

+
+
{{ post.text }}
+ {%- assign cat_count = post.category_ids | size -%} + {%- if cat_count > 0 %} + + Categorized under + {%- for cat_id in post.category_ids %} + {%- assign cat = categories | where: "id", cat_id | first -%} + + + {%- endfor %} +
+ {%- endif %} + {%- assign tag_count = post.tags | size -%} + {%- if tag_count > 0 %} + + Tagged + {%- for tag in post.tags %} + + + {%- endfor %} +
+ {%- endif %} + {%- if logged_on %}Edit Post{% endif %} +
+{%- endfor %} diff --git a/src/MyWebLog/themes/tech-blog/layout.liquid b/src/MyWebLog/themes/tech-blog/layout.liquid new file mode 100644 index 0000000..f347b90 --- /dev/null +++ b/src/MyWebLog/themes/tech-blog/layout.liquid @@ -0,0 +1,86 @@ + + + + + + + + + {%- if is_home %} + {{ web_log.name }}{% if web_log.subtitle %} | {{ web_log.subtitle.value }}{% endif %} + {%- else %} + {{ page_title | strip_html }}{% if page_title and page_title != "" %} » {% endif %}{{ web_log.name }} + {%- endif %} + + + {% comment %}link(rel='canonical' href=config.url + url_for(page.path.replace('index.html', ''))) {% endcomment %} + {%- if is_home %} + + {%- endif %} + + + +
+
+ {{ content }} +
+ +
+ + + \ No newline at end of file diff --git a/src/MyWebLog/themes/tech-blog/single-page.liquid b/src/MyWebLog/themes/tech-blog/single-page.liquid new file mode 100644 index 0000000..d5a7c5d --- /dev/null +++ b/src/MyWebLog/themes/tech-blog/single-page.liquid @@ -0,0 +1,5 @@ +
+

{{ page.title }}

+
{{ page.text }}
+ {%- if logged_on %}

Edit Page

{% endif %} +
\ No newline at end of file diff --git a/src/MyWebLog/themes/tech-blog/single-post.liquid b/src/MyWebLog/themes/tech-blog/single-post.liquid new file mode 100644 index 0000000..8d929a2 --- /dev/null +++ b/src/MyWebLog/themes/tech-blog/single-post.liquid @@ -0,0 +1,41 @@ +{%- assign post = model.posts | first -%} +
+

+ {{ post.title }}
+ +

+
{{ post.text }}
+ +
diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/img/bitbadger.png b/src/MyWebLog/wwwroot/themes/tech-blog/img/bitbadger.png new file mode 100644 index 0000000000000000000000000000000000000000..62f8d7638d8e71d53a97a7551968a7919c3cd081 GIT binary patch literal 17821 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
    #o_1%P)Ts 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 Am;e9( literal 0 HcmV?d00001 diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/img/facebook.png b/src/MyWebLog/wwwroot/themes/tech-blog/img/facebook.png new file mode 100644 index 0000000000000000000000000000000000000000..3cdde01e60781aa2325518393b80d072ccb945cd GIT binary patch literal 6518 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 literal 0 HcmV?d00001 diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/img/rss.png b/src/MyWebLog/wwwroot/themes/tech-blog/img/rss.png new file mode 100644 index 0000000000000000000000000000000000000000..4cc7b962fa9111bd7c17e1e54c8c76254a4b7e18 GIT binary patch literal 13761 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 literal 0 HcmV?d00001 diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/img/twitter.png b/src/MyWebLog/wwwroot/themes/tech-blog/img/twitter.png new file mode 100644 index 0000000000000000000000000000000000000000..8b5ae7c93873ce8ff6d9c4fc16aaecd432e74c3f GIT binary patch literal 10348 zcmWk!1yCDJ6sCr|yQa89u|lCZ#jQYb4Z+=|P&{aGcc)N-6nED`XmO{wLvi`E7F=Nu3{gR}bls#J?d>fc+<>=8 zNK!7Arf!xNw4M+*Yg#!4Wi<;Xn(}xqIya-jT$!jrgrr#xB51B!95jBNM5EcTi%A?z7&cf zyr$OC~JnlGS8zWmZuY(r%3URf5eUUnjf6$i3pf9 z3^bxAccjz_ufq(sxjCTLj$B7aRQ!U12%mFMOSS8D`}OJ8&WFLO`MP2ZdUJjK#lWW{ z*VU$zzo~)BLIy*>F^HY|lXW)!c`bXc_DAd<9Thh=hAqGM#8d6_J|xIpO9$sBzYOYX zPITT3!`j%J6qE4;^~4A+%WG>B!T8gbqAKWz;rGYJ$2kk|AMCcjt~nO1W+$@k=G)R) zG)qn=C&g8@-5cEgSor<@yBYz`^$t5nBhalidmGR>NtaWgX7pJ_rvEj%;-@q3_MvL1 zM*?GxB`ji4`1gXWqASLc{r9;>ED0SjNu{yAGC%R(P@2bxOot9V5eBlr>;1|NsH%U^ zyw1dW3XIE&%ri*=M zJov+)6ia71?@uJD#2^JpZ9=yilHA!vRdZ<{pSdQ}S5vx}z1%l{xdMly_mbNl7Ii=} z+Aq`);t$IwescBo{1rQDB zv|o|XgFmJi1d1K}sd`~@d;YiWvNH8%9?BJ^8nCaaF~BPzAP}YR(3WmXY}i{M14U{& zE6HSL;c&0Y{{eBdXUd6ab*zWF22bu378UVp5()<#3Z0zHVRyYhX;XC7WH=CXguM(x z;_d2ux)~gb8ha2=kBc#p?3e!ZNzqB;Ah@wnz$}uq=h3Be;*5~YTD8c1q z@00y;vmoPkeR+D)c_a9OmLjaX#@Hva*>$JDh8*dlqVoPN*QWCiuur#&L z$X!JWiSeLFWeUN9KgX-8&JY%jksG40-lFk>1bHj!Rww6tc}6V1sKhR`P>l6CXMXL% zA3q|#HK3>+?C+z7O3tzT5iXhWwp$?Xu`Gl_Uyt(gEuG)Bkxje_ei2JqE_bxI&q#^= zO;fV(;LM^b-vVQnx24G%w;)cHdO5RSn9DFbyDU=rrJg!&IhgXr@k3ZPY=_gpfRNY9 zSy7(8S6D`UH>N)!bTR+PtSW((qJrgqekT^C#CfSc77E=dMadkgdbYAbc%-~1ejKa7 zAR(F6#@RAhaIci&{pB&gYnGIjww>}W+H#DT)eMQk@WtlFhI6erML&{2OSq1~7M50c z)l1XGZ|(KC4u%W;GBaESov9NWl#)#;Q!*d)27)B}J^t21)d{)H(gq>CBF(4Wc7)Ym zot>xNDABwMS3R10MSA!}mZZ?0?1*luUjD3plCvdcs()8^;AshduP3Y1)#UUh8ag_s z9mj5R81Qny9lHJ*XKhhfel3%%+-3C7`v5nH%$4 z_Ippb|H0?|gM-OABKP`yx(nUwU4wUl9~4vAEw{(grD7XC(CDUwnv#08uPpIsU|N}% zyhP|QS;U7pJ3GraIUI@n`K~9Ws)~aIXD2{KKD&P`tB5KoryU+Dzc*c+sGqgq{m4J9 z$WLrO*m!g}*O<+sS^C577QLdfaw3;R@`vl-ecR*JqTkaVSM*)`MD?-WD;tEyfHF?% zLapJb7mw5K+Q)#$=^BWmqZAAj6%~d0=9`zFk(RPtvZaOg$CNF0+nxBSMti%(0d6d% zUJww0-Ytm)gQ};9z*~}vii+FQT>p@so*q|tNUO)uM!V-qesgnrKeT2%hD7vR?q`%b z2fO(u-yKf7*BHd?q}QN&`^?{gH?M40T#+qOQj+npNjA=Wrrfc#)zvi@=?V3yw>%Kz z)u@ltHLRU|i@b~hY z%hPioFJ(Sg8FYXDxw>-Veko0VxZIv;_}+8MX-j_l1dD_i9}~G2Zz5z~jkzw}#O;Kq zS7h^ppgmZON7ioXrzg#@1x$i_2rB-_q8f0_GBP?k_!cklp^ygg%P9nnfJsePH)3{Y z@W45uY=~Eb_(eEA{rsvX2rc2Ru7J6$F{;GIIwQ8E+^fwZ?rL1o(1d>@JGI}gX*>uPDUE#|UC_BXZnl|63_k?Bimhxt zQoTn-Mvu1$y=oK)xa`3YJ=-E6OihYrn|-**ea?&d9ejX14NBIallN ziyh*Y;!ZH1<(fmrZc@e9bgM2&pO9Bxp3u`nZ=qLG)s|HMsAy!2`lX*vFw zU`Z&0tRUMn|HYCVSyKC^ha`7f-q8Ozw1QMrOKF_>C%eFvcyDf|j-Ihj_@wdI>H{F8 z?Dds$ctcAFn7p2HpVJ4$A0a?+Ul_cjF+0k5r<5&_?8F?sdLpNa$74UmNeoA00Vk-> zicRbaD@?>|!KhO0FepahKd>kk;Z65Bt!UxBlx5$?v7Uz?xIFGA9CY@CB7$mU{i_1#nvZKG0Cd#a2Lu;K4saH*>(_8wqkW$u?I;W!sp zG^+#DW+-d@P_poyl!cwHx`Qb__kGIWC7BcyQ}3`0XMr0C4G20=Bm`07Dr!kb7PC^cK&fSBiiqd=XYr{D*he$gIG#)n_aI~Z>e%Qw!UEeAy$}+b5B+G>` z^vo7!Soj45&Jei|A=;j+1i{bG*=MV^qa&nPPJ^01jq)mh@PTDl zx1J$C-BlX1Ewp*_iIw{?be$)&2|1nO7XSTQ__Q#|+92g&kgn;;kzb~QFVwCiMs;%Y z7EJ+`&dLS=*Rzq)&ipekhRWk+3XTR&3-(&@wS!ySAYH+r`uL|DetuLs0Oxv%s;H`l zJifc1Z}WED_KurWWG19n!!{b&YaF5 zFLo7fQV=dW9k^W}i0^WlU#U7xEd2TGUyY*vPdmJxiEd?@f|uW6Om;~i;W}f7wQ(f^ ze1#q+1!j0~6|sD>9?DA@-4*4;;Z>*TwRiqVN zQpTE5v2o!IIT2E}wPowPxt%Pp%9=PD)lt(E_P;b6kcLU*bty>2xVYR0XBF7Lx0FDD z(v|Shk#B*Jk~0-*^FLZ>fGQz|LhDOwPE4B+L9bdo`qoyd+_kin3Tn&IOc%kHhValB z_R%tUbcL;TiwZMg-uH%>u&~01Q4B8bb~p@Y$%m~DJV{LyN;`@U%j$fulqFgyGcAuF zH;6_O=6#>GVy&{5$L)#W8enquGb<5fLU;?13!Cha5SOUT5WUILE_2I+FBjLO^2D z4PK^#0>-6vCC3HqUV^2g#`FvEnQk=BvO5!w_<6((0>L0qWt@8g ztNoho-?+Rj2dYWjtRgM$|Da}hUIwhgwD{8=eBXEkP=Shy%4U~i1FNw#=LuE=S&>$) zF5Uj9a*e!_1JQ>e1`Su&y182W_uHgZ4#W&+(V`g!i+@%oa@lpewqq!2mFj9Z{d1Zd z>Kn!wi)Rnss^R7p)rZJC0;t>&pcS={O<4i^rHH}Jd>7fb2u*Mj(`T(L9>+3k8%Dw8 zxK9gb{1e^P3kzS=TLYix+EW6aZE^UTJ=Sp1Tyhuojt|X#FIUbr1yCsDa4 zvatha0Htg5-`3F#7Ts%k{6^e4Nj3etz-Z%NLoU){b>b7t6jX78&HEL%Gc$*y^}EK_ zG7M7k5=BoRlo6*z)08C;d{ja;0}W|lzZ2wfp#u?@SNL)L)(}WZak01j!NK)?I=AUd znf`un1$hNn)?tW!DY@Xl-8*c-8c=H}dnNGqRAx_29QpbcRp+RRJ_$Yln+Gc}ViEq` z`1}qY8jIUgzP2lp^0yn#u7v1=bFlOMo8wo=U>ueX25>L$dXX$|ZB&wSs#r-677a#&n**w1J^IaFyuL{Se&YYsRQWGEE6f(fP2mK;uyNHiFwikkUHc7Fql^9`KN)@z_v@8ePP3vCur#%#L zBwA8T4IT(SY^yJ}S#*nkW&M!ZT@o5ez;>>vgwrkTJ_L3#$h^938-h#bbxF~xeT96H z_Vtaty!?egNL5wGLXr_@xBkq`oTC#f{3SLBf&P9OH&6G!L0J75S21pB2ZW}@sC02~?JhcBd@Xri53O5TBKPg7^n|tH-UV#fqxmPTPs;Lq-tEuQ- z>x|`$pP3KFhiKX;(p6?3&JV-^i5Vp_ReTzqn~)9z7{sk zD;wW7IcY7$LOP+oSf17y(gZ{ zuG|9Ev8{6=YH^ROWHz14--U(;UMte|>=?cEW!2RKjfW4W>uTlHJOy9LC940319)|o4&#d!(3`12I?z-9@Dc|b^XI%gtBreK|4 zzssH!O-rIRyR0bjX#HKQ5FeRxue3H1qjJWU+tF5GBJsCQ@$=&@o`s5q-}C*A-}U_0z_kvJU^08d;+<}3ChlNLL96Vkt(+EV+ZLRa2yuVnG zgoNM5*Td6u?r} zuB{Tqu!YNNKQ$RCFC4DLdF*%>xZx$*n(5MXSi`ijE8(t7Rsw&|j}^``eE zFgI0;@>rt#w4CZZE{1@Ou@*dO^zzLM!g1lz!{`qQz^6w6V(0B2;9b8~D_qs1m|(+9rtG?yDjcDJCT*}Ib)K)y(vRxa}I?~;(I z9x)n2B!^wc=_TTDuC_NFCg6~L=``NpJv0z@$8ghTbll=gbmkS^XSsA>tw$VYiW}Fj zciWA#ad9W~$H~o)+acsQlJ2!q4jiw$L^;!ItsA8{be5f-IxbNO#LdMZY1O*pMgMU= z1a0ZL>s6hAgRXe&;p(Z~82>ExB4)bW3#Xku6%V^rx*YE-#p%mEM3#%mAJDvFb`pe_ z5vIV7*xKXzFn(;j$9K=A(RIKQJ>Mf7^y@zd{%ZQ;6~ zo6fM*rbQLiJY~j2X>*?A$J6!@DY~np<@YLueF+)1lU*x*Edq`UO18F@HH!VyP)o)})F65-2&FH2lkIEZ zL5v_oXqxuvfiE|q~dZ-Ve6 zqZ=q4<%|);Q{2k<&S0MtWB;Q{;Gl2ZW@Pwpo^(Ve;4ol1&{1%+@#kVi;Rb)#Z*)1_ z<#FB|7d)FYk5j-Af8egEG2&S9JK#_e_?gE%AxW?8VdYwxA;9@59AfM6g?Lz+vX}n` z3hoGzoAm}3Miv+L!I$}3V#0DrNbCLNnveU_#?~-#zx3i6uC*_l0fii3^Gi(Qo@mY|4Z{$Y+jg z@~u{luxUQ9|7rUfwj%#J@Z$>tHdba<_A$Gm5jn|)#u$hm$c$%W7(^3zsGhRy9DaGc z=m^~|DRFkdK>6~M&dSI5(+~U1$9M1S=Hbc5)trPf`DW9go^|dwZvxtk9F06~EuhI3 zJ$Oa{Z9rxFTou3Ke{Vna>sMECY{!d>$E&7D=be(mDdugy{$uD6V#br-AS^G9Gk(qT}oE~bq7 z&9qKO&%^CSvAgYT6d>yuN;W#K1&r|7X}^to6aB+}K}5tFVzXf_^=dGVX3{Pxl_+$2 zS|d20_Cu$Xr1Eth#KwsFW|G>FL=^<27D5t|i`9-R?UB)uqMxgGg+eY{s0L%RArHDyV$5U|G(6HXq&1rvzMX8YMJDmbP z0J)u*L&Vj-%3bG`e)hD^_~U5o-hs!1pv+Xlh4wf5bpQ6-8#T_cT)cvdKdj?ySSXGb zlMqHFG#6uAKse206<}v~epUV_hOt!d15`RI4@BN{(+0g1r>+s?3zXB;X3GE0?5*50 zCYq(?jiaz}rYbf;#r9dA>WFG%TySNmuPv35M26;tz3h@;wtl}YMy91FdueTXWX!=V z*g?YQD6D{WHzQVVR_z^?;q0$>%jtE6mw2k$L|VUVgJJAo71Zs_FDf)SyqLWZFTmhY z*Bd73xZzJ^R885(o{em<{bNFkocgAZ+s3CXYtp&}qL;#*sPM;b{*$9)h>4K2sk8dF zD3pGfR*@NA*QB9MWFe!Rz~wHjtp)|p&>x$FH=z#cK`52*?#O( z>gn{>=+2!>M#y}#6ao&EXnY`j1**{E#(&Ak>~B43^Br#an{PgFDaB)bioktoWioha zjY*F%?)@0b9D@rjA!Dd#KgaWffz>7;RSgkDs|srQ79LJ)Sp6W z%hMOaAZDZr(H5{~ZEex3VO0%1J(jnL!!eYvM}arkWtuotd{OK8i6k^B zmT>T-17N501&3m!>ubM#Oa(s$Gv(ebfsORr?`{Q+e6t%HKeGcw{~f>br0(FMmvfbt zv(;*prD=MJIaUm)qN3KRTk_aSl-1!zwNFeX&cC@}jK3O24P zNL`yqS)1x7$LLU0H2d%)_N5}stvd#HZ&g)C0nP;|#P%2nxF(4I2Ycqo$R~%6zr+s4 z9`|4|>PZYFISTT=tD{ATm^(TNgR8At4$u4N#Dnbx`JzB+kIiA>LTXzR%l zhTzVXNB)4|mxQ}yq=bSL0M`n<{tl|^F~|!zKOiIoH~7zCC7$o~VRIYDdLE7!ac-6A zMYPn(3+l=RH{NuU$>U|9-h~5#aU|Q$gyq8J-ZaP071m{!gYQU(HRX7jf{rDPjVS{J zOx!S8#-mMCnnAM>SO`e*D?f+@@q7YGSisDsFSOT7A1~T-CyuJn`1~Pgz7AaLv@S`^ zVU$(-6={5Yd}A%>)eO}KCEj$mdBOUSJ@d`@^6a3OjP`E_n*=yQ^77Z2v9RRS-zon; z|Et4!rF0SZ&piW%$X9 zTU#j*+yLQC7@$GTt6&XXmjrelEwp!9o+cKsTc&1aq%}1Olw)E*_-LsXJ4yaRnyyDq zy^?fX?}ykC1AKbx zbXdC2Z^%H3DpE)Jj!65r8sU5-9UiJaAVa5xDXNF3YF9xES3-uH{ zEu>N}7z_}*^ju1tLqw#2uT1*(1tJa%8D(Z0Ayct$wsYfIcqyC5%3CqH!!%7X>?fYi zznnMK&+M$ezp2+VB-*G1HDL}BflPyd@J$uoTcwBrM6uUj*}9xX4nE8A}u8p zNrnHNrD*Hq&cV*!@@V_SHO!K1d2{%oxprz@Pc)&ckpl>vXdguGN_2_MrJE<9!OLrE zGBjF>B(@W#OXPjck?(udZEd1`E%h-=T`t&~|C6sA1?}$s@&C5Q)J8u{6qq<^$D{W3RR4!1fLikyBnnzlo1jUfuGuJcQ`|iqLX&_4mYabpY-iq(7p28 zN8~alL?bAm^tgv}&EKSXgCz27SP4c(HG0RExULVDI^s<>`p|~pArgSmWg2)C@H%*+ zn$wQX>+(!hB$&+Pc7#a7-TfVtzDn>{Y0>VxhJ&n`SgbcsNQq3!vI#`jiN{7@e@Ml^ z7K`TTz7ZH2_SWlU`TBfHFnjHy{IHK zF!99E0!H0UlV5DqYxUp=$EHEftE|kFFADE`{PUAf`VdT(?H4y(h$BwF;(KDq zsG1%%d+^3&WNUcIX%h>Or?06zomH$I9l^^B|LPkXL}ZfXxyHwpmja(3{dRm;li?ht z{J-Eb^mx=A&o652?*$lDvgAL~##ams8Sw0&S2vxb177m7=Y0PUTnSfAf`*0$AdP`G z#acyLt?ljqextdg757_zqkyP?N^xfvq=3raKJ4V+SL)O-0#crl(aPlYJq9{68(aU* zKTG^gPEOC4=o?d{Q@YWbr7C!FgM5>EDg8hbS%eHgpwHqizB>jgYRq)&8yBsceV}{I z^>FHab{%-_*#|?5f<`g3v%9UjlYmv#=yrF7ly$&N`Dzi44@ZKxyZ+Z$@29_rBq9kf zIB7jhI{HIbpP#OE8)H^?7;rJyuJ04x&ifA+@jPsT6Cpr@RZIn| t)*5Gcw}Wg7ymc?DafcuS4)}%{|AUrZ^{4w literal 0 HcmV?d00001 diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/style.css b/src/MyWebLog/wwwroot/themes/tech-blog/style.css new file mode 100644 index 0000000..d1a8408 --- /dev/null +++ b/src/MyWebLog/wwwroot/themes/tech-blog/style.css @@ -0,0 +1,396 @@ +@import url('https://fonts.googleapis.com/css?family=Oswald|Raleway'); +@font-face { + font-family: 'JetBrains Mono'; + src: url('https://raw.githubusercontent.com/JetBrains/JetBrainsMono/master/fonts/webfonts/JetBrainsMono-Regular.woff2') format('woff2'), + url('https://raw.githubusercontent.com/JetBrains/JetBrainsMono/master/fonts/ttf/JetBrainsMono-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} +html { + background-color: lightgray; +} +body, .entry-meta { + font-family: "Raleway", "Segoe UI", Ubuntu, Tahoma, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; +} +body { + margin: 0px; + background-color: #FFFAFA; +} +a { + color: navy; + text-decoration: none; +} +a:hover { + border-bottom: dotted 1px navy; +} +a img { + border:0; +} +acronym { + border-bottom:dotted 1px black; +} +header, h1, h2, h3, footer a, .home-lead a, .highlight { + font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; +} +h1 { + text-align: center; + margin: 1.4rem 0; + font-size: 2rem; +} +h2 { + margin: 1.2rem 0; +} +h3 { + margin: 1rem 0; +} +h2, h3 { + border-bottom: solid 2px navy; +} +@media all and (min-width:40rem) { + h2, h3 { + width: 80%; + } +} +p { + margin: 1rem 0; +} +code, pre { + font-family: "JetBrains Mono","SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; + font-size: 1rem; +} +code { + background-color: rgba(0, 0, 0, .1); + padding: 0 .25rem; +} +#content { + margin: 0 1rem; + font-size: 1.1rem; +} +.auto { + margin: 0 auto; +} +@media all and (min-width: 68rem) { + .content { + width: 66rem; + } +} +.hdr { + font-size: 14pt; + font-weight: bold; +} +.site-header { + height: 100px; + display: flex; + flex-direction: row; + justify-content: space-between; + background-image: -webkit-gradient(linear, left top, left bottom, from(lightgray), to(#FFFAFA)); + background-image: -webkit-linear-gradient(top, lightgray, #FFFAFA); + background-image: -moz-linear-gradient(top, lightgray, #FFFAFA); + background-image: linear-gradient(to bottom, lightgray, #FFFAFA); +} +.site-header a:link, .site-header a:visited { + color: black; +} +.site-header a:hover { + border-bottom:none; +} +.header-title { + font-size: 3rem; + font-weight: bold; + line-height: 100px; + text-align: center; +} +.header-spacer { + flex-grow: 3; +} +.header-social { + padding: 25px .8rem 0 0; +} +.header-social img { + width: 50px; + height: 50px; +} +@media all and (max-width:40rem) { + .site-header { + height: auto; + flex-direction: column; + align-items: center; + } + .header-title { + line-height: 3rem; + } + .header-spacer { + display: none; + } + .header-social { + padding-right: 0; + } +} +.content-item { + padding-left: 5px; + padding-right: 5px; + font-size: 1rem; +} +article.page .metadata { + display: none; +} +.strike { + text-decoration: line-through; +} +.category-list span:after { + content: ", "; +} +.category-list span:last-of-type:after { + content: ""; +} +footer { + padding: 20px 15px 10px 15px; + display: flex; + flex-direction: row; + justify-content: space-between; + font-size: 1rem; + color: black; + clear: both; + background-image: -webkit-gradient(linear, left top, left bottom, from(#FFFAFA), to(lightgray)); + background-image: -webkit-linear-gradient(top, #FFFAFA, lightgray); + background-image: -moz-linear-gradient(top, #FFFAFA, lightgray); + background-image: linear-gradient(to bottom, #FFFAFA, lightgray); +} +footer a:link, footer a:visited { + color: black; +} +.alignleft { + float:left; + padding-right: 5px; +} +ul { + padding-left: 40px; +} +li { + list-style-type: disc; +} + +.content-wrapper { + margin: 0 1rem; +} +@media all and (min-width: 80rem) { + .content-wrapper { + margin: 0; + display: flex; + flex-flow: row; + align-items: flex-start; + justify-content: space-around; + } +} +.home-title { + text-align: left; + line-height: 2rem; +} +.home-lead { + font-family: "Raleway", "Segoe UI", Ubuntu, Tahoma, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + font-size: 1.2rem; +} +.home-break { + width: 80%; + border: dotted 1px lightgray; + border-bottom: 0; +} +.blog-sidebar { + border-top: dotted 1px lightgray; + padding-top: 1rem; + font-size: 1rem; + display: flex; + flex-flow: row wrap; + justify-content: space-around; +} +.blog-sidebar ul { + padding-left: 1rem; +} +.blog-sidebar > ul { + padding: 0; + margin: 0; +} +.blog-sidebar li { + list-style: none; +} +.blog-sidebar li:before { + content: '» '; +} +@media all and (min-width: 68rem) { + .blog-sidebar { + width: 66rem; + margin: auto; + } +} +@media all and (min-width: 80rem) { + .blog-sidebar { + width: 12rem; + border-top: none; + border-left: dotted 1px lightgray; + padding-top: 0; + padding-left: 2rem; + margin: 0; + flex-direction: column; + } +} +.blog-sidebar a { + font-size: 10pt; + font-family: sans-serif; +} +.sidebar-head { + text-align: center; + font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + font-weight: bold; + color: maroon; + margin-bottom: .8rem; + padding: 3px 12px; + border-bottom: solid 2px lightgray; + font-size: 1rem; +} +.app-sidebar-name, .app-sidebar-description { + margin: 0; + padding: 0; +} +.app-sidebar-description { + font-style: italic; + color: #555555; + padding-bottom: .6rem; +} + + +.content { + font-size: 1.1rem; +} +.auto { + margin: 0 auto; +} +.auto > div { + max-width: 66rem; +} +@media all and (min-width: 68rem) { + .content { + width: 66rem; + } +} +.entry-title { + line-height: 1.5rem; +} +.entry-meta { + font-size: 1rem; +} +.cat-list-count { + padding-left: .3rem; + font-size: .8rem; +} +.cat-list-count:before { + content: '('; +} +.cat-list-count:after { + content: ')'; +} +.bottom-nav { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + padding-top: 1.5rem; +} + +figure.highlight { + background-color: #F8F8F8; +} +figure.highlight table { + background-color: #002b36; + width: 100%; +} +figure.highlight td.gutter, +figure.highlight td.code, +figure.highlight pre { + padding: 0; + border: 0; + background-color: #002b36; + color: #839496; +} +figure.highlight td.gutter { + text-align:right; + padding-right: .4rem; +} +figure.highlight td.gutter div.line:after { + content: ':'; + color: #586e75; +} +figure.highlight td.code pre div.line:after { + content: '.'; + visibility: hidden; +} +figure.highlight pre div.line, +figure.highlight pre div.line > * { + font-family: Consolas,"Courier New",Courier,monospace !important; +} +figure.highlight { + display: block; + overflow-x: auto; + padding: 0.5em; + color: #839496; + border:1px dashed #ddd; +} +figure.highlight .comment, +figure.highlight .quote { + color: #586e75; +} +/* Solarized Green */ +figure.highlight .keyword, +figure.highlight .selector-tag, +figure.highlight .addition { + color: #859900; +} +/* Solarized Cyan */ +figure.highlight .number, +figure.highlight .string, +figure.highlight .meta .meta-string, +figure.highlight .literal, +figure.highlight .doctag, +figure.highlight .regexp { + color: #2aa198; +} +/* Solarized Blue */ +figure.highlight .title, +figure.highlight .section, +figure.highlight .name, +figure.highlight .selector-id, +figure.highlight .selector-class { + color: #268bd2; +} +/* Solarized Yellow */ +figure.highlight .attribute, +figure.highlight .attr, +figure.highlight .variable, +figure.highlight .template-variable, +figure.highlight .class .title, +figure.highlight .type { + color: #b58900; +} +/* Solarized Orange */ +figure.highlight .symbol, +figure.highlight .bullet, +figure.highlight .subst, +figure.highlight .meta, +figure.highlight .meta .keyword, +figure.highlight .selector-attr, +figure.highlight .selector-pseudo, +figure.highlight .link { + color: #cb4b16; +} +/* Solarized Red */ +figure.highlight .built_in, +figure.highlight .deletion { + color: #dc322f; +} +figure.highlight .formula { + background: #eee8d5; +} +figure.highlight .emphasis { + font-style: italic; +} +figure.highlight .strong { + font-weight: bold; +} -- 2.45.1 From e2f94edb9ef9ed2e53d5ef425797d7a50a80ddb1 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 13 May 2022 09:18:56 -0400 Subject: [PATCH 035/102] WIP on local Bootstrap lib support - Sort categories case-insensitively - Strip HTML from category description --- src/MyWebLog.Data/Data.fs | 2 +- src/MyWebLog/themes/admin/layout.liquid | 12 ++++++ src/MyWebLog/themes/admin/post-edit.liquid | 2 +- src/MyWebLog/themes/tech-blog/index.liquid | 12 ++++++ .../wwwroot/themes/tech-blog/style.css | 37 ++++++++++++++----- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index b2f2637..058f108 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -178,7 +178,7 @@ module Category = let! cats = rethink { withTable Table.Category getAll [ webLogId ] (nameof webLogId) - orderBy "name" + orderByFunc (fun it -> it.G("name").Downcase () :> obj) result; withRetryDefault conn } let ordered = orderByHierarchy cats None None [] diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index 87ee26e..b779c99 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -65,6 +65,18 @@ + diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index ea7eac5..7629d13 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -125,7 +125,7 @@
diff --git a/src/MyWebLog/themes/tech-blog/index.liquid b/src/MyWebLog/themes/tech-blog/index.liquid index c87015d..f1b8c33 100644 --- a/src/MyWebLog/themes/tech-blog/index.liquid +++ b/src/MyWebLog/themes/tech-blog/index.liquid @@ -42,3 +42,15 @@ {%- if logged_on %}Edit Post{% endif %} {%- endfor %} + diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/style.css b/src/MyWebLog/wwwroot/themes/tech-blog/style.css index d1a8408..18d43c2 100644 --- a/src/MyWebLog/wwwroot/themes/tech-blog/style.css +++ b/src/MyWebLog/wwwroot/themes/tech-blog/style.css @@ -8,29 +8,31 @@ } html { background-color: lightgray; + scroll-behavior: smooth; } body, .entry-meta { - font-family: "Raleway", "Segoe UI", Ubuntu, Tahoma, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + font-family: "Raleway", "Segoe UI", Ubuntu, Tahoma, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; } body { - margin: 0px; - background-color: #FFFAFA; + margin: 0; + background-color: #FFFAFA; } a { - color: navy; - text-decoration: none; + color: navy; + text-decoration: none; } a:hover { - border-bottom: dotted 1px navy; + border-bottom: dotted 1px navy; } a img { - border:0; + border:0; } acronym { - border-bottom:dotted 1px black; + border-bottom:dotted 1px black; + text-decoration: none; } header, h1, h2, h3, footer a, .home-lead a, .highlight { - font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; + font-family: "Oswald", "Segoe UI", Ubuntu, "DejaVu Sans", "Liberation Sans", Arial, sans-serif; } h1 { text-align: center; @@ -61,6 +63,20 @@ code, pre { code { background-color: rgba(0, 0, 0, .1); padding: 0 .25rem; + white-space: pre; +} +pre { + background-color: rgba(0, 0, 0, .9); + color: rgba(255, 255, 255, .9); + padding: .5rem; + border-radius: .5rem; + overflow: auto; +} +pre > code { + background-color: unset; +} +div[style="color:#DADADA;background-color:#1E1E1E;"] { + background-color: unset !important; } #content { margin: 0 1rem; @@ -224,7 +240,8 @@ li { } @media all and (min-width: 80rem) { .blog-sidebar { - width: 12rem; + min-width: 12rem; + max-width: 16rem; border-top: none; border-left: dotted 1px lightgray; padding-top: 0; -- 2.45.1 From 13e9919f58444231c80df1992d1aef383977854b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 13 May 2022 22:55:25 -0400 Subject: [PATCH 036/102] Finish RSS feed - Redirect hyphenated tags to spaced if they exist - Tweak personal index template --- src/MyWebLog/Handlers.fs | 60 +++++++++++++------ .../themes/daniel-j-summers/index.liquid | 4 +- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs index 0039de6..0ab62dc 100644 --- a/src/MyWebLog/Handlers.fs +++ b/src/MyWebLog/Handlers.fs @@ -461,6 +461,7 @@ module Post = open System.IO open System.ServiceModel.Syndication + open System.Text.RegularExpressions open System.Xml /// Split the "rest" capture for categories and tags into the page number and category/tag URL parts @@ -587,7 +588,14 @@ module Post = hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") hash.Add ("is_tag", true) return! themedView "index" next ctx hash - | _ -> return! Error.notFound next ctx + // Other systems use hyphens for spaces; redirect if this is an old tag link + | _ -> + let spacedTag = tag.Replace ("-", " ") + match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + | posts when List.length posts > 0 -> + let endUrl = if pageNbr = 1L then "" else $"page/{pageNbr}" + return! redirectTo true $"""/tag/{spacedTag.Replace (" ", "+")}/{endUrl}""" next ctx + | _ -> return! Error.notFound next ctx } // GET / @@ -613,32 +621,41 @@ module Post = let generateFeed : HttpHandler = fun next ctx -> backgroundTask { let conn = conn ctx let webLog = WebLogCache.get ctx + let urlBase = $"https://{webLog.urlBase}/" // TODO: hard-coded number of items let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1L 10 conn let! authors = getAuthors webLog posts conn let cats = CategoryCache.get ctx let toItem (post : Post) = - let urlBase = $"https://{webLog.urlBase}/" + let plainText = + Regex.Replace (post.text, "<(.|\n)*?>", "") + |> function + | txt when txt.Length < 255 -> txt + | txt -> $"{txt.Substring (0, 252)}..." let item = SyndicationItem ( - Id = $"{urlBase}{Permalink.toString post.permalink}", - Title = TextSyndicationContent.CreateHtmlContent post.title, - PublishDate = DateTimeOffset post.publishedOn.Value) + Id = $"{urlBase}{Permalink.toString post.permalink}", + Title = TextSyndicationContent.CreateHtmlContent post.title, + PublishDate = DateTimeOffset post.publishedOn.Value, + LastUpdatedTime = DateTimeOffset post.updatedOn, + Content = TextSyndicationContent.CreatePlaintextContent plainText) item.AddPermalink (Uri item.Id) - let doc = XmlDocument () - let content = doc.CreateElement ("content", "encoded", "http://purl.org/rss/1.0/modules/content/") - content.InnerText <- post.text - .Replace("src=\"/", $"src=\"{urlBase}") - .Replace ("href=\"/", $"href=\"{urlBase}") - item.ElementExtensions.Add content + + let encoded = post.text.Replace("src=\"/", $"src=\"{urlBase}").Replace ("href=\"/", $"href=\"{urlBase}") + item.ElementExtensions.Add ("encoded", "http://purl.org/rss/1.0/modules/content/", encoded) item.Authors.Add (SyndicationPerson ( Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value)) - for catId in post.categoryIds do - let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId) - item.Categories.Add (SyndicationCategory (cat.name, $"{urlBase}category/{cat.slug}/", cat.name)) - for tag in post.tags do - let urlTag = tag.Replace (" ", "+") - item.Categories.Add (SyndicationCategory (tag, $"{urlBase}tag/{urlTag}/", $"{tag} (tag)")) + [ post.categoryIds + |> List.map (fun catId -> + let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId) + SyndicationCategory (cat.name, $"{urlBase}category/{cat.slug}/", cat.name)) + post.tags + |> List.map (fun tag -> + let urlTag = tag.Replace (" ", "+") + SyndicationCategory (tag, $"{urlBase}tag/{urlTag}/", $"{tag} (tag)")) + ] + |> List.concat + |> List.iter item.Categories.Add item @@ -648,11 +665,16 @@ module Post = feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn feed.Generator <- generator ctx feed.Items <- posts |> Seq.ofList |> Seq.map toItem + feed.Language <- "en" + feed.Id <- urlBase + + feed.Links.Add (SyndicationLink (Uri $"{urlBase}feed.xml", "self", "", "application/rss+xml", 0L)) + feed.AttributeExtensions.Add (XmlQualifiedName ("content", "http://www.w3.org/2000/xmlns/"), "http://purl.org/rss/1.0/modules/content/") + feed.ElementExtensions.Add ("link", "", urlBase) use mem = new MemoryStream () use xml = XmlWriter.Create mem - let formatter = Rss20FeedFormatter feed - formatter.WriteTo xml + feed.SaveAsRss20 xml xml.Close () let _ = mem.Seek (0L, SeekOrigin.Begin) diff --git a/src/MyWebLog/themes/daniel-j-summers/index.liquid b/src/MyWebLog/themes/daniel-j-summers/index.liquid index e7d2d91..551439c 100644 --- a/src/MyWebLog/themes/daniel-j-summers/index.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/index.liquid @@ -17,10 +17,10 @@ {{ post.published_on | date: "dddd, MMMM d, yyyy" }} - {{ post.published_on | date: "h:mm tt" | downcase }} + {{ post.published_on | date: "h:mm tt" | downcase }} - {{ model.authors | value: post.author_id }} + {{ model.authors | value: post.author_id }} {% if logged_on %} -- 2.45.1 From 20b7ba1150031b34c412240f8a3cb9b5d159e7f3 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 18 May 2022 17:04:10 -0400 Subject: [PATCH 037/102] Split handlers into individual files - Fix 500 when viewing draft posts --- src/MyWebLog.Data/MyWebLog.Data.fsproj | 6 +- src/MyWebLog.Domain/MyWebLog.Domain.fsproj | 2 +- src/MyWebLog/Handlers.fs | 1019 -------------------- src/MyWebLog/Handlers/Admin.fs | 95 ++ src/MyWebLog/Handlers/Category.fs | 82 ++ src/MyWebLog/Handlers/Error.fs | 18 + src/MyWebLog/Handlers/Helpers.fs | 171 ++++ src/MyWebLog/Handlers/Page.fs | 100 ++ src/MyWebLog/Handlers/Post.fs | 397 ++++++++ src/MyWebLog/Handlers/Routes.fs | 70 ++ src/MyWebLog/Handlers/User.fs | 117 +++ src/MyWebLog/MyWebLog.fsproj | 15 +- src/MyWebLog/Program.fs | 2 +- src/MyWebLog/appsettings.json | 2 +- 14 files changed, 1066 insertions(+), 1030 deletions(-) delete mode 100644 src/MyWebLog/Handlers.fs create mode 100644 src/MyWebLog/Handlers/Admin.fs create mode 100644 src/MyWebLog/Handlers/Category.fs create mode 100644 src/MyWebLog/Handlers/Error.fs create mode 100644 src/MyWebLog/Handlers/Helpers.fs create mode 100644 src/MyWebLog/Handlers/Page.fs create mode 100644 src/MyWebLog/Handlers/Post.fs create mode 100644 src/MyWebLog/Handlers/Routes.fs create mode 100644 src/MyWebLog/Handlers/User.fs diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index 34ddfd4..588f479 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -10,12 +10,12 @@ - + - - + + diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index 0a0bea0..a3c3e9b 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -13,7 +13,7 @@ - + diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs deleted file mode 100644 index 0ab62dc..0000000 --- a/src/MyWebLog/Handlers.fs +++ /dev/null @@ -1,1019 +0,0 @@ -[] -module MyWebLog.Handlers - -open System -open System.Net -open System.Threading.Tasks -open System.Web -open DotLiquid -open Giraffe -open Microsoft.AspNetCore.Http -open MyWebLog -open MyWebLog.ViewModels -open RethinkDb.Driver.Net - -/// Handlers for error conditions -module Error = - - (* open Microsoft.Extensions.Logging *) - - (*/// Handle errors - let error (ex : Exception) (log : ILogger) = - log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.") - clearResponse - >=> setStatusCode 500 - >=> setHttpHeader "X-Toast" (sprintf "error|||%s: %s" (ex.GetType().Name) ex.Message) - >=> 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 None - - /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there - let notFound : HttpHandler = - setStatusCode 404 >=> text "Not found" - - -open System.Text.Json - -/// Session extensions to get and set objects -type ISession with - - /// Set an item in the session - member this.Set<'T> (key, item : 'T) = - this.SetString (key, JsonSerializer.Serialize item) - - /// Get an item from the session - member this.Get<'T> key = - match this.GetString key with - | null -> None - | item -> Some (JsonSerializer.Deserialize<'T> item) - - -open System.Collections.Generic - -[] -module private Helpers = - - open Microsoft.AspNetCore.Antiforgery - open Microsoft.Extensions.Configuration - open Microsoft.Extensions.DependencyInjection - open System.Security.Claims - open System.IO - - /// The HTTP item key for loading the session - let private sessionLoadedKey = "session-loaded" - - /// Load the session if it has not been loaded already; ensures async access but not excessive loading - let private loadSession (ctx : HttpContext) = task { - if not (ctx.Items.ContainsKey sessionLoadedKey) then - do! ctx.Session.LoadAsync () - ctx.Items.Add (sessionLoadedKey, "yes") - } - - /// Ensure that the session is committed - let private commitSession (ctx : HttpContext) = task { - if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync () - } - - /// Add a message to the user's session - let addMessage (ctx : HttpContext) message = task { - do! loadSession ctx - let msg = match ctx.Session.Get "messages" with Some it -> it | None -> [] - ctx.Session.Set ("messages", message :: msg) - } - - /// Get any messages from the user's session, removing them in the process - let messages (ctx : HttpContext) = task { - do! loadSession ctx - match ctx.Session.Get "messages" with - | Some msg -> - ctx.Session.Remove "messages" - return msg |> (List.rev >> Array.ofList) - | None -> return [||] - } - - /// Hold variable for the configured generator string - let mutable private generatorString : string option = None - - /// Get the generator string - let generator (ctx : HttpContext) = - if Option.isNone generatorString then - let cfg = ctx.RequestServices.GetRequiredService () - generatorString <- Option.ofObj cfg["Generator"] - match generatorString with Some gen -> gen | None -> "generator not configured" - - /// Either get the web log from the hash, or get it from the cache and add it to the hash - let private deriveWebLogFromHash (hash : Hash) ctx = - match hash.ContainsKey "web_log" with - | true -> hash["web_log"] :?> WebLog - | false -> - 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 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 - let! messages = messages ctx - 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) - hash.Add ("messages", messages) - hash.Add ("generator", generator ctx) - - do! commitSession ctx - - // 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 - - // Render view content... - let! contentTemplate = TemplateCache.get theme template - hash.Add ("content", contentTemplate.Render hash) - - // ...then render that content with its 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 next ctx = fun (hash : Hash) -> task { - return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash - } - - /// Redirect after doing some action; commits session and issues a temporary redirect - let redirectToGet url : HttpHandler = fun next ctx -> task { - do! commitSession ctx - return! redirectTo false url next ctx - } - - /// 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 () - - /// Get the Anti-CSRF service - let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () - - /// Get the cross-site request forgery token set - let csrfToken (ctx : HttpContext) = - (antiForgery ctx).GetAndStoreTokens ctx - - /// Validate the cross-site request forgery token in the current request - let validateCsrf : HttpHandler = fun next ctx -> task { - match! (antiForgery ctx).IsRequestValidAsync ctx with - | true -> return! next ctx - | false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx - } - - /// Require a user to be logged on - let requireUser = requiresAuthentication Error.notAuthorized - - /// Get the templates available for the current web log's theme (in a key/value pair list) - let templatesForTheme ctx (typ : string) = - seq { - KeyValuePair.Create ("", $"- Default (single-{typ}) -") - yield! - Path.Combine ("themes", (WebLogCache.get ctx).themePath) - |> Directory.EnumerateFiles - |> Seq.filter (fun it -> it.EndsWith $"{typ}.liquid") - |> Seq.map (fun it -> - let parts = it.Split Path.DirectorySeparatorChar - let template = parts[parts.Length - 1].Replace (".liquid", "") - KeyValuePair.Create (template, template)) - } - |> Array.ofSeq - - -/// Handlers to manipulate admin functions -module Admin = - - open System.IO - - /// The currently available themes - let private themes () = - Directory.EnumerateDirectories "themes" - |> Seq.map (fun it -> it.Split Path.DirectorySeparatorChar |> Array.last) - |> Seq.filter (fun it -> it <> "admin") - |> Seq.map (fun it -> KeyValuePair.Create (it, it)) - |> Array.ofSeq - - // GET /admin - let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let getCount (f : WebLogId -> IConnection -> Task) = f webLogId conn - let! posts = Data.Post.countByStatus Published |> getCount - let! drafts = Data.Post.countByStatus Draft |> getCount - let! pages = Data.Page.countAll |> getCount - let! listed = Data.Page.countListed |> getCount - let! cats = Data.Category.countAll |> getCount - let! topCats = Data.Category.countTopLevel |> getCount - return! - Hash.FromAnonymousObject - {| page_title = "Dashboard" - model = - { posts = posts - drafts = drafts - pages = pages - listedPages = listed - categories = cats - topLevelCategories = topCats - } - |} - |> viewForTheme "admin" "dashboard" next ctx - } - - // GET /admin/settings - let settings : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = WebLogCache.get ctx - let! allPages = Data.Page.findAll webLog.id (conn ctx) - return! - Hash.FromAnonymousObject - {| csrf = csrfToken ctx - model = SettingsModel.fromWebLog webLog - pages = - seq { - KeyValuePair.Create ("posts", "- First Page of Posts -") - yield! allPages - |> List.sortBy (fun p -> p.title.ToLower ()) - |> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title)) - } - |> Array.ofSeq - themes = themes () - web_log = webLog - page_title = "Web Log Settings" - |} - |> viewForTheme "admin" "settings" next ctx - } - - // POST /admin/settings - let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let conn = conn ctx - let! model = ctx.BindFormAsync () - match! Data.WebLog.findById (WebLogCache.get ctx).id conn with - | Some webLog -> - let updated = - { webLog with - name = model.name - subtitle = if model.subtitle = "" then None else Some model.subtitle - defaultPage = model.defaultPage - postsPerPage = model.postsPerPage - timeZone = model.timeZone - themePath = model.themePath - } - do! Data.WebLog.updateSettings updated conn - - // Update cache - WebLogCache.set ctx updated - - do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" } - return! redirectToGet "/admin" next ctx - | None -> return! Error.notFound next ctx - } - - -/// Handlers to manipulate categories -module Category = - - // GET /categories - let all : HttpHandler = requireUser >=> fun next ctx -> task { - return! - Hash.FromAnonymousObject {| - categories = CategoryCache.get ctx - page_title = "Categories" - csrf = csrfToken ctx - |} - |> viewForTheme "admin" "category-list" next ctx - } - - // GET /category/{id}/edit - let edit catId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let! result = task { - match catId with - | "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" }) - | _ -> - match! Data.Category.findById (CategoryId catId) webLogId conn with - | Some cat -> return Some ("Edit Category", cat) - | None -> return None - } - match result with - | Some (title, cat) -> - return! - Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = EditCategoryModel.fromCategory cat - page_title = title - categories = CategoryCache.get ctx - |} - |> viewForTheme "admin" "category-edit" next ctx - | None -> return! Error.notFound next ctx - } - - // POST /category/save - let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let webLogId = webLogId ctx - let conn = conn ctx - let! category = task { - match model.categoryId with - | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLogId } - | catId -> return! Data.Category.findById (CategoryId catId) webLogId conn - } - match category with - | Some cat -> - let cat = - { cat with - name = model.name - slug = model.slug - 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! CategoryCache.update ctx - do! addMessage ctx { UserMessage.success with message = "Category saved successfully" } - return! redirectToGet $"/category/{CategoryId.toString cat.id}/edit" next ctx - | None -> return! Error.notFound next ctx - } - - // POST /category/{id}/delete - let delete catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - match! Data.Category.delete (CategoryId catId) webLogId conn with - | true -> - do! CategoryCache.update ctx - do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" } - | false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" } - return! redirectToGet "/categories" 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.fromPageMinimal webLog) - page_title = "Pages" - |} - |> viewForTheme "admin" "page-list" next ctx - } - - // GET /page/{id}/edit - let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task { - let! result = task { - match pgId with - | "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" }) - | _ -> - match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with - | Some page -> return Some ("Edit Page", page) - | None -> return None - } - match result with - | Some (title, page) -> - let model = EditPageModel.fromPage page - return! - Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = model - metadata = Array.zip model.metaNames model.metaValues - |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) - page_title = title - templates = templatesForTheme ctx "page" - |} - |> viewForTheme "admin" "page-edit" next ctx - | None -> return! Error.notFound next ctx - } - - // POST /page/save - let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - 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; 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 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 - template = match model.template with "" -> None | tmpl -> Some tmpl - text = MarkupText.toHtml revision.text - metadata = Seq.zip model.metaNames model.metaValues - |> Seq.filter (fun it -> fst it > "") - |> Seq.map (fun it -> { name = fst it; value = snd it }) - |> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}") - |> List.ofSeq - revisions = match page.revisions |> List.tryHead with - | Some r when r.text = revision.text -> page.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 - do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } - return! redirectToGet $"/page/{PageId.toString page.id}/edit" next ctx - | None -> return! Error.notFound next ctx - } - - -/// Handlers to manipulate posts -module Post = - - open System.IO - open System.ServiceModel.Syndication - open System.Text.RegularExpressions - open System.Xml - - /// Split the "rest" capture for categories and tags into the page number and category/tag URL parts - let private pathAndPageNumber (ctx : HttpContext) = - let slugs = (string ctx.Request.RouteValues["slug"]).Split "/" |> Array.filter (fun it -> it <> "") - let pageIdx = Array.IndexOf (slugs, "page") - let pageNbr = if pageIdx > 0 then (int64 slugs[pageIdx + 1]) else 1L - let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs - pageNbr, String.Join ("/", slugParts) - - /// The type of post list being prepared - type ListType = - | AdminList - | CategoryList - | PostList - | SinglePost - | TagList - - /// Get all authors for a list of posts as metadata items - let private getAuthors (webLog : WebLog) (posts : Post list) conn = - posts - |> List.map (fun p -> p.authorId) - |> List.distinct - |> Data.WebLogUser.findNames webLog.id conn - - /// Convert a list of posts into items ready to be displayed - let private preparePostList webLog posts listType url pageNbr perPage ctx conn = task { - let! authors = getAuthors webLog posts conn - let postItems = - posts - |> Seq.ofList - |> Seq.truncate perPage - |> Seq.map (PostListItem.fromPost webLog) - |> Array.ofSeq - let! olderPost, newerPost = - match listType with - | SinglePost -> Data.Post.findSurroundingPosts webLog.id (List.head posts).publishedOn.Value conn - | _ -> Task.FromResult (None, None) - let newerLink = - match listType, pageNbr with - | SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink) - | _, 1L -> None - | PostList, 2L when webLog.defaultPage = "posts" -> Some "" - | PostList, _ -> Some $"page/{pageNbr - 1L}" - | CategoryList, 2L -> Some $"category/{url}/" - | CategoryList, _ -> Some $"category/{url}/page/{pageNbr - 1L}" - | TagList, 2L -> Some $"tag/{url}/" - | TagList, _ -> Some $"tag/{url}/page/{pageNbr - 1L}" - | AdminList, 2L -> Some "posts" - | AdminList, _ -> Some $"posts/page/{pageNbr - 1L}" - let olderLink = - match listType, List.length posts > perPage with - | SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink) - | _, false -> None - | PostList, true -> Some $"page/{pageNbr + 1L}" - | CategoryList, true -> Some $"category/{url}/page/{pageNbr + 1L}" - | TagList, true -> Some $"tag/{url}/page/{pageNbr + 1L}" - | AdminList, true -> Some $"posts/page/{pageNbr + 1L}" - let model = - { posts = postItems - authors = authors - subtitle = None - newerLink = newerLink - newerName = newerPost |> Option.map (fun p -> p.title) - olderLink = olderLink - olderName = olderPost |> Option.map (fun p -> p.title) - } - return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx |} - } - - // GET /page/{pageNbr} - let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn - let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx conn - let title = - match pageNbr, webLog.defaultPage with - | 1L, "posts" -> None - | _, "posts" -> Some $"Page {pageNbr}" - | _, _ -> Some $"Page {pageNbr} « Posts" - match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () - if pageNbr = 1L && webLog.defaultPage = "posts" then hash.Add ("is_home", true) - return! themedView "index" next ctx hash - } - - // GET /category/{slug}/ - // GET /category/{slug}/page/{pageNbr} - let pageOfCategorizedPosts : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - let pageNbr, slug = pathAndPageNumber ctx - let allCats = CategoryCache.get ctx - let cat = allCats |> Array.find (fun cat -> cat.slug = slug) - // Category pages include posts in subcategories - let catIds = - allCats - |> Seq.ofArray - |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) - |> Seq.map (fun c -> CategoryId c.id) - |> List.ofSeq - match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") - hash.Add ("subtitle", cat.description.Value) - hash.Add ("is_category", true) - return! themedView "index" next ctx hash - | _ -> return! Error.notFound next ctx - } - - // GET /tag/{tag}/ - // GET /tag/{tag}/page/{pageNbr} - let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - let pageNbr, rawTag = pathAndPageNumber ctx - let tag = HttpUtility.UrlDecode rawTag - match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") - hash.Add ("is_tag", true) - return! themedView "index" next ctx hash - // Other systems use hyphens for spaces; redirect if this is an old tag link - | _ -> - let spacedTag = tag.Replace ("-", " ") - match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with - | posts when List.length posts > 0 -> - let endUrl = if pageNbr = 1L then "" else $"page/{pageNbr}" - return! redirectTo true $"""/tag/{spacedTag.Replace (" ", "+")}/{endUrl}""" next ctx - | _ -> return! Error.notFound next ctx - } - - // GET / - let home : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - match webLog.defaultPage with - | "posts" -> return! pageOfPosts 1 next ctx - | pageId -> - match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with - | Some page -> - return! - Hash.FromAnonymousObject {| - page = DisplayPage.fromPage webLog page - page_title = page.title - is_home = true - |} - |> themedView (defaultArg page.template "single-page") next ctx - | None -> return! Error.notFound next ctx - } - - // GET /feed.xml - // (Routing handled by catch-all handler for future configurability) - let generateFeed : HttpHandler = fun next ctx -> backgroundTask { - let conn = conn ctx - let webLog = WebLogCache.get ctx - let urlBase = $"https://{webLog.urlBase}/" - // TODO: hard-coded number of items - let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1L 10 conn - let! authors = getAuthors webLog posts conn - let cats = CategoryCache.get ctx - - let toItem (post : Post) = - let plainText = - Regex.Replace (post.text, "<(.|\n)*?>", "") - |> function - | txt when txt.Length < 255 -> txt - | txt -> $"{txt.Substring (0, 252)}..." - let item = SyndicationItem ( - Id = $"{urlBase}{Permalink.toString post.permalink}", - Title = TextSyndicationContent.CreateHtmlContent post.title, - PublishDate = DateTimeOffset post.publishedOn.Value, - LastUpdatedTime = DateTimeOffset post.updatedOn, - Content = TextSyndicationContent.CreatePlaintextContent plainText) - item.AddPermalink (Uri item.Id) - - let encoded = post.text.Replace("src=\"/", $"src=\"{urlBase}").Replace ("href=\"/", $"href=\"{urlBase}") - item.ElementExtensions.Add ("encoded", "http://purl.org/rss/1.0/modules/content/", encoded) - item.Authors.Add (SyndicationPerson ( - Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value)) - [ post.categoryIds - |> List.map (fun catId -> - let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId) - SyndicationCategory (cat.name, $"{urlBase}category/{cat.slug}/", cat.name)) - post.tags - |> List.map (fun tag -> - let urlTag = tag.Replace (" ", "+") - SyndicationCategory (tag, $"{urlBase}tag/{urlTag}/", $"{tag} (tag)")) - ] - |> List.concat - |> List.iter item.Categories.Add - item - - - let feed = SyndicationFeed () - feed.Title <- TextSyndicationContent webLog.name - feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name - feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn - feed.Generator <- generator ctx - feed.Items <- posts |> Seq.ofList |> Seq.map toItem - feed.Language <- "en" - feed.Id <- urlBase - - feed.Links.Add (SyndicationLink (Uri $"{urlBase}feed.xml", "self", "", "application/rss+xml", 0L)) - feed.AttributeExtensions.Add (XmlQualifiedName ("content", "http://www.w3.org/2000/xmlns/"), "http://purl.org/rss/1.0/modules/content/") - feed.ElementExtensions.Add ("link", "", urlBase) - - use mem = new MemoryStream () - use xml = XmlWriter.Create mem - feed.SaveAsRss20 xml - xml.Close () - - let _ = mem.Seek (0L, SeekOrigin.Begin) - let rdr = new StreamReader(mem) - let! output = rdr.ReadToEndAsync () - - return! ( setHttpHeader "Content-Type" "text/xml" >=> setStatusCode 200 >=> setBodyFromString output) next ctx - } - - /// Sequence where the first returned value is the proper handler for the link - let private deriveAction ctx : HttpHandler seq = - let webLog = WebLogCache.get ctx - let conn = conn ctx - let permalink = (string >> Permalink) ctx.Request.RouteValues["link"] - let await it = (Async.AwaitTask >> Async.RunSynchronously) it - seq { - // Current post - match Data.Post.findByPermalink permalink webLog.id conn |> await with - | Some post -> - let model = preparePostList webLog [ post ] SinglePost "" 1 1 ctx conn |> await - model.Add ("page_title", post.title) - yield fun next ctx -> themedView "single-post" next ctx model - | None -> () - // Current page - match Data.Page.findByPermalink permalink webLog.id conn |> await with - | Some page -> - yield fun next ctx -> - Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} - |> themedView (defaultArg page.template "single-page") next ctx - | None -> () - // RSS feed - // TODO: configure this via web log - if Permalink.toString permalink = "feed.xml" then yield generateFeed - // Prior post - match Data.Post.findCurrentPermalink permalink webLog.id conn |> await with - | Some link -> yield redirectTo true $"/{Permalink.toString link}" - | None -> () - // Prior permalink - match Data.Page.findCurrentPermalink permalink webLog.id conn |> await with - | Some link -> yield redirectTo true $"/{Permalink.toString link}" - | None -> () - } - - // GET {**link} - let catchAll : HttpHandler = fun next ctx -> task { - match deriveAction ctx |> Seq.tryHead with - | Some handler -> return! handler next ctx - | None -> return! Error.notFound next ctx - } - - // 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! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx conn - hash.Add ("page_title", "Posts") - return! viewForTheme "admin" "post-list" next ctx hash - } - - // GET /post/{id}/edit - let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = WebLogCache.get 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) webLog.id conn with - | Some post -> return Some ("Edit Post", post) - | None -> return None - } - match result with - | Some (title, post) -> - let! cats = Data.Category.findAllForView webLog.id conn - return! - Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = EditPostModel.fromPost webLog post - page_title = title - categories = cats - |} - |> viewForTheme "admin" "post-edit" next ctx - | None -> return! Error.notFound next ctx - } - - // POST /post/save - let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - 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 post = - 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 - metadata = Seq.zip model.metaNames model.metaValues - |> Seq.filter (fun it -> fst it > "") - |> Seq.map (fun it -> { name = fst it; value = snd it }) - |> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}") - |> List.ofSeq - revisions = match post.revisions |> List.tryHead with - | Some r when r.text = revision.text -> post.revisions - | _ -> revision :: post.revisions - } - let post = - match model.setPublished with - | true -> - let dt = DateTime (model.pubOverride.Value.ToUniversalTime().Ticks, DateTimeKind.Utc) - printf $"**** DateKind = {dt.Kind}" - match model.setUpdated with - | true -> - { post with - publishedOn = Some dt - updatedOn = dt - revisions = [ { (List.head post.revisions) with asOf = dt } ] - } - | false -> { post with publishedOn = Some dt } - | false -> post - do! (match model.postId with "new" -> Data.Post.add | _ -> Data.Post.update) post conn - // If the post was published or its categories changed, refresh the category cache - if model.doPublish - || not (pst.Value.categoryIds - |> List.append post.categoryIds - |> List.distinct - |> List.length = List.length pst.Value.categoryIds) then - do! CategoryCache.update ctx - 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 - } - - -/// Handlers to manipulate users -module User = - - open Microsoft.AspNetCore.Authentication; - open Microsoft.AspNetCore.Authentication.Cookies - open System.Security.Claims - open System.Security.Cryptography - open System.Text - - /// 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) - - // GET /user/log-on - let logOn returnUrl : HttpHandler = fun next ctx -> task { - let returnTo = - match returnUrl with - | Some _ -> returnUrl - | None -> - match ctx.Request.Query.ContainsKey "returnUrl" with - | true -> Some ctx.Request.Query["returnUrl"].[0] - | false -> None - return! - Hash.FromAnonymousObject {| - model = { LogOnModel.empty with returnTo = returnTo } - page_title = "Log On" - csrf = csrfToken ctx - |} - |> viewForTheme "admin" "log-on" next ctx - } - - // POST /user/log-on - let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let webLog = WebLogCache.get ctx - match! Data.WebLogUser.findByEmail model.emailAddress webLog.id (conn ctx) with - | 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 ()) - } - let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme) - - do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity, - AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) - do! addMessage ctx - { UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" } - return! redirectToGet (match model.returnTo with Some url -> url | None -> "/admin") next ctx - | _ -> - do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" } - return! logOn model.returnTo next ctx - } - - // GET /user/log-off - let logOff : HttpHandler = fun next ctx -> task { - do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme - do! addMessage ctx { UserMessage.info with message = "Log off successful" } - return! redirectToGet "/" next ctx - } - - /// Display the user edit page, with information possibly filled in - let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { - hash.Add ("page_title", "Edit Your Information") - hash.Add ("csrf", csrfToken ctx) - return! viewForTheme "admin" "user-edit" next ctx hash - } - - // GET /user/edit - let edit : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.WebLogUser.findById (userId ctx) (conn ctx) with - | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx - | None -> return! Error.notFound next ctx - } - - // POST /user/save - let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - if model.newPassword = model.newPasswordConfirm then - let conn = conn ctx - match! Data.WebLogUser.findById (userId ctx) conn with - | Some user -> - let pw, salt = - if model.newPassword = "" then - user.passwordHash, user.salt - else - let newSalt = Guid.NewGuid () - hashedPassword model.newPassword user.userName newSalt, newSalt - let user = - { user with - firstName = model.firstName - lastName = model.lastName - preferredName = model.preferredName - passwordHash = pw - salt = salt - } - do! Data.WebLogUser.update user conn - let pwMsg = if model.newPassword = "" then "" else " and updated your password" - do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } - return! redirectToGet "/user/edit" next ctx - | None -> return! Error.notFound next ctx - else - do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } - return! showEdit (Hash.FromAnonymousObject {| - model = { model with newPassword = ""; newPasswordConfirm = "" } - |}) next ctx - } - -open Giraffe.EndpointRouting - -/// The endpoints defined in the above handlers -let endpoints = [ - GET [ - route "/" Post.home - ] - subRoute "/admin" [ - GET [ - route "" Admin.dashboard - route "/settings" Admin.settings - ] - POST [ - route "/settings" Admin.saveSettings - ] - ] - subRoute "/categor" [ - GET [ - route "ies" Category.all - routef "y/%s/edit" Category.edit - route "y/{**slug}" Post.pageOfCategorizedPosts - ] - POST [ - route "y/save" Category.save - routef "y/%s/delete" Category.delete - ] - ] - subRoute "/page" [ - GET [ - routef "/%d" Post.pageOfPosts - //routef "/%d/" (fun pg -> redirectTo true $"/page/{pg}") - routef "/%s/edit" Page.edit - route "s" (Page.all 1) - routef "s/page/%d" Page.all - ] - POST [ - route "/save" Page.save - ] - ] - subRoute "/post" [ - GET [ - routef "/%s/edit" Post.edit - route "s" (Post.all 1) - routef "s/page/%d" Post.all - ] - POST [ - route "/save" Post.save - ] - ] - subRoute "/tag" [ - GET [ - route "/{**slug}" Post.pageOfTaggedPosts - ] - ] - subRoute "/user" [ - GET [ - route "/edit" User.edit - route "/log-on" (User.logOn None) - route "/log-off" User.logOff - ] - POST [ - route "/log-on" User.doLogOn - route "/save" User.save - ] - ] - route "{**link}" Post.catchAll -] diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs new file mode 100644 index 0000000..5fbd7ce --- /dev/null +++ b/src/MyWebLog/Handlers/Admin.fs @@ -0,0 +1,95 @@ +/// Handlers to manipulate admin functions +module MyWebLog.Handlers.Admin + +open System.Collections.Generic +open System.IO + +/// The currently available themes +let private themes () = + Directory.EnumerateDirectories "themes" + |> Seq.map (fun it -> it.Split Path.DirectorySeparatorChar |> Array.last) + |> Seq.filter (fun it -> it <> "admin") + |> Seq.map (fun it -> KeyValuePair.Create (it, it)) + |> Array.ofSeq + +open System.Threading.Tasks +open DotLiquid +open Giraffe +open MyWebLog +open MyWebLog.ViewModels +open RethinkDb.Driver.Net + +// GET /admin +let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { + let webLogId = webLogId ctx + let conn = conn ctx + let getCount (f : WebLogId -> IConnection -> Task) = f webLogId conn + let! posts = Data.Post.countByStatus Published |> getCount + let! drafts = Data.Post.countByStatus Draft |> getCount + let! pages = Data.Page.countAll |> getCount + let! listed = Data.Page.countListed |> getCount + let! cats = Data.Category.countAll |> getCount + let! topCats = Data.Category.countTopLevel |> getCount + return! + Hash.FromAnonymousObject + {| page_title = "Dashboard" + model = + { posts = posts + drafts = drafts + pages = pages + listedPages = listed + categories = cats + topLevelCategories = topCats + } + |} + |> viewForTheme "admin" "dashboard" next ctx +} + +// GET /admin/settings +let settings : HttpHandler = requireUser >=> fun next ctx -> task { + let webLog = WebLogCache.get ctx + let! allPages = Data.Page.findAll webLog.id (conn ctx) + return! + Hash.FromAnonymousObject + {| csrf = csrfToken ctx + model = SettingsModel.fromWebLog webLog + pages = + seq { + KeyValuePair.Create ("posts", "- First Page of Posts -") + yield! allPages + |> List.sortBy (fun p -> p.title.ToLower ()) + |> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title)) + } + |> Array.ofSeq + themes = themes () + web_log = webLog + page_title = "Web Log Settings" + |} + |> viewForTheme "admin" "settings" next ctx +} + +// POST /admin/settings +let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let conn = conn ctx + let! model = ctx.BindFormAsync () + match! Data.WebLog.findById (WebLogCache.get ctx).id conn with + | Some webLog -> + let updated = + { webLog with + name = model.name + subtitle = if model.subtitle = "" then None else Some model.subtitle + defaultPage = model.defaultPage + postsPerPage = model.postsPerPage + timeZone = model.timeZone + themePath = model.themePath + } + do! Data.WebLog.updateSettings updated conn + + // Update cache + WebLogCache.set ctx updated + + do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" } + return! redirectToGet "/admin" next ctx + | None -> return! Error.notFound next ctx +} + diff --git a/src/MyWebLog/Handlers/Category.fs b/src/MyWebLog/Handlers/Category.fs new file mode 100644 index 0000000..d0d8e73 --- /dev/null +++ b/src/MyWebLog/Handlers/Category.fs @@ -0,0 +1,82 @@ +/// Handlers to manipulate categories +module MyWebLog.Handlers.Category + +open DotLiquid +open Giraffe +open MyWebLog + +// GET /categories +let all : HttpHandler = requireUser >=> fun next ctx -> task { + return! + Hash.FromAnonymousObject {| + categories = CategoryCache.get ctx + page_title = "Categories" + csrf = csrfToken ctx + |} + |> viewForTheme "admin" "category-list" next ctx +} + +open MyWebLog.ViewModels + +// GET /category/{id}/edit +let edit catId : HttpHandler = requireUser >=> fun next ctx -> task { + let webLogId = webLogId ctx + let conn = conn ctx + let! result = task { + match catId with + | "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" }) + | _ -> + match! Data.Category.findById (CategoryId catId) webLogId conn with + | Some cat -> return Some ("Edit Category", cat) + | None -> return None + } + match result with + | Some (title, cat) -> + return! + Hash.FromAnonymousObject {| + csrf = csrfToken ctx + model = EditCategoryModel.fromCategory cat + page_title = title + categories = CategoryCache.get ctx + |} + |> viewForTheme "admin" "category-edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /category/save +let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + let webLogId = webLogId ctx + let conn = conn ctx + let! category = task { + match model.categoryId with + | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLogId } + | catId -> return! Data.Category.findById (CategoryId catId) webLogId conn + } + match category with + | Some cat -> + let cat = + { cat with + name = model.name + slug = model.slug + 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! CategoryCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Category saved successfully" } + return! redirectToGet $"/category/{CategoryId.toString cat.id}/edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /category/{id}/delete +let delete catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let webLogId = webLogId ctx + let conn = conn ctx + match! Data.Category.delete (CategoryId catId) webLogId conn with + | true -> + do! CategoryCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" } + | false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" } + return! redirectToGet "/categories" next ctx +} diff --git a/src/MyWebLog/Handlers/Error.fs b/src/MyWebLog/Handlers/Error.fs new file mode 100644 index 0000000..794e6bd --- /dev/null +++ b/src/MyWebLog/Handlers/Error.fs @@ -0,0 +1,18 @@ +/// Handlers for error conditions +module MyWebLog.Handlers.Error + +open System.Net +open System.Threading.Tasks +open Microsoft.AspNetCore.Http +open Giraffe + +/// 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 None + +/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there +let notFound : HttpHandler = + setStatusCode 404 >=> text "Not found" diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs new file mode 100644 index 0000000..3fc78b8 --- /dev/null +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -0,0 +1,171 @@ +[] +module private MyWebLog.Handlers.Helpers + +open System.Text.Json +open Microsoft.AspNetCore.Http + +/// Session extensions to get and set objects +type ISession with + + /// Set an item in the session + member this.Set<'T> (key, item : 'T) = + this.SetString (key, JsonSerializer.Serialize item) + + /// Get an item from the session + member this.Get<'T> key = + match this.GetString key with + | null -> None + | item -> Some (JsonSerializer.Deserialize<'T> item) + + +/// The HTTP item key for loading the session +let private sessionLoadedKey = "session-loaded" + +/// Load the session if it has not been loaded already; ensures async access but not excessive loading +let private loadSession (ctx : HttpContext) = task { + if not (ctx.Items.ContainsKey sessionLoadedKey) then + do! ctx.Session.LoadAsync () + ctx.Items.Add (sessionLoadedKey, "yes") +} + +/// Ensure that the session is committed +let private commitSession (ctx : HttpContext) = task { + if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync () +} + +open MyWebLog.ViewModels + +/// Add a message to the user's session +let addMessage (ctx : HttpContext) message = task { + do! loadSession ctx + let msg = match ctx.Session.Get "messages" with Some it -> it | None -> [] + ctx.Session.Set ("messages", message :: msg) +} + +/// Get any messages from the user's session, removing them in the process +let messages (ctx : HttpContext) = task { + do! loadSession ctx + match ctx.Session.Get "messages" with + | Some msg -> + ctx.Session.Remove "messages" + return msg |> (List.rev >> Array.ofList) + | None -> return [||] +} + +/// Hold variable for the configured generator string +let mutable private generatorString : string option = None + +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.DependencyInjection + +/// Get the generator string +let generator (ctx : HttpContext) = + match generatorString with + | Some gen -> gen + | None -> + let cfg = ctx.RequestServices.GetRequiredService () + generatorString <- Option.ofObj cfg["Generator"] + defaultArg generatorString "generator not configured" + +open DotLiquid +open MyWebLog + +/// Either get the web log from the hash, or get it from the cache and add it to the hash +let private deriveWebLogFromHash (hash : Hash) ctx = + match hash.ContainsKey "web_log" with + | true -> hash["web_log"] :?> WebLog + | false -> + let wl = WebLogCache.get ctx + hash.Add ("web_log", wl) + wl + +open Giraffe + +/// Render a view for the specified theme, using the specified template, layout, and hash +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 + let! messages = messages ctx + 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) + hash.Add ("messages", messages) + hash.Add ("generator", generator ctx) + + do! commitSession ctx + + // NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render; + // the net effect is a "layout" capability similar to Razor or Pug + + // Render view content... + let! contentTemplate = TemplateCache.get theme template + hash.Add ("content", contentTemplate.Render hash) + + // ...then render that content with its 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 next ctx = fun (hash : Hash) -> task { + return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash +} + +/// Redirect after doing some action; commits session and issues a temporary redirect +let redirectToGet url : HttpHandler = fun next ctx -> task { + do! commitSession ctx + return! redirectTo false url next ctx +} + +/// Get the web log ID for the current request +let webLogId ctx = (WebLogCache.get ctx).id + +open System.Security.Claims + +/// 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 + +open RethinkDb.Driver.Net + +/// Get the RethinkDB connection +let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () + +open Microsoft.AspNetCore.Antiforgery + +/// Get the Anti-CSRF service +let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () + +/// Get the cross-site request forgery token set +let csrfToken (ctx : HttpContext) = + (antiForgery ctx).GetAndStoreTokens ctx + +/// Validate the cross-site request forgery token in the current request +let validateCsrf : HttpHandler = fun next ctx -> task { + match! (antiForgery ctx).IsRequestValidAsync ctx with + | true -> return! next ctx + | false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx +} + +/// Require a user to be logged on +let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized + +open System.Collections.Generic +open System.IO + +/// Get the templates available for the current web log's theme (in a key/value pair list) +let templatesForTheme ctx (typ : string) = + seq { + KeyValuePair.Create ("", $"- Default (single-{typ}) -") + yield! + Path.Combine ("themes", (WebLogCache.get ctx).themePath) + |> Directory.EnumerateFiles + |> Seq.filter (fun it -> it.EndsWith $"{typ}.liquid") + |> Seq.map (fun it -> + let parts = it.Split Path.DirectorySeparatorChar + let template = parts[parts.Length - 1].Replace (".liquid", "") + KeyValuePair.Create (template, template)) + } + |> Array.ofSeq + diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs new file mode 100644 index 0000000..c725a77 --- /dev/null +++ b/src/MyWebLog/Handlers/Page.fs @@ -0,0 +1,100 @@ +/// Handlers to manipulate pages +module MyWebLog.Handlers.Page + +open DotLiquid +open Giraffe +open MyWebLog +open MyWebLog.ViewModels + +// 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.fromPageMinimal webLog) + page_title = "Pages" + |} + |> viewForTheme "admin" "page-list" next ctx +} + +// GET /page/{id}/edit +let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task { + let! result = task { + match pgId with + | "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" }) + | _ -> + match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with + | Some page -> return Some ("Edit Page", page) + | None -> return None + } + match result with + | Some (title, page) -> + let model = EditPageModel.fromPage page + return! + Hash.FromAnonymousObject {| + csrf = csrfToken ctx + model = model + metadata = Array.zip model.metaNames model.metaValues + |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) + page_title = title + templates = templatesForTheme ctx "page" + |} + |> viewForTheme "admin" "page-edit" next ctx + | None -> return! Error.notFound next ctx +} + +open System + +// POST /page/save +let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + 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; 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 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 + template = match model.template with "" -> None | tmpl -> Some tmpl + text = MarkupText.toHtml revision.text + metadata = Seq.zip model.metaNames model.metaValues + |> Seq.filter (fun it -> fst it > "") + |> Seq.map (fun it -> { name = fst it; value = snd it }) + |> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}") + |> List.ofSeq + revisions = match page.revisions |> List.tryHead with + | Some r when r.text = revision.text -> page.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 + do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } + return! redirectToGet $"/page/{PageId.toString page.id}/edit" next ctx + | None -> return! Error.notFound next ctx +} diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs new file mode 100644 index 0000000..c67ef7c --- /dev/null +++ b/src/MyWebLog/Handlers/Post.fs @@ -0,0 +1,397 @@ +/// Handlers to manipulate posts +module MyWebLog.Handlers.Post + +open System +open Giraffe +open Microsoft.AspNetCore.Http + +/// Split the "rest" capture for categories and tags into the page number and category/tag URL parts +let private pathAndPageNumber (ctx : HttpContext) = + let slugs = (string ctx.Request.RouteValues["slug"]).Split "/" |> Array.filter (fun it -> it <> "") + let pageIdx = Array.IndexOf (slugs, "page") + let pageNbr = if pageIdx > 0 then (int64 slugs[pageIdx + 1]) else 1L + let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs + pageNbr, String.Join ("/", slugParts) + +/// The type of post list being prepared +type ListType = + | AdminList + | CategoryList + | PostList + | SinglePost + | TagList + +open MyWebLog + +/// Get all authors for a list of posts as metadata items +let private getAuthors (webLog : WebLog) (posts : Post list) conn = + posts + |> List.map (fun p -> p.authorId) + |> List.distinct + |> Data.WebLogUser.findNames webLog.id conn + +open System.Threading.Tasks +open DotLiquid +open MyWebLog.ViewModels + +/// Convert a list of posts into items ready to be displayed +let private preparePostList webLog posts listType url pageNbr perPage ctx conn = task { + let! authors = getAuthors webLog posts conn + let postItems = + posts + |> Seq.ofList + |> Seq.truncate perPage + |> Seq.map (PostListItem.fromPost webLog) + |> Array.ofSeq + let! olderPost, newerPost = + match listType with + | SinglePost -> + let post = List.head posts + let dateTime = defaultArg post.publishedOn post.updatedOn + Data.Post.findSurroundingPosts webLog.id dateTime conn + | _ -> Task.FromResult (None, None) + let newerLink = + match listType, pageNbr with + | SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink) + | _, 1L -> None + | PostList, 2L when webLog.defaultPage = "posts" -> Some "" + | PostList, _ -> Some $"page/{pageNbr - 1L}" + | CategoryList, 2L -> Some $"category/{url}/" + | CategoryList, _ -> Some $"category/{url}/page/{pageNbr - 1L}" + | TagList, 2L -> Some $"tag/{url}/" + | TagList, _ -> Some $"tag/{url}/page/{pageNbr - 1L}" + | AdminList, 2L -> Some "posts" + | AdminList, _ -> Some $"posts/page/{pageNbr - 1L}" + let olderLink = + match listType, List.length posts > perPage with + | SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink) + | _, false -> None + | PostList, true -> Some $"page/{pageNbr + 1L}" + | CategoryList, true -> Some $"category/{url}/page/{pageNbr + 1L}" + | TagList, true -> Some $"tag/{url}/page/{pageNbr + 1L}" + | AdminList, true -> Some $"posts/page/{pageNbr + 1L}" + let model = + { posts = postItems + authors = authors + subtitle = None + newerLink = newerLink + newerName = newerPost |> Option.map (fun p -> p.title) + olderLink = olderLink + olderName = olderPost |> Option.map (fun p -> p.title) + } + return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx |} +} + +// GET /page/{pageNbr} +let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.get ctx + let conn = conn ctx + let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn + let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx conn + let title = + match pageNbr, webLog.defaultPage with + | 1L, "posts" -> None + | _, "posts" -> Some $"Page {pageNbr}" + | _, _ -> Some $"Page {pageNbr} « Posts" + match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () + if pageNbr = 1L && webLog.defaultPage = "posts" then hash.Add ("is_home", true) + return! themedView "index" next ctx hash +} + +// GET /category/{slug}/ +// GET /category/{slug}/page/{pageNbr} +let pageOfCategorizedPosts : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.get ctx + let conn = conn ctx + let pageNbr, slug = pathAndPageNumber ctx + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + let catIds = + allCats + |> Seq.ofArray + |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) + |> Seq.map (fun c -> CategoryId c.id) + |> List.ofSeq + match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") + hash.Add ("subtitle", cat.description.Value) + hash.Add ("is_category", true) + return! themedView "index" next ctx hash + | _ -> return! Error.notFound next ctx +} + +open System.Web + +// GET /tag/{tag}/ +// GET /tag/{tag}/page/{pageNbr} +let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.get ctx + let conn = conn ctx + let pageNbr, rawTag = pathAndPageNumber ctx + let tag = HttpUtility.UrlDecode rawTag + match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") + hash.Add ("is_tag", true) + return! themedView "index" next ctx hash + // Other systems use hyphens for spaces; redirect if this is an old tag link + | _ -> + let spacedTag = tag.Replace ("-", " ") + match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + | posts when List.length posts > 0 -> + let endUrl = if pageNbr = 1L then "" else $"page/{pageNbr}" + return! redirectTo true $"""/tag/{spacedTag.Replace (" ", "+")}/{endUrl}""" next ctx + | _ -> return! Error.notFound next ctx +} + +// GET / +let home : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.get ctx + match webLog.defaultPage with + | "posts" -> return! pageOfPosts 1 next ctx + | pageId -> + match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with + | Some page -> + return! + Hash.FromAnonymousObject {| + page = DisplayPage.fromPage webLog page + page_title = page.title + is_home = true + |} + |> themedView (defaultArg page.template "single-page") next ctx + | None -> return! Error.notFound next ctx +} + +open System.IO +open System.ServiceModel.Syndication +open System.Text.RegularExpressions +open System.Xml + +// GET /feed.xml +// (Routing handled by catch-all handler for future configurability) +let generateFeed : HttpHandler = fun next ctx -> backgroundTask { + let conn = conn ctx + let webLog = WebLogCache.get ctx + let urlBase = $"https://{webLog.urlBase}/" + // TODO: hard-coded number of items + let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1L 10 conn + let! authors = getAuthors webLog posts conn + let cats = CategoryCache.get ctx + + let toItem (post : Post) = + let plainText = + Regex.Replace (post.text, "<(.|\n)*?>", "") + |> function + | txt when txt.Length < 255 -> txt + | txt -> $"{txt.Substring (0, 252)}..." + let item = SyndicationItem ( + Id = $"{urlBase}{Permalink.toString post.permalink}", + Title = TextSyndicationContent.CreateHtmlContent post.title, + PublishDate = DateTimeOffset post.publishedOn.Value, + LastUpdatedTime = DateTimeOffset post.updatedOn, + Content = TextSyndicationContent.CreatePlaintextContent plainText) + item.AddPermalink (Uri item.Id) + + let encoded = post.text.Replace("src=\"/", $"src=\"{urlBase}").Replace ("href=\"/", $"href=\"{urlBase}") + item.ElementExtensions.Add ("encoded", "http://purl.org/rss/1.0/modules/content/", encoded) + item.Authors.Add (SyndicationPerson ( + Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value)) + [ post.categoryIds + |> List.map (fun catId -> + let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId) + SyndicationCategory (cat.name, $"{urlBase}category/{cat.slug}/", cat.name)) + post.tags + |> List.map (fun tag -> + let urlTag = tag.Replace (" ", "+") + SyndicationCategory (tag, $"{urlBase}tag/{urlTag}/", $"{tag} (tag)")) + ] + |> List.concat + |> List.iter item.Categories.Add + item + + + let feed = SyndicationFeed () + feed.Title <- TextSyndicationContent webLog.name + feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name + feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn + feed.Generator <- generator ctx + feed.Items <- posts |> Seq.ofList |> Seq.map toItem + feed.Language <- "en" + feed.Id <- urlBase + + feed.Links.Add (SyndicationLink (Uri $"{urlBase}feed.xml", "self", "", "application/rss+xml", 0L)) + feed.AttributeExtensions.Add + (XmlQualifiedName ("content", "http://www.w3.org/2000/xmlns/"), "http://purl.org/rss/1.0/modules/content/") + feed.ElementExtensions.Add ("link", "", urlBase) + + use mem = new MemoryStream () + use xml = XmlWriter.Create mem + feed.SaveAsRss20 xml + xml.Close () + + let _ = mem.Seek (0L, SeekOrigin.Begin) + let rdr = new StreamReader(mem) + let! output = rdr.ReadToEndAsync () + + return! ( setHttpHeader "Content-Type" "text/xml" >=> setStatusCode 200 >=> setBodyFromString output) next ctx +} + +/// Sequence where the first returned value is the proper handler for the link +let private deriveAction ctx : HttpHandler seq = + let webLog = WebLogCache.get ctx + let conn = conn ctx + let permalink = (string >> Permalink) ctx.Request.RouteValues["link"] + let await it = (Async.AwaitTask >> Async.RunSynchronously) it + seq { + // Current post + match Data.Post.findByPermalink permalink webLog.id conn |> await with + | Some post -> + let model = preparePostList webLog [ post ] SinglePost "" 1 1 ctx conn |> await + model.Add ("page_title", post.title) + yield fun next ctx -> themedView "single-post" next ctx model + | None -> () + // Current page + match Data.Page.findByPermalink permalink webLog.id conn |> await with + | Some page -> + yield fun next ctx -> + Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} + |> themedView (defaultArg page.template "single-page") next ctx + | None -> () + // RSS feed + // TODO: configure this via web log + if Permalink.toString permalink = "feed.xml" then yield generateFeed + // Prior post + match Data.Post.findCurrentPermalink permalink webLog.id conn |> await with + | Some link -> yield redirectTo true $"/{Permalink.toString link}" + | None -> () + // Prior permalink + match Data.Page.findCurrentPermalink permalink webLog.id conn |> await with + | Some link -> yield redirectTo true $"/{Permalink.toString link}" + | None -> () + } + +// GET {**link} +let catchAll : HttpHandler = fun next ctx -> task { + match deriveAction ctx |> Seq.tryHead with + | Some handler -> return! handler next ctx + | None -> return! Error.notFound next ctx +} + +// 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! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx conn + hash.Add ("page_title", "Posts") + return! viewForTheme "admin" "post-list" next ctx hash +} + +// GET /post/{id}/edit +let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { + let webLog = WebLogCache.get 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) webLog.id conn with + | Some post -> return Some ("Edit Post", post) + | None -> return None + } + match result with + | Some (title, post) -> + let! cats = Data.Category.findAllForView webLog.id conn + return! + Hash.FromAnonymousObject {| + csrf = csrfToken ctx + model = EditPostModel.fromPost webLog post + page_title = title + categories = cats + |} + |> viewForTheme "admin" "post-edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /post/save +let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + 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 post = + 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 + metadata = Seq.zip model.metaNames model.metaValues + |> Seq.filter (fun it -> fst it > "") + |> Seq.map (fun it -> { name = fst it; value = snd it }) + |> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}") + |> List.ofSeq + revisions = match post.revisions |> List.tryHead with + | Some r when r.text = revision.text -> post.revisions + | _ -> revision :: post.revisions + } + let post = + match model.setPublished with + | true -> + let dt = DateTime (model.pubOverride.Value.ToUniversalTime().Ticks, DateTimeKind.Utc) + printf $"**** DateKind = {dt.Kind}" + match model.setUpdated with + | true -> + { post with + publishedOn = Some dt + updatedOn = dt + revisions = [ { (List.head post.revisions) with asOf = dt } ] + } + | false -> { post with publishedOn = Some dt } + | false -> post + do! (match model.postId with "new" -> Data.Post.add | _ -> Data.Post.update) post conn + // If the post was published or its categories changed, refresh the category cache + if model.doPublish + || not (pst.Value.categoryIds + |> List.append post.categoryIds + |> List.distinct + |> List.length = List.length pst.Value.categoryIds) then + do! CategoryCache.update ctx + 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/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs new file mode 100644 index 0000000..64f07c3 --- /dev/null +++ b/src/MyWebLog/Handlers/Routes.fs @@ -0,0 +1,70 @@ +/// Routes for this application +module MyWebLog.Handlers.Routes + +open Giraffe.EndpointRouting + +/// The endpoints defined in the above handlers +let endpoints = [ + GET [ + route "/" Post.home + ] + subRoute "/admin" [ + GET [ + route "" Admin.dashboard + route "/settings" Admin.settings + ] + POST [ + route "/settings" Admin.saveSettings + ] + ] + subRoute "/categor" [ + GET [ + route "ies" Category.all + routef "y/%s/edit" Category.edit + route "y/{**slug}" Post.pageOfCategorizedPosts + ] + POST [ + route "y/save" Category.save + routef "y/%s/delete" Category.delete + ] + ] + subRoute "/page" [ + GET [ + routef "/%d" Post.pageOfPosts + //routef "/%d/" (fun pg -> redirectTo true $"/page/{pg}") + routef "/%s/edit" Page.edit + route "s" (Page.all 1) + routef "s/page/%d" Page.all + ] + POST [ + route "/save" Page.save + ] + ] + subRoute "/post" [ + GET [ + routef "/%s/edit" Post.edit + route "s" (Post.all 1) + routef "s/page/%d" Post.all + ] + POST [ + route "/save" Post.save + ] + ] + subRoute "/tag" [ + GET [ + route "/{**slug}" Post.pageOfTaggedPosts + ] + ] + subRoute "/user" [ + GET [ + route "/edit" User.edit + route "/log-on" (User.logOn None) + route "/log-off" User.logOff + ] + POST [ + route "/log-on" User.doLogOn + route "/save" User.save + ] + ] + route "{**link}" Post.catchAll +] diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs new file mode 100644 index 0000000..253196b --- /dev/null +++ b/src/MyWebLog/Handlers/User.fs @@ -0,0 +1,117 @@ +/// Handlers to manipulate users +module MyWebLog.Handlers.User + +open System +open System.Security.Cryptography +open System.Text + +/// 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) + +open DotLiquid +open Giraffe +open MyWebLog.ViewModels + +// GET /user/log-on +let logOn returnUrl : HttpHandler = fun next ctx -> task { + let returnTo = + match returnUrl with + | Some _ -> returnUrl + | None -> + match ctx.Request.Query.ContainsKey "returnUrl" with + | true -> Some ctx.Request.Query["returnUrl"].[0] + | false -> None + return! + Hash.FromAnonymousObject {| + model = { LogOnModel.empty with returnTo = returnTo } + page_title = "Log On" + csrf = csrfToken ctx + |} + |> viewForTheme "admin" "log-on" next ctx +} + +open System.Security.Claims +open Microsoft.AspNetCore.Authentication +open Microsoft.AspNetCore.Authentication.Cookies +open MyWebLog + +// POST /user/log-on +let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + let webLog = WebLogCache.get ctx + match! Data.WebLogUser.findByEmail model.emailAddress webLog.id (conn ctx) with + | 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 ()) + } + let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme) + + do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity, + AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) + do! addMessage ctx + { UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" } + return! redirectToGet (match model.returnTo with Some url -> url | None -> "/admin") next ctx + | _ -> + do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" } + return! logOn model.returnTo next ctx +} + +// GET /user/log-off +let logOff : HttpHandler = fun next ctx -> task { + do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme + do! addMessage ctx { UserMessage.info with message = "Log off successful" } + return! redirectToGet "/" next ctx +} + +/// Display the user edit page, with information possibly filled in +let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { + hash.Add ("page_title", "Edit Your Information") + hash.Add ("csrf", csrfToken ctx) + return! viewForTheme "admin" "user-edit" next ctx hash +} + +// GET /user/edit +let edit : HttpHandler = requireUser >=> fun next ctx -> task { + match! Data.WebLogUser.findById (userId ctx) (conn ctx) with + | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx + | None -> return! Error.notFound next ctx +} + +// POST /user/save +let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + if model.newPassword = model.newPasswordConfirm then + let conn = conn ctx + match! Data.WebLogUser.findById (userId ctx) conn with + | Some user -> + let pw, salt = + if model.newPassword = "" then + user.passwordHash, user.salt + else + let newSalt = Guid.NewGuid () + hashedPassword model.newPassword user.userName newSalt, newSalt + let user = + { user with + firstName = model.firstName + lastName = model.lastName + preferredName = model.preferredName + passwordHash = pw + salt = salt + } + do! Data.WebLogUser.update user conn + let pwMsg = if model.newPassword = "" then "" else " and updated your password" + do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } + return! redirectToGet "/user/edit" next ctx + | None -> return! Error.notFound next ctx + else + do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } + return! showEdit (Hash.FromAnonymousObject {| + model = { model with newPassword = ""; newPasswordConfirm = "" } + |}) next ctx +} diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index d58ba53..2497096 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -9,15 +9,22 @@ - + + + + + + + + - + - + @@ -31,6 +38,4 @@ - - diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 904b5ea..656dbd7 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -276,7 +276,7 @@ let main args = let _ = app.UseStaticFiles () let _ = app.UseRouting () let _ = app.UseSession () - let _ = app.UseGiraffe Handlers.endpoints + let _ = app.UseGiraffe Handlers.Routes.endpoints app.Run() diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 2081e58..ec1c59d 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,5 +3,5 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha02" + "Generator": "myWebLog 2.0-alpha03" } -- 2.45.1 From 7b69fe9439cc99e5433eb468b59f64c0a46e4352 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 18 May 2022 20:42:12 -0400 Subject: [PATCH 038/102] Return 404 if page URLs have extra content at the end --- src/MyWebLog/Handlers/Post.fs | 88 +++++++++++++++++++---------------- src/MyWebLog/appsettings.json | 2 +- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index c67ef7c..eefb2e8 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -9,7 +9,11 @@ open Microsoft.AspNetCore.Http let private pathAndPageNumber (ctx : HttpContext) = let slugs = (string ctx.Request.RouteValues["slug"]).Split "/" |> Array.filter (fun it -> it <> "") let pageIdx = Array.IndexOf (slugs, "page") - let pageNbr = if pageIdx > 0 then (int64 slugs[pageIdx + 1]) else 1L + let pageNbr = + match pageIdx with + | -1 -> Some 1L + | idx when idx + 2 = slugs.Length -> Some (int64 slugs[pageIdx + 1]) + | _ -> None let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs pageNbr, String.Join ("/", slugParts) @@ -101,27 +105,29 @@ let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { // GET /category/{slug}/ // GET /category/{slug}/page/{pageNbr} let pageOfCategorizedPosts : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - let pageNbr, slug = pathAndPageNumber ctx - let allCats = CategoryCache.get ctx - let cat = allCats |> Array.find (fun cat -> cat.slug = slug) - // Category pages include posts in subcategories - let catIds = - allCats - |> Seq.ofArray - |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) - |> Seq.map (fun c -> CategoryId c.id) - |> List.ofSeq - match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") - hash.Add ("subtitle", cat.description.Value) - hash.Add ("is_category", true) - return! themedView "index" next ctx hash - | _ -> return! Error.notFound next ctx + let webLog = WebLogCache.get ctx + let conn = conn ctx + match pathAndPageNumber ctx with + | Some pageNbr, slug -> + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + let catIds = + allCats + |> Seq.ofArray + |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) + |> Seq.map (fun c -> CategoryId c.id) + |> List.ofSeq + match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") + hash.Add ("subtitle", cat.description.Value) + hash.Add ("is_category", true) + return! themedView "index" next ctx hash + | _ -> return! Error.notFound next ctx + | None, _ -> return! Error.notFound next ctx } open System.Web @@ -129,25 +135,27 @@ open System.Web // GET /tag/{tag}/ // GET /tag/{tag}/page/{pageNbr} let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - let pageNbr, rawTag = pathAndPageNumber ctx - let tag = HttpUtility.UrlDecode rawTag - match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") - hash.Add ("is_tag", true) - return! themedView "index" next ctx hash - // Other systems use hyphens for spaces; redirect if this is an old tag link - | _ -> - let spacedTag = tag.Replace ("-", " ") - match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + let webLog = WebLogCache.get ctx + let conn = conn ctx + match pathAndPageNumber ctx with + | Some pageNbr, rawTag -> + let tag = HttpUtility.UrlDecode rawTag + match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> - let endUrl = if pageNbr = 1L then "" else $"page/{pageNbr}" - return! redirectTo true $"""/tag/{spacedTag.Replace (" ", "+")}/{endUrl}""" next ctx - | _ -> return! Error.notFound next ctx + let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") + hash.Add ("is_tag", true) + return! themedView "index" next ctx hash + // Other systems use hyphens for spaces; redirect if this is an old tag link + | _ -> + let spacedTag = tag.Replace ("-", " ") + match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + | posts when List.length posts > 0 -> + let endUrl = if pageNbr = 1L then "" else $"page/{pageNbr}" + return! redirectTo true $"""/tag/{spacedTag.Replace (" ", "+")}/{endUrl}""" next ctx + | _ -> return! Error.notFound next ctx + | None, _ -> return! Error.notFound next ctx } // GET / diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index ec1c59d..70a2d94 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,5 +3,5 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha03" + "Generator": "myWebLog 2.0-alpha04" } -- 2.45.1 From a0f3d01e222db646bde4ff8f63df1a3c00996b4c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 19 May 2022 18:07:13 -0400 Subject: [PATCH 039/102] Eliminate task warnings - Bump generator version --- src/MyWebLog/Handlers/Page.fs | 4 +++- src/MyWebLog/Handlers/Post.fs | 4 +++- src/MyWebLog/appsettings.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index c725a77..0c34fef 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -47,6 +47,8 @@ let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task { open System +#nowarn "3511" + // POST /page/save let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () @@ -92,7 +94,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { | Some r when r.text = revision.text -> page.revisions | _ -> revision :: page.revisions } - do! (match model.pageId with "new" -> Data.Page.add | _ -> Data.Page.update) page conn + do! (if model.pageId = "new" then Data.Page.add else Data.Page.update) page conn if updateList then do! PageListCache.update ctx do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } return! redirectToGet $"/page/{PageId.toString page.id}/edit" next ctx diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index eefb2e8..4606098 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -328,6 +328,8 @@ let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { | None -> return! Error.notFound next ctx } +#nowarn "3511" + // POST /post/save let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () @@ -391,7 +393,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { } | false -> { post with publishedOn = Some dt } | false -> post - do! (match model.postId with "new" -> Data.Post.add | _ -> Data.Post.update) post conn + do! (if model.postId = "new" then Data.Post.add else Data.Post.update) post conn // If the post was published or its categories changed, refresh the category cache if model.doPublish || not (pst.Value.categoryIds diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 70a2d94..7003291 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,5 +3,5 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha04" + "Generator": "myWebLog 2.0-alpha05" } -- 2.45.1 From dc24da8ed71b8d18806eee2d3aa872d41cd498ba Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 19 May 2022 22:15:47 -0400 Subject: [PATCH 040/102] Tweak admin styles --- src/MyWebLog/themes/admin/layout.liquid | 6 ++--- src/MyWebLog/wwwroot/themes/admin/admin.css | 28 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index b779c99..f26122e 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -10,7 +10,7 @@
-
diff --git a/src/MyWebLog/themes/admin/permalinks.liquid b/src/MyWebLog/themes/admin/permalinks.liquid new file mode 100644 index 0000000..25bcc0a --- /dev/null +++ b/src/MyWebLog/themes/admin/permalinks.liquid @@ -0,0 +1,57 @@ +

{{ page_title }}

+
+
+ + +
+
+
+

+ {{ model.current_title }}
+ + {{ model.current_permalink }}
+ « Back to Edit {{ model.entity | capitalize }} +
+

+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index 7629d13..9a3a7e3 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -15,6 +15,9 @@ + {%- if model.page_id != "new" %} + Manage Permalinks + {% endif -%}
    diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.css b/src/MyWebLog/wwwroot/themes/admin/admin.css index e0b2276..354bd87 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.css +++ b/src/MyWebLog/wwwroot/themes/admin/admin.css @@ -9,6 +9,7 @@ body { scrollbar-color: var(--dark-gray) gray; padding-top: 3rem; padding-bottom: 2rem; + min-height: 100vh; } body::-webkit-scrollbar { width: 12px; diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index c8fac15..d992c7f 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -2,15 +2,25 @@ /** The next index for a metadata item */ nextMetaIndex : 0, + /** The next index for a permalink */ + nextPermalink : 0, + /** * Set the next meta item index * @param idx The index to set */ - // Calling a function with a Liquid variable does not look like an error in the IDE... setNextMetaIndex(idx) { this.nextMetaIndex = idx }, - + + /** + * Set the next permalink index + * @param idx The index to set + */ + setPermalinkIndex(idx) { + this.nextPermalink = idx + }, + /** * Add a new row for metadata entry */ @@ -80,7 +90,55 @@ document.getElementById(nameField.id).focus() this.nextMetaIndex++ }, - + + /** + * Add a new row for a permalink + */ + addPermalink() { + // Remove button + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.className = "btn btn-sm btn-danger" + removeBtn.innerHTML = "−" + removeBtn.setAttribute("onclick", `Admin.removePermalink(${this.nextPermalink})`) + + const removeCol = document.createElement("div") + removeCol.className = "col-1 text-center align-self-center" + removeCol.appendChild(removeBtn) + + // Link + const linkField = document.createElement("input") + linkField.type = "text" + linkField.name = "prior" + linkField.id = `prior_${this.nextPermalink}` + linkField.className = "form-control" + linkField.placeholder = "Link" + + const linkLabel = document.createElement("label") + linkLabel.htmlFor = linkField.id + linkLabel.innerText = linkField.placeholder + + const linkFloat = document.createElement("div") + linkFloat.className = "form-floating" + linkFloat.appendChild(linkField) + linkFloat.appendChild(linkLabel) + + const linkCol = document.createElement("div") + linkCol.className = "col-11" + linkCol.appendChild(linkFloat) + + // Put it all together + const newRow = document.createElement("div") + newRow.className = "row mb-3" + newRow.id = `meta_${this.nextPermalink}` + newRow.appendChild(removeCol) + newRow.appendChild(linkCol) + + document.getElementById("permalinks").appendChild(newRow) + document.getElementById(linkField.id).focus() + this.nextPermalink++ + }, + /** * Remove a metadata item * @param idx The index of the metadata item to remove @@ -88,7 +146,15 @@ removeMetaItem(idx) { document.getElementById(`meta_${idx}`).remove() }, - + + /** + * Remove a permalink + * @param idx The index of the permalink to remove + */ + removePermalink(idx) { + document.getElementById(`link_${idx}`).remove() + }, + /** * Confirm and delete a category * @param id The ID of the category to be deleted -- 2.45.1 From 0a21240984c4ba32ef67659c3129b69f60f33933 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 21 May 2022 00:07:16 -0400 Subject: [PATCH 042/102] Implement tag mapping - Move all admin functions to /admin URLs - Create Liquid filters for page/post edit, category/tag link - Update all themes to use these filters - Add delete for pages/posts - Move category/page functions to Admin module --- src/MyWebLog.Data/Converters.fs | 8 + src/MyWebLog.Data/Data.fs | 169 +++++++++-- src/MyWebLog.Domain/DataTypes.fs | 27 ++ src/MyWebLog.Domain/SupportTypes.fs | 16 ++ src/MyWebLog.Domain/ViewModels.fs | 24 ++ src/MyWebLog/Handlers/Admin.fs | 269 ++++++++++++++++++ src/MyWebLog/Handlers/Category.fs | 82 ------ src/MyWebLog/Handlers/Page.fs | 127 --------- src/MyWebLog/Handlers/Post.fs | 69 +++-- src/MyWebLog/Handlers/Routes.fs | 92 +++--- src/MyWebLog/Handlers/User.fs | 6 +- src/MyWebLog/MyWebLog.fsproj | 2 - src/MyWebLog/Program.fs | 47 ++- .../themes/admin/category-edit.liquid | 2 +- .../themes/admin/category-list.liquid | 8 +- src/MyWebLog/themes/admin/dashboard.liquid | 12 +- src/MyWebLog/themes/admin/layout.liquid | 9 +- src/MyWebLog/themes/admin/page-edit.liquid | 2 +- src/MyWebLog/themes/admin/page-list.liquid | 12 +- src/MyWebLog/themes/admin/permalinks.liquid | 4 +- src/MyWebLog/themes/admin/post-edit.liquid | 2 +- src/MyWebLog/themes/admin/post-list.liquid | 12 +- .../themes/admin/tag-mapping-edit.liquid | 35 +++ .../themes/admin/tag-mapping-list.liquid | 32 +++ src/MyWebLog/themes/admin/user-edit.liquid | 2 +- .../themes/bit-badger/home-page.liquid | 2 +- .../themes/bit-badger/single-page.liquid | 2 +- .../themes/bit-badger/solution-page.liquid | 2 +- .../themes/daniel-j-summers/index.liquid | 2 +- .../daniel-j-summers/single-post.liquid | 6 +- src/MyWebLog/themes/tech-blog/index.liquid | 8 +- src/MyWebLog/themes/tech-blog/layout.liquid | 2 +- .../themes/tech-blog/single-page.liquid | 2 +- .../themes/tech-blog/single-post.liquid | 13 +- src/MyWebLog/wwwroot/themes/admin/admin.js | 44 ++- 35 files changed, 796 insertions(+), 357 deletions(-) delete mode 100644 src/MyWebLog/Handlers/Category.fs delete mode 100644 src/MyWebLog/Handlers/Page.fs create mode 100644 src/MyWebLog/themes/admin/tag-mapping-edit.liquid create mode 100644 src/MyWebLog/themes/admin/tag-mapping-list.liquid diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index 4c38c45..e3a4e7c 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -48,6 +48,13 @@ type PostIdConverter () = override _.ReadJson (reader : JsonReader, _ : Type, _ : PostId, _ : bool, _ : JsonSerializer) = (string >> PostId) reader.Value +type TagMapIdConverter () = + inherit JsonConverter () + override _.WriteJson (writer : JsonWriter, value : TagMapId, _ : JsonSerializer) = + writer.WriteValue (TagMapId.toString value) + override _.ReadJson (reader : JsonReader, _ : Type, _ : TagMapId, _ : bool, _ : JsonSerializer) = + (string >> TagMapId) reader.Value + type WebLogIdConverter () = inherit JsonConverter () override _.WriteJson (writer : JsonWriter, value : WebLogId, _ : JsonSerializer) = @@ -74,6 +81,7 @@ let all () : JsonConverter seq = PermalinkConverter () PageIdConverter () PostIdConverter () + TagMapIdConverter () WebLogIdConverter () WebLogUserIdConverter () // Handles DUs with no associated data, as well as option fields diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 7282b81..db3be7a 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -17,6 +17,9 @@ module Table = /// The post table let Post = "Post" + /// The tag map table + let TagMap = "TagMap" + /// The web log table let WebLog = "WebLog" @@ -24,7 +27,7 @@ module Table = let WebLogUser = "WebLogUser" /// A list of all tables - let all = [ Category; Comment; Page; Post; WebLog; WebLogUser ] + let all = [ Category; Comment; Page; Post; TagMap; WebLog; WebLogUser ] /// Functions to assist with retrieving data @@ -50,6 +53,9 @@ module Helpers = let! results = f conn return results |> List.tryHead } + + /// Cast a strongly-typed list to an object list + let objList<'T> (objects : 'T list) = objects |> List.map (fun it -> it :> obj) open RethinkDb.Driver.FSharp @@ -71,7 +77,7 @@ module Startup = log.LogInformation $"Creating index {table}.permalink..." do! rethink { withTable table - indexCreate "permalink" (fun row -> r.Array (row.G "webLogId", row.G "permalink") :> obj) + indexCreate "permalink" (fun row -> r.Array (row["webLogId"], row["permalink"]) :> obj) write; withRetryOnce; ignoreResult conn } // Prior permalinks are searched when a post or page permalink do not match the current URL @@ -92,18 +98,34 @@ module Startup = indexCreate idx [ Multi ] write; withRetryOnce; ignoreResult conn } + // Tag mapping needs an index by web log ID and both tag and URL values + if Table.TagMap = table then + if not (indexes |> List.contains "webLogAndTag") then + log.LogInformation $"Creating index {table}.webLogAndTag..." + do! rethink { + withTable table + indexCreate "webLogAndTag" (fun row -> r.Array (row["webLogId"], row["tag"]) :> obj) + write; withRetryOnce; ignoreResult conn + } + if not (indexes |> List.contains "webLogAndUrl") then + log.LogInformation $"Creating index {table}.webLogAndUrl..." + do! rethink { + withTable table + indexCreate "webLogAndUrl" (fun row -> r.Array (row["webLogId"], row["urlValue"]) :> obj) + write; withRetryOnce; ignoreResult conn + } // Users log on with e-mail if Table.WebLogUser = table && not (indexes |> List.contains "logOn") then log.LogInformation $"Creating index {table}.logOn..." do! rethink { withTable table - indexCreate "logOn" (fun row -> r.Array (row.G "webLogId", row.G "userName") :> obj) + indexCreate "logOn" (fun row -> r.Array (row["webLogId"], row["userName"]) :> obj) write; withRetryOnce; ignoreResult conn } } /// Ensure all necessary tables and indexes exist - let ensureDb (config : DataConfig) (log : ILogger) conn = task { + let ensureDb (config : DataConfig) (log : ILogger) conn = backgroundTask { let! dbs = rethink { dbList; result; withRetryOnce conn } if not (dbs |> List.contains config.Database) then @@ -121,6 +143,7 @@ module Startup = do! makeIdx Table.Comment [ "postId" ] do! makeIdx Table.Page [ "webLogId"; "authorId" ] do! makeIdx Table.Post [ "webLogId"; "authorId" ] + do! makeIdx Table.TagMap [] do! makeIdx Table.WebLog [ "urlBase" ] do! makeIdx Table.WebLogUser [ "webLogId" ] } @@ -178,7 +201,7 @@ module Category = let! cats = rethink { withTable Table.Category getAll [ webLogId ] (nameof webLogId) - orderByFunc (fun it -> it.G("name").Downcase () :> obj) + orderByFunc (fun it -> it["name"].Downcase () :> obj) result; withRetryDefault conn } let ordered = orderByHierarchy cats None None [] @@ -232,8 +255,8 @@ module Category = do! rethink { withTable Table.Post getAll [ webLogId ] (nameof webLogId) - filter (fun row -> row.G("categoryIds").Contains catId :> obj) - update (fun row -> r.HashMap ("categoryIds", r.Array(row.G "categoryIds").Remove catId) :> obj) + filter (fun row -> row["categoryIds"].Contains catId :> obj) + update (fun row -> r.HashMap ("categoryIds", r.Array(row["categoryIds"]).Remove catId) :> obj) write; withRetryDefault; ignoreResult conn } // Delete the category itself @@ -251,7 +274,7 @@ module Category = let findNames (webLogId : WebLogId) conn (catIds : CategoryId list) = backgroundTask { let! cats = rethink { withTable Table.Category - getAll (catIds |> List.map (fun it -> it :> obj)) + getAll (objList catIds) filter "webLogId" webLogId result; withRetryDefault conn } @@ -275,6 +298,8 @@ module Category = /// Functions to manipulate pages module Page = + open RethinkDb.Driver.Model + /// Add a new page let add (page : Page) = rethink { @@ -302,6 +327,19 @@ module Page = result; withRetryDefault } + /// Delete a page + let delete (pageId : PageId) (webLogId : WebLogId) conn = backgroundTask { + let! result = + rethink { + withTable Table.Page + getAll [ pageId ] + filter (fun row -> row["webLogId"].Eq webLogId :> obj) + delete + write; withRetryDefault conn + } + return result.Deleted > 0UL + } + /// Retrieve all pages for a web log (excludes text, prior permalinks, and revisions) let findAll (webLogId : WebLogId) = rethink { @@ -342,10 +380,10 @@ module Page = |> tryFirst /// Find the current permalink for a page by a prior permalink - let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) = + let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) = rethink { withTable Table.Page - getAll [ permalink ] "priorPermalinks" + getAll (objList permalinks) "priorPermalinks" filter "webLogId" webLogId pluck [ "permalink" ] limit 1 @@ -370,7 +408,7 @@ module Page = withTable Table.Page getAll [ webLogId ] (nameof webLogId) without [ "priorPermalinks"; "revisions" ] - orderByFunc (fun row -> row.G("title").Downcase ()) + orderByFunc (fun row -> row["title"].Downcase ()) skip ((pageNbr - 1) * 25) limit 25 result; withRetryDefault @@ -396,7 +434,7 @@ module Page = } /// Update prior permalinks for a page - let updatePriorPermalinks pageId webLogId (permalinks : Permalink list) conn = task { + let updatePriorPermalinks pageId webLogId (permalinks : Permalink list) conn = backgroundTask { match! findById pageId webLogId conn with | Some _ -> do! rethink { @@ -414,6 +452,7 @@ module Page = module Post = open System + open RethinkDb.Driver.Model /// Add a post let add (post : Post) = @@ -433,11 +472,24 @@ module Post = result; withRetryDefault } + /// Delete a post + let delete (postId : PostId) (webLogId : WebLogId) conn = backgroundTask { + let! result = + rethink { + withTable Table.Post + getAll [ postId ] + filter (fun row -> row["webLogId"].Eq webLogId :> obj) + delete + write; withRetryDefault conn + } + return result.Deleted > 0UL + } + /// Find a post by its permalink let findByPermalink (permalink : Permalink) (webLogId : WebLogId) = rethink { withTable Table.Post - getAll [ r.Array(webLogId, permalink) ] (nameof permalink) + getAll [ r.Array (webLogId, permalink) ] (nameof permalink) without [ "priorPermalinks"; "revisions" ] limit 1 result; withRetryDefault @@ -454,10 +506,10 @@ module Post = |> verifyWebLog webLogId (fun p -> p.webLogId) /// Find the current permalink for a post by a prior permalink - let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) = + let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) = rethink { withTable Table.Post - getAll [ permalink ] "priorPermalinks" + getAll (objList permalinks) "priorPermalinks" filter "webLogId" webLogId pluck [ "permalink" ] limit 1 @@ -470,7 +522,7 @@ module Post = let pg = int pageNbr rethink { withTable Table.Post - getAll (catIds |> List.map (fun it -> it :> obj)) "categoryIds" + getAll (objList catIds) "categoryIds" filter "webLogId" webLogId filter "status" Published without [ "priorPermalinks"; "revisions" ] @@ -488,7 +540,7 @@ module Post = withTable Table.Post getAll [ webLogId ] (nameof webLogId) without [ "priorPermalinks"; "revisions" ] - orderByFuncDescending (fun row -> row.G("publishedOn").Default_ "updatedOn" :> obj) + orderByFuncDescending (fun row -> row["publishedOn"].Default_ "updatedOn" :> obj) skip ((pg - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault @@ -529,7 +581,7 @@ module Post = rethink { withTable Table.Post getAll [ webLogId ] (nameof webLogId) - filter (fun row -> row.G("publishedOn").Lt publishedOn :> obj) + filter (fun row -> row["publishedOn"].Lt publishedOn :> obj) orderByDescending "publishedOn" limit 1 result; withRetryDefault @@ -539,7 +591,7 @@ module Post = rethink { withTable Table.Post getAll [ webLogId ] (nameof webLogId) - filter (fun row -> row.G("publishedOn").Gt publishedOn :> obj) + filter (fun row -> row["publishedOn"].Gt publishedOn :> obj) orderBy "publishedOn" limit 1 result; withRetryDefault @@ -558,7 +610,7 @@ module Post = } /// Update prior permalinks for a post - let updatePriorPermalinks (postId : PostId) webLogId (permalinks : Permalink list) conn = task { + let updatePriorPermalinks (postId : PostId) webLogId (permalinks : Permalink list) conn = backgroundTask { match! ( rethink { withTable Table.Post @@ -579,16 +631,79 @@ module Post = } +/// Functions to manipulate tag mappings +module TagMap = + + open RethinkDb.Driver.Model + + /// Delete a tag mapping + let delete (tagMapId : TagMapId) (webLogId : WebLogId) conn = backgroundTask { + let! result = + rethink { + withTable Table.TagMap + getAll [ tagMapId ] + filter (fun row -> row["webLogId"].Eq webLogId :> obj) + delete + write; withRetryDefault conn + } + return result.Deleted > 0UL + } + + /// Find a tag map by its ID + let findById (tagMapId : TagMapId) webLogId = + rethink { + withTable Table.TagMap + get tagMapId + resultOption; withRetryOptionDefault + } + |> verifyWebLog webLogId (fun tm -> tm.webLogId) + + /// Find a tag mapping via URL value for a given web log + let findByUrlValue (urlValue : string) (webLogId : WebLogId) = + rethink { + withTable Table.TagMap + getAll [ r.Array (webLogId, urlValue) ] "webLogAndUrl" + limit 1 + result; withRetryDefault + } + |> tryFirst + + /// Find all tag mappings for a web log + let findByWebLogId (webLogId : WebLogId) = + rethink { + withTable Table.TagMap + between (r.Array (webLogId, r.Minval ())) (r.Array (webLogId, r.Maxval ())) [ Index "webLogAndTag" ] + orderBy "tag" + result; withRetryDefault + } + + /// Retrieve mappings for the specified tags + let findMappingForTags (tags : string list) (webLogId : WebLogId) = + rethink { + withTable Table.TagMap + getAll (tags |> List.map (fun tag -> r.Array (webLogId, tag) :> obj)) "webLogAndTag" + result; withRetryDefault + } + + /// Save a tag mapping + let save (tagMap : TagMap) = + rethink { + withTable Table.TagMap + get tagMap.id + replace tagMap + write; withRetryDefault; ignoreResult + } + + /// Functions to manipulate web logs module WebLog = /// Add a web log - let add (webLog : WebLog) = - rethink { - withTable Table.WebLog - insert webLog - write; withRetryOnce; ignoreResult - } + let add (webLog : WebLog) = rethink { + withTable Table.WebLog + insert webLog + write; withRetryOnce; ignoreResult + } /// Retrieve a web log by the URL base let findByHost (url : string) = @@ -651,7 +766,7 @@ module WebLogUser = let findNames (webLogId : WebLogId) conn (userIds : WebLogUserId list) = backgroundTask { let! users = rethink { withTable Table.WebLogUser - getAll (userIds |> List.map (fun it -> it :> obj)) + getAll (objList userIds) filter "webLogId" webLogId result; withRetryDefault conn } diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 2f0b6c1..4624b7a 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -219,6 +219,33 @@ module Post = } +/// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1") +type TagMap = + { /// The ID of this tag mapping + id : TagMapId + + /// The ID of the web log to which this tag mapping belongs + webLogId : WebLogId + + /// The tag which should be mapped to a different value in links + tag : string + + /// The value by which the tag should be linked + urlValue : string + } + +/// Functions to support tag mappings +module TagMap = + + /// An empty tag mapping + let empty = + { id = TagMapId.empty + webLogId = WebLogId.empty + tag = "" + urlValue = "" + } + + /// A web log [] type WebLog = diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 9f3e67e..cad55bd 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -187,6 +187,22 @@ module PostId = let create () = PostId (newId ()) +/// An identifier for a tag mapping +type TagMapId = TagMapId of string + +/// Functions to support tag mapping IDs +module TagMapId = + + /// An empty tag mapping ID + let empty = TagMapId "" + + /// Convert a tag mapping ID to a string + let toString = function TagMapId tmi -> tmi + + /// Create a new tag mapping ID + let create () = TagMapId (newId ()) + + /// An identifier for a web log type WebLogId = WebLogId of string diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index ccbc837..b923065 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -255,6 +255,30 @@ type EditPostModel = } +/// View model to edit a tag mapping +[] +type EditTagMapModel = + { /// The ID of the tag mapping being edited + id : string + + /// The tag being mapped to a different link value + tag : string + + /// The link value for the tag + urlValue : string + } + + /// Whether this is a new tag mapping + member this.isNew = this.id = "new" + + /// Create an edit model from the tag mapping + static member fromMapping (tagMap : TagMap) : EditTagMapModel = + { id = TagMapId.toString tagMap.id + tag = tagMap.tag + urlValue = tagMap.urlValue + } + + /// View model to edit a user [] type EditUserModel = diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 5fbd7ce..9afd336 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -45,6 +45,214 @@ let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { |> viewForTheme "admin" "dashboard" next ctx } +// -- CATEGORIES -- + +// GET /admin/categories +let listCategories : HttpHandler = requireUser >=> fun next ctx -> task { + return! + Hash.FromAnonymousObject {| + categories = CategoryCache.get ctx + page_title = "Categories" + csrf = csrfToken ctx + |} + |> viewForTheme "admin" "category-list" next ctx +} + +// GET /admin/category/{id}/edit +let editCategory catId : HttpHandler = requireUser >=> fun next ctx -> task { + let webLogId = webLogId ctx + let conn = conn ctx + let! result = task { + match catId with + | "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" }) + | _ -> + match! Data.Category.findById (CategoryId catId) webLogId conn with + | Some cat -> return Some ("Edit Category", cat) + | None -> return None + } + match result with + | Some (title, cat) -> + return! + Hash.FromAnonymousObject {| + csrf = csrfToken ctx + model = EditCategoryModel.fromCategory cat + page_title = title + categories = CategoryCache.get ctx + |} + |> viewForTheme "admin" "category-edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/category/save +let saveCategory : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + let webLogId = webLogId ctx + let conn = conn ctx + let! category = task { + match model.categoryId with + | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLogId } + | catId -> return! Data.Category.findById (CategoryId catId) webLogId conn + } + match category with + | Some cat -> + let cat = + { cat with + name = model.name + slug = model.slug + 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! CategoryCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Category saved successfully" } + return! redirectToGet $"/admin/category/{CategoryId.toString cat.id}/edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/category/{id}/delete +let deleteCategory catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let webLogId = webLogId ctx + let conn = conn ctx + match! Data.Category.delete (CategoryId catId) webLogId conn with + | true -> + do! CategoryCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" } + | false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" } + return! redirectToGet "/admin/categories" next ctx +} + +// -- PAGES -- + +// GET /admin/pages +// GET /admin/pages/page/{pageNbr} +let listPages 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.fromPageMinimal webLog) + page_title = "Pages" + |} + |> viewForTheme "admin" "page-list" next ctx +} + +// GET /admin/page/{id}/edit +let editPage pgId : HttpHandler = requireUser >=> fun next ctx -> task { + let! result = task { + match pgId with + | "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" }) + | _ -> + match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with + | Some page -> return Some ("Edit Page", page) + | None -> return None + } + match result with + | Some (title, page) -> + let model = EditPageModel.fromPage page + return! + Hash.FromAnonymousObject {| + csrf = csrfToken ctx + model = model + metadata = Array.zip model.metaNames model.metaValues + |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) + page_title = title + templates = templatesForTheme ctx "page" + |} + |> viewForTheme "admin" "page-edit" next ctx + | None -> return! Error.notFound next ctx +} + +// GET /admin/page/{id}/permalinks +let editPagePermalinks pgId : HttpHandler = requireUser >=> fun next ctx -> task { + match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with + | Some pg -> + return! + Hash.FromAnonymousObject {| + csrf = csrfToken ctx + model = ManagePermalinksModel.fromPage pg + page_title = $"Manage Prior Permalinks" + |} + |> viewForTheme "admin" "permalinks" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/page/permalinks +let savePagePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + let links = model.prior |> Array.map Permalink |> List.ofArray + match! Data.Page.updatePriorPermalinks (PageId model.id) (webLogId ctx) links (conn ctx) with + | true -> + do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" } + return! redirectToGet $"/admin/page/{model.id}/permalinks" next ctx + | false -> return! Error.notFound next ctx +} + +// POST /admin/page/{id}/delete +let deletePage pgId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + match! Data.Page.delete (PageId pgId) (webLogId ctx) (conn ctx) with + | true -> do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } + | false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } + return! redirectToGet "/admin/pages" next ctx +} + +open System + +#nowarn "3511" + +// POST /page/save +let savePage : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + 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; 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 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 + template = match model.template with "" -> None | tmpl -> Some tmpl + text = MarkupText.toHtml revision.text + metadata = Seq.zip model.metaNames model.metaValues + |> Seq.filter (fun it -> fst it > "") + |> Seq.map (fun it -> { name = fst it; value = snd it }) + |> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}") + |> List.ofSeq + revisions = match page.revisions |> List.tryHead with + | Some r when r.text = revision.text -> page.revisions + | _ -> revision :: page.revisions + } + do! (if model.pageId = "new" then Data.Page.add else Data.Page.update) page conn + if updateList then do! PageListCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } + return! redirectToGet $"/admin/page/{PageId.toString page.id}/edit" next ctx + | None -> return! Error.notFound next ctx +} + +// -- WEB LOG SETTINGS -- + // GET /admin/settings let settings : HttpHandler = requireUser >=> fun next ctx -> task { let webLog = WebLogCache.get ctx @@ -93,3 +301,64 @@ let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx - | None -> return! Error.notFound next ctx } +// -- TAG MAPPINGS -- + +// GET /admin/tag-mappings +let tagMappings : HttpHandler = requireUser >=> fun next ctx -> task { + let! mappings = Data.TagMap.findByWebLogId (webLogId ctx) (conn ctx) + return! + Hash.FromAnonymousObject + {| csrf = csrfToken ctx + mappings = mappings + mapping_ids = mappings |> List.map (fun it -> { name = it.tag; value = TagMapId.toString it.id }) + page_title = "Tag Mappings" + |} + |> viewForTheme "admin" "tag-mapping-list" next ctx +} + +// GET /admin/tag-mapping/{id}/edit +let editMapping tagMapId : HttpHandler = requireUser >=> fun next ctx -> task { + let webLogId = webLogId ctx + let isNew = tagMapId = "new" + let tagMap = + if isNew then + Task.FromResult (Some { TagMap.empty with id = TagMapId "new" }) + else + Data.TagMap.findById (TagMapId tagMapId) webLogId (conn ctx) + match! tagMap with + | Some tm -> + return! + Hash.FromAnonymousObject + {| csrf = csrfToken ctx + model = EditTagMapModel.fromMapping tm + page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.tag} Tag" + |} + |> viewForTheme "admin" "tag-mapping-edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/tag-mapping/save +let saveMapping : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let webLogId = webLogId ctx + let conn = conn ctx + let! model = ctx.BindFormAsync () + let tagMap = + if model.id = "new" then + Task.FromResult (Some { TagMap.empty with id = TagMapId.create (); webLogId = webLogId }) + else + Data.TagMap.findById (TagMapId model.id) webLogId conn + match! tagMap with + | Some tm -> + do! Data.TagMap.save { tm with tag = model.tag.ToLower (); urlValue = model.urlValue.ToLower () } conn + do! addMessage ctx { UserMessage.success with message = "Tag mapping saved successfully" } + return! redirectToGet $"/admin/tag-mapping/{TagMapId.toString tm.id}/edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/tag-mapping/{id}/delete +let deleteMapping tagMapId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + match! Data.TagMap.delete (TagMapId tagMapId) (webLogId ctx) (conn ctx) with + | true -> do! addMessage ctx { UserMessage.success with message = "Tag mapping deleted successfully" } + | false -> do! addMessage ctx { UserMessage.error with message = "Tag mapping not found; nothing deleted" } + return! redirectToGet "/admin/tag-mappings" next ctx +} diff --git a/src/MyWebLog/Handlers/Category.fs b/src/MyWebLog/Handlers/Category.fs deleted file mode 100644 index d0d8e73..0000000 --- a/src/MyWebLog/Handlers/Category.fs +++ /dev/null @@ -1,82 +0,0 @@ -/// Handlers to manipulate categories -module MyWebLog.Handlers.Category - -open DotLiquid -open Giraffe -open MyWebLog - -// GET /categories -let all : HttpHandler = requireUser >=> fun next ctx -> task { - return! - Hash.FromAnonymousObject {| - categories = CategoryCache.get ctx - page_title = "Categories" - csrf = csrfToken ctx - |} - |> viewForTheme "admin" "category-list" next ctx -} - -open MyWebLog.ViewModels - -// GET /category/{id}/edit -let edit catId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let! result = task { - match catId with - | "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" }) - | _ -> - match! Data.Category.findById (CategoryId catId) webLogId conn with - | Some cat -> return Some ("Edit Category", cat) - | None -> return None - } - match result with - | Some (title, cat) -> - return! - Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = EditCategoryModel.fromCategory cat - page_title = title - categories = CategoryCache.get ctx - |} - |> viewForTheme "admin" "category-edit" next ctx - | None -> return! Error.notFound next ctx -} - -// POST /category/save -let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let webLogId = webLogId ctx - let conn = conn ctx - let! category = task { - match model.categoryId with - | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLogId } - | catId -> return! Data.Category.findById (CategoryId catId) webLogId conn - } - match category with - | Some cat -> - let cat = - { cat with - name = model.name - slug = model.slug - 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! CategoryCache.update ctx - do! addMessage ctx { UserMessage.success with message = "Category saved successfully" } - return! redirectToGet $"/category/{CategoryId.toString cat.id}/edit" next ctx - | None -> return! Error.notFound next ctx -} - -// POST /category/{id}/delete -let delete catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - match! Data.Category.delete (CategoryId catId) webLogId conn with - | true -> - do! CategoryCache.update ctx - do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" } - | false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" } - return! redirectToGet "/categories" next ctx -} diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs deleted file mode 100644 index 1667768..0000000 --- a/src/MyWebLog/Handlers/Page.fs +++ /dev/null @@ -1,127 +0,0 @@ -/// Handlers to manipulate pages -module MyWebLog.Handlers.Page - -open DotLiquid -open Giraffe -open MyWebLog -open MyWebLog.ViewModels - -// 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.fromPageMinimal webLog) - page_title = "Pages" - |} - |> viewForTheme "admin" "page-list" next ctx -} - -// GET /page/{id}/edit -let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task { - let! result = task { - match pgId with - | "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" }) - | _ -> - match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with - | Some page -> return Some ("Edit Page", page) - | None -> return None - } - match result with - | Some (title, page) -> - let model = EditPageModel.fromPage page - return! - Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = model - metadata = Array.zip model.metaNames model.metaValues - |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) - page_title = title - templates = templatesForTheme ctx "page" - |} - |> viewForTheme "admin" "page-edit" next ctx - | None -> return! Error.notFound next ctx -} - -// GET /page/{id}/permalinks -let editPermalinks pgId : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with - | Some pg -> - return! - Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = ManagePermalinksModel.fromPage pg - page_title = $"Manage Prior Permalinks" - |} - |> viewForTheme "admin" "permalinks" next ctx - | None -> return! Error.notFound next ctx -} - -// POST /page/permalinks -let savePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let links = model.prior |> Array.map Permalink |> List.ofArray - match! Data.Page.updatePriorPermalinks (PageId model.id) (webLogId ctx) links (conn ctx) with - | true -> - do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" } - return! redirectToGet $"/page/{model.id}/permalinks" next ctx - | false -> return! Error.notFound next ctx -} - -open System - -#nowarn "3511" - -// POST /page/save -let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - 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; 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 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 - template = match model.template with "" -> None | tmpl -> Some tmpl - text = MarkupText.toHtml revision.text - metadata = Seq.zip model.metaNames model.metaValues - |> Seq.filter (fun it -> fst it > "") - |> Seq.map (fun it -> { name = fst it; value = snd it }) - |> Seq.sortBy (fun it -> $"{it.name.ToLower ()} {it.value.ToLower ()}") - |> List.ofSeq - revisions = match page.revisions |> List.tryHead with - | Some r when r.text = revision.text -> page.revisions - | _ -> revision :: page.revisions - } - do! (if model.pageId = "new" then Data.Page.add else Data.Page.update) page conn - if updateList then do! PageListCache.update ctx - do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } - return! redirectToGet $"/page/{PageId.toString page.id}/edit" next ctx - | None -> return! Error.notFound next ctx -} diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index c6912bd..fe039ff 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -34,13 +34,22 @@ let private getAuthors (webLog : WebLog) (posts : Post list) conn = |> List.distinct |> Data.WebLogUser.findNames webLog.id conn +/// Get all tag mappings for a list of posts as metadata items +let private getTagMappings (webLog : WebLog) (posts : Post list) = + posts + |> List.map (fun p -> p.tags) + |> List.concat + |> List.distinct + |> fun tags -> Data.TagMap.findMappingForTags tags webLog.id + open System.Threading.Tasks open DotLiquid open MyWebLog.ViewModels /// Convert a list of posts into items ready to be displayed let private preparePostList webLog posts listType url pageNbr perPage ctx conn = task { - let! authors = getAuthors webLog posts conn + let! authors = getAuthors webLog posts conn + let! tagMappings = getTagMappings webLog posts conn let postItems = posts |> Seq.ofList @@ -64,8 +73,8 @@ let private preparePostList webLog posts listType url pageNbr perPage ctx conn = | CategoryList, _ -> Some $"category/{url}/page/{pageNbr - 1L}" | TagList, 2L -> Some $"tag/{url}/" | TagList, _ -> Some $"tag/{url}/page/{pageNbr - 1L}" - | AdminList, 2L -> Some "posts" - | AdminList, _ -> Some $"posts/page/{pageNbr - 1L}" + | AdminList, 2L -> Some "admin/posts" + | AdminList, _ -> Some $"admin/posts/page/{pageNbr - 1L}" let olderLink = match listType, List.length posts > perPage with | SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink) @@ -73,7 +82,7 @@ let private preparePostList webLog posts listType url pageNbr perPage ctx conn = | PostList, true -> Some $"page/{pageNbr + 1L}" | CategoryList, true -> Some $"category/{url}/page/{pageNbr + 1L}" | TagList, true -> Some $"tag/{url}/page/{pageNbr + 1L}" - | AdminList, true -> Some $"posts/page/{pageNbr + 1L}" + | AdminList, true -> Some $"admin/posts/page/{pageNbr + 1L}" let model = { posts = postItems authors = authors @@ -83,7 +92,7 @@ let private preparePostList webLog posts listType url pageNbr perPage ctx conn = olderLink = olderLink olderName = olderPost |> Option.map (fun p -> p.title) } - return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx |} + return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx; tag_mappings = tagMappings |} } // GET /page/{pageNbr} @@ -139,7 +148,12 @@ let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { let conn = conn ctx match pathAndPageNumber ctx with | Some pageNbr, rawTag -> - let tag = HttpUtility.UrlDecode rawTag + let urlTag = HttpUtility.UrlDecode rawTag + let! tag = backgroundTask { + match! Data.TagMap.findByUrlValue urlTag webLog.id conn with + | Some m -> return m.tag + | None -> return urlTag + } match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn @@ -254,7 +268,8 @@ let generateFeed : HttpHandler = fun next ctx -> backgroundTask { let private deriveAction ctx : HttpHandler seq = let webLog = WebLogCache.get ctx let conn = conn ctx - let permalink = (string >> Permalink) ctx.Request.RouteValues["link"] + let textLink = string ctx.Request.RouteValues["link"] + let permalink = Permalink textLink let await it = (Async.AwaitTask >> Async.RunSynchronously) it seq { // Current post @@ -273,13 +288,22 @@ let private deriveAction ctx : HttpHandler seq = | None -> () // RSS feed // TODO: configure this via web log - if Permalink.toString permalink = "feed.xml" then yield generateFeed + if textLink = "feed.xml" then yield generateFeed + // Post differing only by trailing slash + let altLink = Permalink (if textLink.EndsWith "/" then textLink[..textLink.Length - 2] else $"{textLink}/") + match Data.Post.findByPermalink altLink webLog.id conn |> await with + | Some post -> yield redirectTo true $"/{Permalink.toString post.permalink}" + | None -> () + // Page differing only by trailing slash + match Data.Page.findByPermalink altLink webLog.id conn |> await with + | Some page -> yield redirectTo true $"/{Permalink.toString page.permalink}" + | None -> () // Prior post - match Data.Post.findCurrentPermalink permalink webLog.id conn |> await with + match Data.Post.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with | Some link -> yield redirectTo true $"/{Permalink.toString link}" | None -> () // Prior permalink - match Data.Page.findCurrentPermalink permalink webLog.id conn |> await with + match Data.Page.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with | Some link -> yield redirectTo true $"/{Permalink.toString link}" | None -> () } @@ -291,8 +315,8 @@ let catchAll : HttpHandler = fun next ctx -> task { | None -> return! Error.notFound next ctx } -// GET /posts -// GET /posts/page/{pageNbr} +// GET /admin/posts +// GET /admin/posts/page/{pageNbr} let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { let webLog = WebLogCache.get ctx let conn = conn ctx @@ -302,7 +326,7 @@ let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { return! viewForTheme "admin" "post-list" next ctx hash } -// GET /post/{id}/edit +// GET /admin/post/{id}/edit let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { let webLog = WebLogCache.get ctx let conn = conn ctx @@ -328,7 +352,7 @@ let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { | None -> return! Error.notFound next ctx } -// GET /post/{id}/permalinks +// GET /admin/post/{id}/permalinks let editPermalinks postId : HttpHandler = requireUser >=> fun next ctx -> task { match! Data.Post.findByFullId (PostId postId) (webLogId ctx) (conn ctx) with | Some post -> @@ -342,20 +366,28 @@ let editPermalinks postId : HttpHandler = requireUser >=> fun next ctx -> task { | None -> return! Error.notFound next ctx } -// POST /post/permalinks +// POST /admin/post/permalinks let savePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () let links = model.prior |> Array.map Permalink |> List.ofArray match! Data.Post.updatePriorPermalinks (PostId model.id) (webLogId ctx) links (conn ctx) with | true -> do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" } - return! redirectToGet $"/post/{model.id}/permalinks" next ctx + return! redirectToGet $"/admin/post/{model.id}/permalinks" next ctx | false -> return! Error.notFound next ctx } +// POST /admin/post/{id}/delete +let delete postId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + match! Data.Post.delete (PostId postId) (webLogId ctx) (conn ctx) with + | true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" } + | false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" } + return! redirectToGet "/admin/posts" next ctx +} + #nowarn "3511" -// POST /post/save +// POST /admin/post/save let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () let webLogId = webLogId ctx @@ -391,6 +423,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { tags = model.tags.Split "," |> Seq.ofArray |> Seq.map (fun it -> it.Trim().ToLower ()) + |> Seq.filter (fun it -> it <> "") |> Seq.sort |> List.ofSeq categoryIds = model.categoryIds |> Array.map CategoryId |> List.ofArray @@ -427,6 +460,6 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { |> List.length = List.length pst.Value.categoryIds) then do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with message = "Post saved successfully" } - return! redirectToGet $"/post/{PostId.toString post.id}/edit" next ctx + return! redirectToGet $"/admin/post/{PostId.toString post.id}/edit" next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 9c3a21f..42609bc 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -10,63 +10,65 @@ let endpoints = [ ] subRoute "/admin" [ GET [ - route "" Admin.dashboard - route "/settings" Admin.settings + route "" Admin.dashboard + subRoute "/categor" [ + route "ies" Admin.listCategories + routef "y/%s/edit" Admin.editCategory + ] + subRoute "/page" [ + route "s" (Admin.listPages 1) + routef "s/page/%d" Admin.listPages + routef "/%s/edit" Admin.editPage + routef "/%s/permalinks" Admin.editPagePermalinks + ] + subRoute "/post" [ + route "s" (Post.all 1) + routef "s/page/%d" Post.all + routef "/%s/edit" Post.edit + routef "/%s/permalinks" Post.editPermalinks + ] + route "/settings" Admin.settings + subRoute "/tag-mapping" [ + route "s" Admin.tagMappings + routef "/%s/edit" Admin.editMapping + ] + route "/user/edit" User.edit ] POST [ - route "/settings" Admin.saveSettings + subRoute "/category" [ + route "/save" Admin.saveCategory + routef "/%s/delete" Admin.deleteCategory + ] + subRoute "/page" [ + route "/save" Admin.savePage + route "/permalinks" Admin.savePagePermalinks + routef "/%s/delete" Admin.deletePage + ] + subRoute "/post" [ + route "/save" Post.save + route "/permalinks" Post.savePermalinks + routef "/%s/delete" Post.delete + ] + route "/settings" Admin.saveSettings + subRoute "/tag-mapping" [ + route "/save" Admin.saveMapping + routef "/%s/delete" Admin.deleteMapping + ] + route "/user/save" User.save ] ] - subRoute "/categor" [ - GET [ - route "ies" Category.all - routef "y/%s/edit" Category.edit - route "y/{**slug}" Post.pageOfCategorizedPosts - ] - POST [ - route "y/save" Category.save - routef "y/%s/delete" Category.delete - ] - ] - subRoute "/page" [ - GET [ - routef "/%d" Post.pageOfPosts - routef "/%s/edit" Page.edit - routef "/%s/permalinks" Page.editPermalinks - route "s" (Page.all 1) - routef "s/page/%d" Page.all - ] - POST [ - route "/permalinks" Page.savePermalinks - route "/save" Page.save - ] - ] - subRoute "/post" [ - GET [ - routef "/%s/edit" Post.edit - routef "/%s/permalinks" Post.editPermalinks - route "s" (Post.all 1) - routef "s/page/%d" Post.all - ] - POST [ - route "/permalinks" Post.savePermalinks - route "/save" Post.save - ] - ] - subRoute "/tag" [ - GET [ - route "/{**slug}" Post.pageOfTaggedPosts - ] + GET [ + route "/category/{**slug}" Post.pageOfCategorizedPosts + routef "/page/%d" Post.pageOfPosts + route "/tag/{**slug}" Post.pageOfTaggedPosts ] subRoute "/user" [ GET [ - route "/edit" User.edit route "/log-on" (User.logOn None) route "/log-off" User.logOff ] POST [ route "/log-on" User.doLogOn - route "/save" User.save ] ] route "{**link}" Post.catchAll diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index 253196b..cfa4c69 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -76,14 +76,14 @@ let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { return! viewForTheme "admin" "user-edit" next ctx hash } -// GET /user/edit +// GET /admin/user/edit let edit : HttpHandler = requireUser >=> fun next ctx -> task { match! Data.WebLogUser.findById (userId ctx) (conn ctx) with | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx | None -> return! Error.notFound next ctx } -// POST /user/save +// POST /admin/user/save let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () if model.newPassword = model.newPasswordConfirm then @@ -107,7 +107,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { do! Data.WebLogUser.update user conn let pwMsg = if model.newPassword = "" then "" else " and updated your password" do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } - return! redirectToGet "/user/edit" next ctx + return! redirectToGet "/admin/user/edit" next ctx | None -> return! Error.notFound next ctx else do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 2497096..e3991e8 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -12,8 +12,6 @@ - - diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 1a87eff..3fabce8 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -28,7 +28,26 @@ module DotLiquidBespoke = open System.IO open DotLiquid + open MyWebLog.ViewModels + /// A filter to generate a link with posts categorized under the given category + type CategoryLinkFilter () = + static member CategoryLink (_ : Context, catObj : obj) = + match catObj with + | :? DisplayCategory as cat -> $"/category/{cat.slug}/" + | :? DropProxy as proxy -> $"""/category/{proxy["slug"]}/""" + | _ -> $"alert('unknown category object type {catObj.GetType().Name}')" + + /// A filter to generate a link that will edit a page + type EditPageLinkFilter () = + static member EditPageLink (_ : Context, postId : string) = + $"/admin/page/{postId}/edit" + + /// A filter to generate a link that will edit a post + type EditPostLinkFilter () = + static member EditPostLink (_ : Context, postId : string) = + $"/admin/post/{postId}/edit" + /// A filter to generate nav links, highlighting the active link (exact match) type NavLinkFilter () = static member NavLink (ctx : Context, url : string, text : string) = @@ -43,6 +62,14 @@ module DotLiquidBespoke = } |> Seq.fold (+) "" + /// A filter to generate a link with posts tagged with the given tag + type TagLinkFilter () = + static member TagLink (ctx : Context, tag : string) = + match ctx.Environments[0].["tag_mappings"] :?> TagMap list + |> List.tryFind (fun it -> it.tag = tag) with + | Some tagMap -> $"/tag/{tagMap.urlValue}/" + | None -> $"""/tag/{tag.Replace (" ", "+")}/""" + /// Create links for a user to log on or off, and a dashboard link if they are logged off type UserLinksTag () = inherit Tag () @@ -246,20 +273,24 @@ let main args = let _ = builder.Services.AddGiraffe () // Set up DotLiquid - Template.RegisterFilter typeof - Template.RegisterFilter typeof + [ typeof; typeof + typeof; typeof + typeof; typeof + ] + |> List.iter Template.RegisterFilter + Template.RegisterTag "user_links" [ // Domain types - typeof; typeof; typeof + typeof; typeof; typeof; typeof // View models - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof // Framework types typeof; typeof; typeof; typeof - typeof + typeof; typeof ] |> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |])) diff --git a/src/MyWebLog/themes/admin/category-edit.liquid b/src/MyWebLog/themes/admin/category-edit.liquid index 5927344..720f919 100644 --- a/src/MyWebLog/themes/admin/category-edit.liquid +++ b/src/MyWebLog/themes/admin/category-edit.liquid @@ -1,6 +1,6 @@ 

{{ page_title }}

-
+
diff --git a/src/MyWebLog/themes/admin/category-list.liquid b/src/MyWebLog/themes/admin/category-list.liquid index 797a788..2cfe7ae 100644 --- a/src/MyWebLog/themes/admin/category-list.liquid +++ b/src/MyWebLog/themes/admin/category-list.liquid @@ -1,6 +1,6 @@ 

{{ page_title }}

- Add a New Category + Add a New Category @@ -18,14 +18,14 @@ {{ cat.name }}
{%- if cat.post_count > 0 %} - + View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} {%- endif %} - Edit + Edit - Delete diff --git a/src/MyWebLog/themes/admin/dashboard.liquid b/src/MyWebLog/themes/admin/dashboard.liquid index 18b8c72..452e4f3 100644 --- a/src/MyWebLog/themes/admin/dashboard.liquid +++ b/src/MyWebLog/themes/admin/dashboard.liquid @@ -9,8 +9,8 @@ Published {{ model.posts }}   Drafts {{ model.drafts }} - View All - Write a New Post + View All + Write a New Post @@ -22,8 +22,8 @@ All {{ model.pages }}   Shown in Page List {{ model.listed_pages }} - View All - Create a New Page + View All + Create a New Page @@ -37,8 +37,8 @@ All {{ model.categories }}   Top Level {{ model.top_level_categories }} - View All - Add a New Category + View All + Add a New Category diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index f26122e..1a8fceb 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -21,14 +21,15 @@ {% if logged_on -%} {%- endif %}
@@ -19,9 +19,12 @@ View Page - Edit + Edit - Delete + + Delete + @@ -30,4 +33,7 @@ {%- endfor %}
/{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}
+ + +
diff --git a/src/MyWebLog/themes/admin/permalinks.liquid b/src/MyWebLog/themes/admin/permalinks.liquid index 25bcc0a..7acd1dc 100644 --- a/src/MyWebLog/themes/admin/permalinks.liquid +++ b/src/MyWebLog/themes/admin/permalinks.liquid @@ -1,6 +1,6 @@ 

{{ page_title }}

-
+
@@ -10,7 +10,7 @@ {{ model.current_title }}
{{ model.current_permalink }}
- « Back to Edit {{ model.entity | capitalize }} + « Back to Edit {{ model.entity | capitalize }}

diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index 9a3a7e3..3f3e88b 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -1,6 +1,6 @@ 

{{ page_title }}

- +
diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index f8e8e1c..5f654ea 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -1,6 +1,6 @@

{{ page_title }}

- Write a New Post + Write a New Post @@ -25,9 +25,12 @@ View Post - Edit + Edit - Delete + + Delete + @@ -51,4 +54,7 @@ {% endif %} + + + diff --git a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid new file mode 100644 index 0000000..8c8f0a9 --- /dev/null +++ b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid @@ -0,0 +1,35 @@ +

{{ page_title }}

+ diff --git a/src/MyWebLog/themes/admin/tag-mapping-list.liquid b/src/MyWebLog/themes/admin/tag-mapping-list.liquid new file mode 100644 index 0000000..c430ae7 --- /dev/null +++ b/src/MyWebLog/themes/admin/tag-mapping-list.liquid @@ -0,0 +1,32 @@ +

{{ page_title }}

+
{{ model.authors | value: post.author_id }}
+ + + + + + + + {% for map in mappings -%} + {%- assign map_id = mapping_ids | value: map.tag -%} + + + + + {%- endfor %} + +
TagURL Value
+ {{ map.tag }}
+ + + Delete + + +
{{ map.url_value }}
+
+ +
+
diff --git a/src/MyWebLog/themes/admin/user-edit.liquid b/src/MyWebLog/themes/admin/user-edit.liquid index 1d1e018..5e13300 100644 --- a/src/MyWebLog/themes/admin/user-edit.liquid +++ b/src/MyWebLog/themes/admin/user-edit.liquid @@ -1,6 +1,6 @@

{{ page_title }}

-
+
diff --git a/src/MyWebLog/themes/bit-badger/home-page.liquid b/src/MyWebLog/themes/bit-badger/home-page.liquid index 164dbcf..94ac8cb 100644 --- a/src/MyWebLog/themes/bit-badger/home-page.liquid +++ b/src/MyWebLog/themes/bit-badger/home-page.liquid @@ -2,7 +2,7 @@
diff --git a/src/MyWebLog/themes/bit-badger/solution-page.liquid b/src/MyWebLog/themes/bit-badger/solution-page.liquid index a9b97d8..cc5cab3 100644 --- a/src/MyWebLog/themes/bit-badger/solution-page.liquid +++ b/src/MyWebLog/themes/bit-badger/solution-page.liquid @@ -94,7 +94,7 @@ {%- endif %}


« Back to All Solutions

{% if logged_on -%} -

Edit This Page

+

Edit This Page

{% endif %}
diff --git a/src/MyWebLog/themes/daniel-j-summers/index.liquid b/src/MyWebLog/themes/daniel-j-summers/index.liquid index 551439c..362806c 100644 --- a/src/MyWebLog/themes/daniel-j-summers/index.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/index.liquid @@ -24,7 +24,7 @@ {% if logged_on %} - Edit Post + Edit Post {% endif %} diff --git a/src/MyWebLog/themes/daniel-j-summers/single-post.liquid b/src/MyWebLog/themes/daniel-j-summers/single-post.liquid index f9d0b14..e9888ad 100644 --- a/src/MyWebLog/themes/daniel-j-summers/single-post.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/single-post.liquid @@ -15,7 +15,7 @@ {% endif %} {{ model.authors | value: post.author_id }} {% if logged_on %} - Edit Post + Edit Post {% endif %}
{{ post.text }}
@@ -27,7 +27,7 @@ {% assign cat = categories | where: "id", cat_id | first %} - + {{ cat.name }}     @@ -40,7 +40,7 @@ Tagged   {% for tag in post.tags %} - +     diff --git a/src/MyWebLog/themes/tech-blog/index.liquid b/src/MyWebLog/themes/tech-blog/index.liquid index f1b8c33..3ad46d2 100644 --- a/src/MyWebLog/themes/tech-blog/index.liquid +++ b/src/MyWebLog/themes/tech-blog/index.liquid @@ -23,7 +23,7 @@ {%- for cat_id in post.category_ids %} {%- assign cat = categories | where: "id", cat_id | first -%} - {%- endfor %}

@@ -34,12 +34,12 @@ Tagged {%- for tag in post.tags %} - + + {%- endfor %}
{%- endif %} - {%- if logged_on %}Edit Post{% endif %} + {%- if logged_on %}Edit Post{% endif %}
{%- endfor %} diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index d992c7f..e3c6822 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -163,7 +163,49 @@ deleteCategory(id, name) { if (confirm(`Are you sure you want to delete the category "${name}"? This action cannot be undone.`)) { const form = document.getElementById("deleteForm") - form.action = `/category/${id}/delete` + form.action = `/admin/category/${id}/delete` + form.submit() + } + return false + }, + + /** + * Confirm and delete a page + * @param id The ID of the page to be deleted + * @param title The title of the page to be deleted + */ + deletePage(id, title) { + if (confirm(`Are you sure you want to delete the page "${name}"? This action cannot be undone.`)) { + const form = document.getElementById("deleteForm") + form.action = `/admin/page/${id}/delete` + form.submit() + } + return false + }, + + /** + * Confirm and delete a post + * @param id The ID of the post to be deleted + * @param title The title of the post to be deleted + */ + deletePost(id, title) { + if (confirm(`Are you sure you want to delete the post "${name}"? This action cannot be undone.`)) { + const form = document.getElementById("deleteForm") + form.action = `/admin/post/${id}/delete` + form.submit() + } + return false + }, + + /** + * Confirm and delete a tag mapping + * @param id The ID of the mapping to be deleted + * @param tag The tag for which the mapping will be deleted + */ + deleteTagMapping(id, tag) { + if (confirm(`Are you sure you want to delete the mapping for "${tag}"? This action cannot be undone.`)) { + const form = document.getElementById("deleteForm") + form.action = `/admin/tag-mapping/${id}/delete` form.submit() } return false -- 2.45.1 From ac3a4fd3f405a19a403cbec977a1e7d96c9a0398 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 21 May 2022 11:00:03 -0400 Subject: [PATCH 043/102] Fix prior permalink lookup (a8) - Fix permalink edit links (a7) --- src/MyWebLog.Data/Data.fs | 48 ++++++++++++---------- src/MyWebLog/Handlers/Post.fs | 2 +- src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/admin/page-edit.liquid | 2 +- src/MyWebLog/themes/admin/post-edit.liquid | 2 +- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index db3be7a..4ca164f 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -380,17 +380,20 @@ module Page = |> tryFirst /// Find the current permalink for a page by a prior permalink - let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) = - rethink { - withTable Table.Page - getAll (objList permalinks) "priorPermalinks" - filter "webLogId" webLogId - pluck [ "permalink" ] - limit 1 - result; withRetryDefault - } - |> tryFirst - + let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) conn = backgroundTask { + let! result = + (rethink { + withTable Table.Page + getAll (objList permalinks) "priorPermalinks" + filter "webLogId" webLogId + without [ "revisions"; "text" ] + limit 1 + result; withRetryDefault + } + |> tryFirst) conn + return result |> Option.map (fun pg -> pg.permalink) + } + /// Find all pages in the page list for the given web log let findListed (webLogId : WebLogId) = rethink { @@ -506,16 +509,19 @@ module Post = |> verifyWebLog webLogId (fun p -> p.webLogId) /// Find the current permalink for a post by a prior permalink - let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) = - rethink { - withTable Table.Post - getAll (objList permalinks) "priorPermalinks" - filter "webLogId" webLogId - pluck [ "permalink" ] - limit 1 - result; withRetryDefault - } - |> tryFirst + let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) conn = backgroundTask { + let! result = + (rethink { + withTable Table.Post + getAll (objList permalinks) "priorPermalinks" + filter "webLogId" webLogId + without [ "revisions"; "text" ] + limit 1 + result; withRetryDefault + } + |> tryFirst) conn + return result |> Option.map (fun post -> post.permalink) + } /// Find posts to be displayed on a category list page let findPageOfCategorizedPosts (webLogId : WebLogId) (catIds : CategoryId list) (pageNbr : int64) postsPerPage = diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index fe039ff..afdf646 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -302,7 +302,7 @@ let private deriveAction ctx : HttpHandler seq = match Data.Post.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with | Some link -> yield redirectTo true $"/{Permalink.toString link}" | None -> () - // Prior permalink + // Prior page match Data.Page.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with | Some link -> yield redirectTo true $"/{Permalink.toString link}" | None -> () diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index df13854..4cb4a6e 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,5 +3,5 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha06" + "Generator": "myWebLog 2.0-alpha08" } diff --git a/src/MyWebLog/themes/admin/page-edit.liquid b/src/MyWebLog/themes/admin/page-edit.liquid index e208a05..46ddb4a 100644 --- a/src/MyWebLog/themes/admin/page-edit.liquid +++ b/src/MyWebLog/themes/admin/page-edit.liquid @@ -16,7 +16,7 @@ value="{{ model.permalink }}"> {%- if model.page_id != "new" %} - Manage Permalinks + Manage Permalinks {% endif -%}
diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index 3f3e88b..ae6bbac 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -16,7 +16,7 @@ value="{{ model.permalink }}"> {%- if model.page_id != "new" %} - Manage Permalinks + Manage Permalinks {% endif -%}
-- 2.45.1 From 63425ad8062df6dc96f4fbe343803e71ee9ddca4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 21 May 2022 11:36:39 -0400 Subject: [PATCH 044/102] Add htmx logo --- .../img/bit-badger/2021/11/htmx-black-transparent.svg | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/MyWebLog/wwwroot/img/bit-badger/2021/11/htmx-black-transparent.svg diff --git a/src/MyWebLog/wwwroot/img/bit-badger/2021/11/htmx-black-transparent.svg b/src/MyWebLog/wwwroot/img/bit-badger/2021/11/htmx-black-transparent.svg new file mode 100644 index 0000000..99db1a2 --- /dev/null +++ b/src/MyWebLog/wwwroot/img/bit-badger/2021/11/htmx-black-transparent.svg @@ -0,0 +1,9 @@ + + + + + + + + + -- 2.45.1 From cbf87f5b49783f78c9f6e6d9ca3480a9cc5c76d0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 21 May 2022 22:55:13 -0400 Subject: [PATCH 045/102] Support directory installations - Add web log to HttpContext on first retrieval per req - Add several DotLiquid filters - Use int for page numbers - Update all themes to use rel/abs link and other filters --- src/MyWebLog.Data/Data.fs | 21 +- src/MyWebLog.Domain/DataTypes.fs | 16 +- src/MyWebLog.Domain/ViewModels.fs | 5 +- src/MyWebLog/Caches.fs | 48 +++-- src/MyWebLog/DotLiquidBespoke.fs | 123 +++++++++++ src/MyWebLog/Handlers/Admin.fs | 118 ++++++----- src/MyWebLog/Handlers/Error.fs | 16 +- src/MyWebLog/Handlers/Helpers.fs | 20 +- src/MyWebLog/Handlers/Post.fs | 191 ++++++++++-------- src/MyWebLog/Handlers/Routes.fs | 154 +++++++------- src/MyWebLog/Handlers/User.fs | 8 +- src/MyWebLog/MyWebLog.fsproj | 1 + src/MyWebLog/Program.fs | 111 ++-------- .../themes/admin/category-edit.liquid | 2 +- .../themes/admin/category-list.liquid | 23 ++- src/MyWebLog/themes/admin/dashboard.liquid | 14 +- src/MyWebLog/themes/admin/layout.liquid | 2 +- src/MyWebLog/themes/admin/log-on.liquid | 2 +- src/MyWebLog/themes/admin/page-edit.liquid | 5 +- src/MyWebLog/themes/admin/page-list.liquid | 13 +- src/MyWebLog/themes/admin/permalinks.liquid | 6 +- src/MyWebLog/themes/admin/post-edit.liquid | 5 +- src/MyWebLog/themes/admin/post-list.liquid | 16 +- src/MyWebLog/themes/admin/settings.liquid | 2 +- .../themes/admin/tag-mapping-edit.liquid | 4 +- .../themes/admin/tag-mapping-list.liquid | 13 +- src/MyWebLog/themes/admin/user-edit.liquid | 2 +- .../themes/bit-badger/home-page.liquid | 49 ++++- src/MyWebLog/themes/bit-badger/layout.liquid | 11 +- .../themes/bit-badger/single-page.liquid | 4 +- .../themes/bit-badger/solution-page.liquid | 4 +- .../themes/daniel-j-summers/index.liquid | 11 +- .../themes/daniel-j-summers/layout.liquid | 14 +- .../daniel-j-summers/single-post.liquid | 12 +- src/MyWebLog/themes/default/index.liquid | 2 +- src/MyWebLog/themes/default/layout.liquid | 2 +- src/MyWebLog/themes/tech-blog/index.liquid | 9 +- src/MyWebLog/themes/tech-blog/layout.liquid | 20 +- .../themes/tech-blog/single-page.liquid | 2 +- .../themes/tech-blog/single-post.liquid | 8 +- src/MyWebLog/wwwroot/themes/admin/admin.js | 54 +++-- 41 files changed, 658 insertions(+), 485 deletions(-) create mode 100644 src/MyWebLog/DotLiquidBespoke.fs diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 4ca164f..daa94ad 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -540,35 +540,32 @@ module Post = } /// Find posts to be displayed on an admin page - let findPageOfPosts (webLogId : WebLogId) (pageNbr : int64) postsPerPage = - let pg = int pageNbr + let findPageOfPosts (webLogId : WebLogId) (pageNbr : int) postsPerPage = rethink { withTable Table.Post getAll [ webLogId ] (nameof webLogId) without [ "priorPermalinks"; "revisions" ] orderByFuncDescending (fun row -> row["publishedOn"].Default_ "updatedOn" :> obj) - skip ((pg - 1) * postsPerPage) + skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault } /// Find posts to be displayed on a page - let findPageOfPublishedPosts (webLogId : WebLogId) (pageNbr : int64) postsPerPage = - let pg = int pageNbr + let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage = rethink { withTable Table.Post getAll [ webLogId ] (nameof webLogId) filter "status" Published without [ "priorPermalinks"; "revisions" ] orderByDescending "publishedOn" - skip ((pg - 1) * postsPerPage) + skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault } /// Find posts to be displayed on a tag list page - let findPageOfTaggedPosts (webLogId : WebLogId) (tag : string) (pageNbr : int64) postsPerPage = - let pg = int pageNbr + let findPageOfTaggedPosts (webLogId : WebLogId) (tag : string) pageNbr postsPerPage = rethink { withTable Table.Post getAll [ tag ] "tags" @@ -576,7 +573,7 @@ module Post = filter "status" Published without [ "priorPermalinks"; "revisions" ] orderByDescending "publishedOn" - skip ((pg - 1) * postsPerPage) + skip ((pageNbr - 1) * postsPerPage) limit (postsPerPage + 1) result; withRetryDefault } @@ -711,6 +708,12 @@ module WebLog = write; withRetryOnce; ignoreResult } + /// Get all web logs + let all = rethink { + withTable Table.WebLog + result; withRetryDefault + } + /// Retrieve a web log by the URL base let findByHost (url : string) = rethink { diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 4624b7a..60c4b72 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -289,8 +289,20 @@ module WebLog = timeZone = "" } - /// Convert a permalink to an absolute URL - let absoluteUrl webLog = function Permalink link -> $"{webLog.urlBase}{link}" + /// Get the host (including scheme) and extra path from the URL base + let hostAndPath webLog = + let scheme = webLog.urlBase.Split "://" + let host = scheme[1].Split "/" + $"{scheme[0]}://{host[0]}", if host.Length > 1 then $"""/{String.Join ("/", host |> Array.skip 1)}""" else "" + + /// Generate an absolute URL for the given link + let absoluteUrl webLog permalink = + $"{webLog.urlBase}/{Permalink.toString permalink}" + + /// Generate a relative URL for the given link + let relativeUrl webLog permalink = + let _, leadPath = hostAndPath webLog + $"{leadPath}/{Permalink.toString permalink}" /// Convert a date/time to the web log's local date/time let localTime webLog (date : DateTime) = diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index b923065..022c46f 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -400,7 +400,8 @@ type PostListItem = /// Create a post list item from a post static member fromPost (webLog : WebLog) (post : Post) = - let inTZ = WebLog.localTime webLog + let _, extra = WebLog.hostAndPath webLog + let inTZ = WebLog.localTime webLog { id = PostId.toString post.id authorId = WebLogUserId.toString post.authorId status = PostStatus.toString post.status @@ -408,7 +409,7 @@ type PostListItem = permalink = Permalink.toString post.permalink publishedOn = post.publishedOn |> Option.map inTZ |> Option.toNullable updatedOn = inTZ post.updatedOn - text = post.text + text = if extra = "" then post.text else post.text.Replace ("href=\"/", $"href=\"{extra}/") categoryIds = post.categoryIds |> List.map CategoryId.toString tags = post.tags meta = post.metadata diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 23fd6dc..e85850b 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -6,10 +6,12 @@ open Microsoft.AspNetCore.Http module Cache = /// Create the cache key for the web log for the current request - let makeKey (ctx : HttpContext) = ctx.Request.Host.ToUriComponent () + let makeKey (ctx : HttpContext) = (ctx.Items["webLog"] :?> WebLog).urlBase open System.Collections.Concurrent +open Microsoft.Extensions.DependencyInjection +open RethinkDb.Driver.Net /// /// In-memory cache of web log details @@ -17,23 +19,35 @@ open System.Collections.Concurrent /// This is filled by the middleware via the first request for each host, and can be updated via the web log /// settings update page module WebLogCache = - + + /// Create the full path of the request + let private fullPath (ctx : HttpContext) = + $"{ctx.Request.Scheme}://{ctx.Request.Host.Value}{ctx.Request.Path.Value}" + /// The cache of web log details - let private _cache = ConcurrentDictionary () + let mutable private _cache : WebLog list = [] /// Does a host exist in the cache? - let exists ctx = _cache.ContainsKey (Cache.makeKey ctx) + let exists ctx = + let path = fullPath ctx + _cache |> List.exists (fun wl -> path.StartsWith wl.urlBase) /// Get the web log for the current request - let get ctx = _cache[Cache.makeKey ctx] + let get ctx = + let path = fullPath ctx + _cache |> List.find (fun wl -> path.StartsWith wl.urlBase) /// Cache the web log for a particular host - let set ctx webLog = _cache[Cache.makeKey ctx] <- webLog + let set webLog = + _cache <- webLog :: (_cache |> List.filter (fun wl -> wl.id <> webLog.id)) + + /// Fill the web log cache from the database + let fill conn = backgroundTask { + let! webLogs = Data.WebLog.all conn + _cache <- webLogs + } -open Microsoft.Extensions.DependencyInjection -open RethinkDb.Driver.Net - /// A cache of page information needed to display the page list in templates module PageListCache = @@ -42,12 +56,15 @@ module PageListCache = /// Cache of displayed pages let private _cache = ConcurrentDictionary () + /// Are there pages cached for this web log? + let exists ctx = _cache.ContainsKey (Cache.makeKey ctx) + /// 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 update (ctx : HttpContext) = backgroundTask { + let webLog = ctx.Items["webLog"] :?> WebLog let conn = ctx.RequestServices.GetRequiredService () let! pages = Data.Page.findListed webLog.id conn _cache[Cache.makeKey ctx] <- pages |> List.map (DisplayPage.fromPage webLog) |> Array.ofList @@ -62,12 +79,15 @@ module CategoryCache = /// The cache itself let private _cache = ConcurrentDictionary () + /// Are there categories cached for this web log? + let exists ctx = _cache.ContainsKey (Cache.makeKey ctx) + /// Get the categories for the web log for this request let get ctx = _cache[Cache.makeKey ctx] /// Update the cache with fresh data - let update ctx = backgroundTask { - let webLog = WebLogCache.get ctx + let update (ctx : HttpContext) = backgroundTask { + let webLog = ctx.Items["webLog"] :?> WebLog let conn = ctx.RequestServices.GetRequiredService () let! cats = Data.Category.findAllForView webLog.id conn _cache[Cache.makeKey ctx] <- cats @@ -84,7 +104,7 @@ module TemplateCache = let private _cache = ConcurrentDictionary () /// Get a template for the given theme and template nate - let get (theme : string) (templateName : string) = task { + let get (theme : string) (templateName : string) = backgroundTask { let templatePath = $"themes/{theme}/{templateName}" match _cache.ContainsKey templatePath with | true -> () diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs new file mode 100644 index 0000000..3b29deb --- /dev/null +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -0,0 +1,123 @@ +/// Custom DotLiquid filters and tags +module MyWebLog.DotLiquidBespoke + +open System +open System.IO +open DotLiquid +open MyWebLog.ViewModels + +/// Get the current web log from the DotLiquid context +let webLog (ctx : Context) = + ctx.Environments[0].["web_log"] :?> WebLog + +/// Obtain the link from known types +let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) = + match item with + | :? String as link -> Some link + | :? DisplayPage as page -> Some page.permalink + | :? PostListItem as post -> Some post.permalink + | :? DropProxy as proxy -> Option.ofObj proxy["permalink"] |> Option.map string + | _ -> None + |> function + | Some link -> linkFunc (webLog ctx) (Permalink link) + | None -> $"alert('unknown item type {item.GetType().Name}')" + +/// A filter to generate an absolute link +type AbsoluteLinkFilter () = + static member AbsoluteLink (ctx : Context, item : obj) = + permalink ctx item WebLog.absoluteUrl + +/// A filter to generate a link with posts categorized under the given category +type CategoryLinkFilter () = + static member CategoryLink (ctx : Context, catObj : obj) = + match catObj with + | :? DisplayCategory as cat -> Some cat.slug + | :? DropProxy as proxy -> Option.ofObj proxy["slug"] |> Option.map string + | _ -> None + |> function + | Some slug -> WebLog.relativeUrl (webLog ctx) (Permalink $"category/{slug}/") + | None -> $"alert('unknown category object type {catObj.GetType().Name}')" + + +/// A filter to generate a link that will edit a page +type EditPageLinkFilter () = + static member EditPageLink (ctx : Context, pageObj : obj) = + match pageObj with + | :? DisplayPage as page -> Some page.id + | :? DropProxy as proxy -> Option.ofObj proxy["id"] |> Option.map string + | :? String as theId -> Some theId + | _ -> None + |> function + | Some pageId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/page/{pageId}/edit") + | None -> $"alert('unknown page object type {pageObj.GetType().Name}')" + +/// A filter to generate a link that will edit a post +type EditPostLinkFilter () = + static member EditPostLink (ctx : Context, postObj : obj) = + match postObj with + | :? PostListItem as post -> Some post.id + | :? DropProxy as proxy -> Option.ofObj proxy["id"] |> Option.map string + | :? String as theId -> Some theId + | _ -> None + |> function + | Some postId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/post/{postId}/edit") + | None -> $"alert('unknown post object type {postObj.GetType().Name}')" + +/// A filter to generate nav links, highlighting the active link (exact match) +type NavLinkFilter () = + static member NavLink (ctx : Context, url : string, text : string) = + let webLog = webLog ctx + seq { + "
  • " + text + "
  • " + } + |> Seq.fold (+) "" + +/// A filter to generate a relative link +type RelativeLinkFilter () = + static member RelativeLink (ctx : Context, item : obj) = + permalink ctx item WebLog.relativeUrl + +/// A filter to generate a link with posts tagged with the given tag +type TagLinkFilter () = + static member TagLink (ctx : Context, tag : string) = + ctx.Environments[0].["tag_mappings"] :?> TagMap list + |> List.tryFind (fun it -> it.tag = tag) + |> function + | Some tagMap -> tagMap.urlValue + | None -> tag.Replace (" ", "+") + |> function tagUrl -> WebLog.relativeUrl (webLog ctx) (Permalink $"tag/{tagUrl}/") + +/// 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) = + let webLog = webLog context + let link it = WebLog.relativeUrl webLog (Permalink it) + seq { + """" + } + |> Seq.iter result.WriteLine + +/// A filter to retrieve the value of a meta item from a list +// (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`) +type ValueFilter () = + static member Value (_ : Context, items : MetaItem list, name : string) = + match items |> List.tryFind (fun it -> it.name = name) with + | Some item -> item.value + | None -> $"-- {name} not found --" + + diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 9afd336..4940fe1 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -1,6 +1,8 @@ /// Handlers to manipulate admin functions module MyWebLog.Handlers.Admin +// TODO: remove requireUser, as this is applied in the router + open System.Collections.Generic open System.IO @@ -21,9 +23,9 @@ open RethinkDb.Driver.Net // GET /admin let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let getCount (f : WebLogId -> IConnection -> Task) = f webLogId conn + let webLog = webLog ctx + let conn = conn ctx + let getCount (f : WebLogId -> IConnection -> Task) = f webLog.id conn let! posts = Data.Post.countByStatus Published |> getCount let! drafts = Data.Post.countByStatus Draft |> getCount let! pages = Data.Page.countAll |> getCount @@ -60,13 +62,13 @@ let listCategories : HttpHandler = requireUser >=> fun next ctx -> task { // GET /admin/category/{id}/edit let editCategory catId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let! result = task { + let webLog = webLog ctx + let conn = conn ctx + let! result = task { match catId with | "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" }) | _ -> - match! Data.Category.findById (CategoryId catId) webLogId conn with + match! Data.Category.findById (CategoryId catId) webLog.id conn with | Some cat -> return Some ("Edit Category", cat) | None -> return None } @@ -86,12 +88,12 @@ let editCategory catId : HttpHandler = requireUser >=> fun next ctx -> task { // POST /admin/category/save let saveCategory : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () - let webLogId = webLogId ctx - let conn = conn ctx + let webLog = webLog ctx + let conn = conn ctx let! category = task { match model.categoryId with - | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLogId } - | catId -> return! Data.Category.findById (CategoryId catId) webLogId conn + | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLog.id } + | catId -> return! Data.Category.findById (CategoryId catId) webLog.id conn } match category with | Some cat -> @@ -105,20 +107,22 @@ let saveCategory : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx - do! (match model.categoryId with "new" -> Data.Category.add | _ -> Data.Category.update) cat conn do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with message = "Category saved successfully" } - return! redirectToGet $"/admin/category/{CategoryId.toString cat.id}/edit" next ctx + return! + redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/category/{CategoryId.toString cat.id}/edit")) + next ctx | None -> return! Error.notFound next ctx } // POST /admin/category/{id}/delete let deleteCategory catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - match! Data.Category.delete (CategoryId catId) webLogId conn with + let webLog = webLog ctx + let conn = conn ctx + match! Data.Category.delete (CategoryId catId) webLog.id conn with | true -> do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Category not found; cannot delete" } - return! redirectToGet "/admin/categories" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/categories")) next ctx } // -- PAGES -- @@ -126,7 +130,7 @@ let deleteCategory catId : HttpHandler = requireUser >=> validateCsrf >=> fun ne // GET /admin/pages // GET /admin/pages/page/{pageNbr} let listPages pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = WebLogCache.get ctx + let webLog = webLog ctx let! pages = Data.Page.findPageOfPages webLog.id pageNbr (conn ctx) return! Hash.FromAnonymousObject @@ -142,7 +146,7 @@ let editPage pgId : HttpHandler = requireUser >=> fun next ctx -> task { match pgId with | "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" }) | _ -> - match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with + match! Data.Page.findByFullId (PageId pgId) (webLog ctx).id (conn ctx) with | Some page -> return Some ("Edit Page", page) | None -> return None } @@ -164,7 +168,7 @@ let editPage pgId : HttpHandler = requireUser >=> fun next ctx -> task { // GET /admin/page/{id}/permalinks let editPagePermalinks pgId : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with + match! Data.Page.findByFullId (PageId pgId) (webLog ctx).id (conn ctx) with | Some pg -> return! Hash.FromAnonymousObject {| @@ -178,44 +182,46 @@ let editPagePermalinks pgId : HttpHandler = requireUser >=> fun next ctx -> task // POST /admin/page/permalinks let savePagePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let links = model.prior |> Array.map Permalink |> List.ofArray - match! Data.Page.updatePriorPermalinks (PageId model.id) (webLogId ctx) links (conn ctx) with + let webLog = webLog ctx + let! model = ctx.BindFormAsync () + let links = model.prior |> Array.map Permalink |> List.ofArray + match! Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links (conn ctx) with | true -> do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" } - return! redirectToGet $"/admin/page/{model.id}/permalinks" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{model.id}/permalinks")) next ctx | false -> return! Error.notFound next ctx } // POST /admin/page/{id}/delete let deletePage pgId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - match! Data.Page.delete (PageId pgId) (webLogId ctx) (conn ctx) with + let webLog = webLog ctx + match! Data.Page.delete (PageId pgId) webLog.id (conn ctx) with | true -> do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } - return! redirectToGet "/admin/pages" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx } open System #nowarn "3511" -// POST /page/save +// POST /admin/page/save let savePage : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let webLogId = webLogId ctx - let conn = conn ctx - let now = DateTime.UtcNow - let! pg = task { + let! model = ctx.BindFormAsync () + let webLog = webLog 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 + webLogId = webLog.id authorId = userId ctx publishedOn = now } - | pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn + | pgId -> return! Data.Page.findByFullId (PageId pgId) webLog.id conn } match pg with | Some page -> @@ -247,7 +253,8 @@ let savePage : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> ta do! (if model.pageId = "new" then Data.Page.add else Data.Page.update) page conn if updateList then do! PageListCache.update ctx do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } - return! redirectToGet $"/admin/page/{PageId.toString page.id}/edit" next ctx + return! + redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{PageId.toString page.id}/edit")) next ctx | None -> return! Error.notFound next ctx } @@ -255,7 +262,7 @@ let savePage : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> ta // GET /admin/settings let settings : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = WebLogCache.get ctx + let webLog = webLog ctx let! allPages = Data.Page.findAll webLog.id (conn ctx) return! Hash.FromAnonymousObject @@ -278,9 +285,10 @@ let settings : HttpHandler = requireUser >=> fun next ctx -> task { // POST /admin/settings let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let conn = conn ctx - let! model = ctx.BindFormAsync () - match! Data.WebLog.findById (WebLogCache.get ctx).id conn with + let webLog = webLog ctx + let conn = conn ctx + let! model = ctx.BindFormAsync () + match! Data.WebLog.findById webLog.id conn with | Some webLog -> let updated = { webLog with @@ -294,10 +302,10 @@ let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx - do! Data.WebLog.updateSettings updated conn // Update cache - WebLogCache.set ctx updated + WebLogCache.set updated do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" } - return! redirectToGet "/admin" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin")) next ctx | None -> return! Error.notFound next ctx } @@ -305,7 +313,7 @@ let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx - // GET /admin/tag-mappings let tagMappings : HttpHandler = requireUser >=> fun next ctx -> task { - let! mappings = Data.TagMap.findByWebLogId (webLogId ctx) (conn ctx) + let! mappings = Data.TagMap.findByWebLogId (webLog ctx).id (conn ctx) return! Hash.FromAnonymousObject {| csrf = csrfToken ctx @@ -318,13 +326,12 @@ let tagMappings : HttpHandler = requireUser >=> fun next ctx -> task { // GET /admin/tag-mapping/{id}/edit let editMapping tagMapId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLogId = webLogId ctx - let isNew = tagMapId = "new" - let tagMap = + let isNew = tagMapId = "new" + let tagMap = if isNew then Task.FromResult (Some { TagMap.empty with id = TagMapId "new" }) else - Data.TagMap.findById (TagMapId tagMapId) webLogId (conn ctx) + Data.TagMap.findById (TagMapId tagMapId) (webLog ctx).id (conn ctx) match! tagMap with | Some tm -> return! @@ -339,26 +346,29 @@ let editMapping tagMapId : HttpHandler = requireUser >=> fun next ctx -> task { // POST /admin/tag-mapping/save let saveMapping : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLogId = webLogId ctx - let conn = conn ctx - let! model = ctx.BindFormAsync () - let tagMap = + let webLog = webLog ctx + let conn = conn ctx + let! model = ctx.BindFormAsync () + let tagMap = if model.id = "new" then - Task.FromResult (Some { TagMap.empty with id = TagMapId.create (); webLogId = webLogId }) + Task.FromResult (Some { TagMap.empty with id = TagMapId.create (); webLogId = webLog.id }) else - Data.TagMap.findById (TagMapId model.id) webLogId conn + Data.TagMap.findById (TagMapId model.id) webLog.id conn match! tagMap with | Some tm -> do! Data.TagMap.save { tm with tag = model.tag.ToLower (); urlValue = model.urlValue.ToLower () } conn do! addMessage ctx { UserMessage.success with message = "Tag mapping saved successfully" } - return! redirectToGet $"/admin/tag-mapping/{TagMapId.toString tm.id}/edit" next ctx + return! + redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/tag-mapping/{TagMapId.toString tm.id}/edit")) + next ctx | None -> return! Error.notFound next ctx } // POST /admin/tag-mapping/{id}/delete let deleteMapping tagMapId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - match! Data.TagMap.delete (TagMapId tagMapId) (webLogId ctx) (conn ctx) with + let webLog = webLog ctx + match! Data.TagMap.delete (TagMapId tagMapId) webLog.id (conn ctx) with | true -> do! addMessage ctx { UserMessage.success with message = "Tag mapping deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Tag mapping not found; nothing deleted" } - return! redirectToGet "/admin/tag-mappings" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/tag-mappings")) next ctx } diff --git a/src/MyWebLog/Handlers/Error.fs b/src/MyWebLog/Handlers/Error.fs index 794e6bd..dfb00ee 100644 --- a/src/MyWebLog/Handlers/Error.fs +++ b/src/MyWebLog/Handlers/Error.fs @@ -3,15 +3,19 @@ module MyWebLog.Handlers.Error open System.Net open System.Threading.Tasks -open Microsoft.AspNetCore.Http open Giraffe +open Microsoft.AspNetCore.Http +open MyWebLog /// 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 None +let notAuthorized : HttpHandler = fun next ctx -> task { + let webLog = ctx.Items["webLog"] :?> WebLog + if ctx.Request.Method = "GET" then + let returnUrl = WebUtility.UrlEncode ctx.Request.Path + return! redirectTo false (WebLog.relativeUrl webLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx + else + return! (setStatusCode 401 >=> fun _ _ -> Task.FromResult None) next ctx +} /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there let notFound : HttpHandler = diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 3fc78b8..eb9644e 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -67,17 +67,18 @@ let generator (ctx : HttpContext) = generatorString <- Option.ofObj cfg["Generator"] defaultArg generatorString "generator not configured" -open DotLiquid open MyWebLog +/// Get the web log for the request from the context (established by middleware) +let webLog (ctx : HttpContext) = + ctx.Items["webLog"] :?> WebLog + +open DotLiquid + /// Either get the web log from the hash, or get it from the cache and add it to the hash let private deriveWebLogFromHash (hash : Hash) ctx = - match hash.ContainsKey "web_log" with - | true -> hash["web_log"] :?> WebLog - | false -> - let wl = WebLogCache.get ctx - hash.Add ("web_log", wl) - wl + if hash.ContainsKey "web_log" then () else hash.Add ("web_log", webLog ctx) + hash["web_log"] :?> WebLog open Giraffe @@ -118,9 +119,6 @@ let redirectToGet url : HttpHandler = fun next ctx -> task { return! redirectTo false url next ctx } -/// Get the web log ID for the current request -let webLogId ctx = (WebLogCache.get ctx).id - open System.Security.Claims /// Get the user ID for the current request @@ -159,7 +157,7 @@ let templatesForTheme ctx (typ : string) = seq { KeyValuePair.Create ("", $"- Default (single-{typ}) -") yield! - Path.Combine ("themes", (WebLogCache.get ctx).themePath) + Path.Combine ("themes", (webLog ctx).themePath) |> Directory.EnumerateFiles |> Seq.filter (fun it -> it.EndsWith $"{typ}.liquid") |> Seq.map (fun it -> diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index afdf646..2978b74 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -2,21 +2,19 @@ module MyWebLog.Handlers.Post open System -open Giraffe -open Microsoft.AspNetCore.Http -/// Split the "rest" capture for categories and tags into the page number and category/tag URL parts -let private pathAndPageNumber (ctx : HttpContext) = - let slugs = (string ctx.Request.RouteValues["slug"]).Split "/" |> Array.filter (fun it -> it <> "") - let pageIdx = Array.IndexOf (slugs, "page") - let pageNbr = +/// Parse a slug and page number from an "everything else" URL +let private parseSlugAndPage (slugAndPage : string seq) = + let slugs = (slugAndPage |> Seq.skip 1 |> Seq.head).Split "/" |> Array.filter (fun it -> it <> "") + let pageIdx = Array.IndexOf (slugs, "page") + let pageNbr = match pageIdx with - | -1 -> Some 1L - | idx when idx + 2 = slugs.Length -> Some (int64 slugs[pageIdx + 1]) + | -1 -> Some 1 + | idx when idx + 2 = slugs.Length -> Some (int slugs[pageIdx + 1]) | _ -> None let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs pageNbr, String.Join ("/", slugParts) - + /// The type of post list being prepared type ListType = | AdminList @@ -47,10 +45,11 @@ open DotLiquid open MyWebLog.ViewModels /// Convert a list of posts into items ready to be displayed -let private preparePostList webLog posts listType url pageNbr perPage ctx conn = task { +let private preparePostList webLog posts listType (url : string) pageNbr perPage ctx conn = task { let! authors = getAuthors webLog posts conn let! tagMappings = getTagMappings webLog posts conn - let postItems = + let relUrl it = Some <| WebLog.relativeUrl webLog (Permalink it) + let postItems = posts |> Seq.ofList |> Seq.truncate perPage @@ -65,24 +64,24 @@ let private preparePostList webLog posts listType url pageNbr perPage ctx conn = | _ -> Task.FromResult (None, None) let newerLink = match listType, pageNbr with - | SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink) - | _, 1L -> None - | PostList, 2L when webLog.defaultPage = "posts" -> Some "" - | PostList, _ -> Some $"page/{pageNbr - 1L}" - | CategoryList, 2L -> Some $"category/{url}/" - | CategoryList, _ -> Some $"category/{url}/page/{pageNbr - 1L}" - | TagList, 2L -> Some $"tag/{url}/" - | TagList, _ -> Some $"tag/{url}/page/{pageNbr - 1L}" - | AdminList, 2L -> Some "admin/posts" - | AdminList, _ -> Some $"admin/posts/page/{pageNbr - 1L}" + | SinglePost, _ -> newerPost |> Option.map (fun p -> Permalink.toString p.permalink) + | _, 1 -> None + | PostList, 2 when webLog.defaultPage = "posts" -> Some "" + | PostList, _ -> relUrl $"page/{pageNbr - 1}" + | CategoryList, 2 -> relUrl $"category/{url}/" + | CategoryList, _ -> relUrl $"category/{url}/page/{pageNbr - 1}" + | TagList, 2 -> relUrl $"tag/{url}/" + | TagList, _ -> relUrl $"tag/{url}/page/{pageNbr - 1}" + | AdminList, 2 -> relUrl "admin/posts" + | AdminList, _ -> relUrl $"admin/posts/page/{pageNbr - 1}" let olderLink = match listType, List.length posts > perPage with | SinglePost, _ -> olderPost |> Option.map (fun p -> Permalink.toString p.permalink) | _, false -> None - | PostList, true -> Some $"page/{pageNbr + 1L}" - | CategoryList, true -> Some $"category/{url}/page/{pageNbr + 1L}" - | TagList, true -> Some $"tag/{url}/page/{pageNbr + 1L}" - | AdminList, true -> Some $"admin/posts/page/{pageNbr + 1L}" + | PostList, true -> relUrl $"page/{pageNbr + 1}" + | CategoryList, true -> relUrl $"category/{url}/page/{pageNbr + 1}" + | TagList, true -> relUrl $"tag/{url}/page/{pageNbr + 1}" + | AdminList, true -> relUrl $"admin/posts/page/{pageNbr + 1}" let model = { posts = postItems authors = authors @@ -95,28 +94,30 @@ let private preparePostList webLog posts listType url pageNbr perPage ctx conn = return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx; tag_mappings = tagMappings |} } +open Giraffe + // GET /page/{pageNbr} let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx + let webLog = webLog ctx + let conn = conn ctx let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx conn let title = match pageNbr, webLog.defaultPage with - | 1L, "posts" -> None - | _, "posts" -> Some $"Page {pageNbr}" - | _, _ -> Some $"Page {pageNbr} « Posts" + | 1, "posts" -> None + | _, "posts" -> Some $"Page {pageNbr}" + | _, _ -> Some $"Page {pageNbr} « Posts" match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () - if pageNbr = 1L && webLog.defaultPage = "posts" then hash.Add ("is_home", true) + if pageNbr = 1 && webLog.defaultPage = "posts" then hash.Add ("is_home", true) return! themedView "index" next ctx hash } // GET /category/{slug}/ // GET /category/{slug}/page/{pageNbr} -let pageOfCategorizedPosts : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - match pathAndPageNumber ctx with +let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { + let webLog = webLog ctx + let conn = conn ctx + match parseSlugAndPage slugAndPage with | Some pageNbr, slug -> let allCats = CategoryCache.get ctx let cat = allCats |> Array.find (fun cat -> cat.slug = slug) @@ -130,7 +131,7 @@ let pageOfCategorizedPosts : HttpHandler = fun next ctx -> task { match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") hash.Add ("subtitle", cat.description.Value) hash.Add ("is_category", true) @@ -143,10 +144,10 @@ open System.Web // GET /tag/{tag}/ // GET /tag/{tag}/page/{pageNbr} -let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx - match pathAndPageNumber ctx with +let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task { + let webLog = webLog ctx + let conn = conn ctx + match parseSlugAndPage slugAndPage with | Some pageNbr, rawTag -> let urlTag = HttpUtility.UrlDecode rawTag let! tag = backgroundTask { @@ -157,7 +158,7 @@ let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1L then "" else $""" (Page {pageNbr})""" + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") hash.Add ("is_tag", true) return! themedView "index" next ctx hash @@ -166,15 +167,18 @@ let pageOfTaggedPosts : HttpHandler = fun next ctx -> task { let spacedTag = tag.Replace ("-", " ") match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with | posts when List.length posts > 0 -> - let endUrl = if pageNbr = 1L then "" else $"page/{pageNbr}" - return! redirectTo true $"""/tag/{spacedTag.Replace (" ", "+")}/{endUrl}""" next ctx + let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}" + return! + redirectTo true + (WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}""")) + next ctx | _ -> return! Error.notFound next ctx | None, _ -> return! Error.notFound next ctx } // GET / let home : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.get ctx + let webLog = webLog ctx match webLog.defaultPage with | "posts" -> return! pageOfPosts 1 next ctx | pageId -> @@ -190,6 +194,7 @@ let home : HttpHandler = fun next ctx -> task { | None -> return! Error.notFound next ctx } + open System.IO open System.ServiceModel.Syndication open System.Text.RegularExpressions @@ -198,12 +203,12 @@ open System.Xml // GET /feed.xml // (Routing handled by catch-all handler for future configurability) let generateFeed : HttpHandler = fun next ctx -> backgroundTask { - let conn = conn ctx - let webLog = WebLogCache.get ctx - let urlBase = $"https://{webLog.urlBase}/" + let conn = conn ctx + let webLog = webLog ctx // TODO: hard-coded number of items - let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1L 10 conn - let! authors = getAuthors webLog posts conn + let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1 10 conn + let! authors = getAuthors webLog posts conn + let! tagMaps = getTagMappings webLog posts conn let cats = CategoryCache.get ctx let toItem (post : Post) = @@ -213,25 +218,29 @@ let generateFeed : HttpHandler = fun next ctx -> backgroundTask { | txt when txt.Length < 255 -> txt | txt -> $"{txt.Substring (0, 252)}..." let item = SyndicationItem ( - Id = $"{urlBase}{Permalink.toString post.permalink}", + Id = WebLog.absoluteUrl webLog post.permalink, Title = TextSyndicationContent.CreateHtmlContent post.title, PublishDate = DateTimeOffset post.publishedOn.Value, LastUpdatedTime = DateTimeOffset post.updatedOn, Content = TextSyndicationContent.CreatePlaintextContent plainText) item.AddPermalink (Uri item.Id) - let encoded = post.text.Replace("src=\"/", $"src=\"{urlBase}").Replace ("href=\"/", $"href=\"{urlBase}") + let encoded = + post.text.Replace("src=\"/", $"src=\"{webLog.urlBase}/").Replace ("href=\"/", $"href=\"{webLog.urlBase}/") item.ElementExtensions.Add ("encoded", "http://purl.org/rss/1.0/modules/content/", encoded) item.Authors.Add (SyndicationPerson ( Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value)) [ post.categoryIds |> List.map (fun catId -> let cat = cats |> Array.find (fun c -> c.id = CategoryId.toString catId) - SyndicationCategory (cat.name, $"{urlBase}category/{cat.slug}/", cat.name)) + SyndicationCategory (cat.name, WebLog.absoluteUrl webLog (Permalink $"category/{cat.slug}/"), cat.name)) post.tags |> List.map (fun tag -> - let urlTag = tag.Replace (" ", "+") - SyndicationCategory (tag, $"{urlBase}tag/{urlTag}/", $"{tag} (tag)")) + let urlTag = + match tagMaps |> List.tryFind (fun tm -> tm.tag = tag) with + | Some tm -> tm.urlValue + | None -> tag.Replace (" ", "+") + SyndicationCategory (tag, WebLog.absoluteUrl webLog (Permalink $"tag/{urlTag}/"), $"{tag} (tag)")) ] |> List.concat |> List.iter item.Categories.Add @@ -245,12 +254,12 @@ let generateFeed : HttpHandler = fun next ctx -> backgroundTask { feed.Generator <- generator ctx feed.Items <- posts |> Seq.ofList |> Seq.map toItem feed.Language <- "en" - feed.Id <- urlBase + feed.Id <- webLog.urlBase - feed.Links.Add (SyndicationLink (Uri $"{urlBase}feed.xml", "self", "", "application/rss+xml", 0L)) + feed.Links.Add (SyndicationLink (Uri $"{webLog.urlBase}/feed.xml", "self", "", "application/rss+xml", 0L)) feed.AttributeExtensions.Add (XmlQualifiedName ("content", "http://www.w3.org/2000/xmlns/"), "http://purl.org/rss/1.0/modules/content/") - feed.ElementExtensions.Add ("link", "", urlBase) + feed.ElementExtensions.Add ("link", "", webLog.urlBase) use mem = new MemoryStream () use xml = XmlWriter.Create mem @@ -266,15 +275,18 @@ let generateFeed : HttpHandler = fun next ctx -> backgroundTask { /// Sequence where the first returned value is the proper handler for the link let private deriveAction ctx : HttpHandler seq = - let webLog = WebLogCache.get ctx - let conn = conn ctx - let textLink = string ctx.Request.RouteValues["link"] - let permalink = Permalink textLink + let webLog = webLog ctx + let conn = conn ctx + let _, extra = WebLog.hostAndPath webLog + let textLink = if extra = "" then ctx.Request.Path.Value else ctx.Request.Path.Value.Substring extra.Length let await it = (Async.AwaitTask >> Async.RunSynchronously) it seq { + // Home page directory without the directory slash + if textLink = "" then yield redirectTo true (WebLog.relativeUrl webLog Permalink.empty) + let permalink = Permalink (textLink.Substring 1) // Current post match Data.Post.findByPermalink permalink webLog.id conn |> await with - | Some post -> + | Some post -> let model = preparePostList webLog [ post ] SinglePost "" 1 1 ctx conn |> await model.Add ("page_title", post.title) yield fun next ctx -> themedView "single-post" next ctx model @@ -288,27 +300,27 @@ let private deriveAction ctx : HttpHandler seq = | None -> () // RSS feed // TODO: configure this via web log - if textLink = "feed.xml" then yield generateFeed + if textLink = "/feed.xml" then yield generateFeed // Post differing only by trailing slash let altLink = Permalink (if textLink.EndsWith "/" then textLink[..textLink.Length - 2] else $"{textLink}/") match Data.Post.findByPermalink altLink webLog.id conn |> await with - | Some post -> yield redirectTo true $"/{Permalink.toString post.permalink}" + | Some post -> yield redirectTo true (WebLog.relativeUrl webLog post.permalink) | None -> () // Page differing only by trailing slash match Data.Page.findByPermalink altLink webLog.id conn |> await with - | Some page -> yield redirectTo true $"/{Permalink.toString page.permalink}" + | Some page -> yield redirectTo true (WebLog.relativeUrl webLog page.permalink) | None -> () // Prior post match Data.Post.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with - | Some link -> yield redirectTo true $"/{Permalink.toString link}" + | Some link -> yield redirectTo true (WebLog.relativeUrl webLog link) | None -> () // Prior page match Data.Page.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with - | Some link -> yield redirectTo true $"/{Permalink.toString link}" + | Some link -> yield redirectTo true (WebLog.relativeUrl webLog link) | None -> () } -// GET {**link} +// GET {all-of-the-above} let catchAll : HttpHandler = fun next ctx -> task { match deriveAction ctx |> Seq.tryHead with | Some handler -> return! handler next ctx @@ -318,8 +330,8 @@ let catchAll : HttpHandler = fun next ctx -> task { // GET /admin/posts // GET /admin/posts/page/{pageNbr} let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx + let webLog = webLog ctx + let conn = conn ctx let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx conn hash.Add ("page_title", "Posts") @@ -328,8 +340,8 @@ let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { // GET /admin/post/{id}/edit let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = WebLogCache.get ctx - let conn = conn ctx + let webLog = webLog ctx + let conn = conn ctx let! result = task { match postId with | "new" -> return Some ("Write a New Post", { Post.empty with id = PostId "new" }) @@ -354,7 +366,7 @@ let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { // GET /admin/post/{id}/permalinks let editPermalinks postId : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.Post.findByFullId (PostId postId) (webLogId ctx) (conn ctx) with + match! Data.Post.findByFullId (PostId postId) (webLog ctx).id (conn ctx) with | Some post -> return! Hash.FromAnonymousObject {| @@ -368,41 +380,43 @@ let editPermalinks postId : HttpHandler = requireUser >=> fun next ctx -> task { // POST /admin/post/permalinks let savePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let links = model.prior |> Array.map Permalink |> List.ofArray - match! Data.Post.updatePriorPermalinks (PostId model.id) (webLogId ctx) links (conn ctx) with + let webLog = webLog ctx + let! model = ctx.BindFormAsync () + let links = model.prior |> Array.map Permalink |> List.ofArray + match! Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links (conn ctx) with | true -> do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" } - return! redirectToGet $"/admin/post/{model.id}/permalinks" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{model.id}/permalinks")) next ctx | false -> return! Error.notFound next ctx } // POST /admin/post/{id}/delete let delete postId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - match! Data.Post.delete (PostId postId) (webLogId ctx) (conn ctx) with + let webLog = webLog ctx + match! Data.Post.delete (PostId postId) webLog.id (conn ctx) with | true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" } - return! redirectToGet "/admin/posts" next ctx + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/posts")) next ctx } #nowarn "3511" // POST /admin/post/save let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let webLogId = webLogId ctx - let conn = conn ctx - let now = DateTime.UtcNow - let! pst = task { + let! model = ctx.BindFormAsync () + let webLog = webLog 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 + webLogId = webLog.id authorId = userId ctx } - | postId -> return! Data.Post.findByFullId (PostId postId) webLogId conn + | postId -> return! Data.Post.findByFullId (PostId postId) webLog.id conn } match pst with | Some post -> @@ -460,6 +474,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { |> List.length = List.length pst.Value.categoryIds) then do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with message = "Post saved successfully" } - return! redirectToGet $"/admin/post/{PostId.toString post.id}/edit" next ctx + return! + redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{PostId.toString post.id}/edit")) next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 42609bc..6694986 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -1,75 +1,89 @@ /// Routes for this application module MyWebLog.Handlers.Routes +open Giraffe +open MyWebLog + +let router : HttpHandler = choose [ + GET >=> choose [ + route "/" >=> Post.home + ] + subRoute "/admin" (requireUser >=> choose [ + GET >=> choose [ + route "" >=> Admin.dashboard + subRoute "/categor" (choose [ + route "ies" >=> Admin.listCategories + routef "y/%s/edit" Admin.editCategory + ]) + subRoute "/page" (choose [ + route "s" >=> Admin.listPages 1 + routef "s/page/%i" Admin.listPages + routef "/%s/edit" Admin.editPage + routef "/%s/permalinks" Admin.editPagePermalinks + ]) + subRoute "/post" (choose [ + route "s" >=> Post.all 1 + routef "s/page/%i" Post.all + routef "/%s/edit" Post.edit + routef "/%s/permalinks" Post.editPermalinks + ]) + route "/settings" >=> Admin.settings + subRoute "/tag-mapping" (choose [ + route "s" >=> Admin.tagMappings + routef "/%s/edit" Admin.editMapping + ]) + route "/user/edit" >=> User.edit + ] + POST >=> choose [ + subRoute "/category" (choose [ + route "/save" >=> Admin.saveCategory + routef "/%s/delete" Admin.deleteCategory + ]) + subRoute "/page" (choose [ + route "/save" >=> Admin.savePage + route "/permalinks" >=> Admin.savePagePermalinks + routef "/%s/delete" Admin.deletePage + ]) + subRoute "/post" (choose [ + route "/save" >=> Post.save + route "/permalinks" >=> Post.savePermalinks + routef "/%s/delete" Post.delete + ]) + route "/settings" >=> Admin.saveSettings + subRoute "/tag-mapping" (choose [ + route "/save" >=> Admin.saveMapping + routef "/%s/delete" Admin.deleteMapping + ]) + route "/user/save" >=> User.save + ] + ]) + GET >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts + GET >=> routef "/page/%i" Post.pageOfPosts + GET >=> routexp "/tag/(.*)" Post.pageOfTaggedPosts + subRoute "/user" (choose [ + GET >=> choose [ + route "/log-on" >=> User.logOn None + route "/log-off" >=> User.logOff + ] + POST >=> choose [ + route "/log-on" >=> User.doLogOn + ] + ]) + GET >=> Post.catchAll + Error.notFound +] + +/// Wrap a router in a sub-route +let routerWithPath extraPath : HttpHandler = + subRoute extraPath router + +/// Handler to apply Giraffe routing with a possible sub-route +let handleRoute : HttpHandler = fun next ctx -> task { + let _, extraPath = WebLog.hostAndPath (webLog ctx) + return! (if extraPath = "" then router else routerWithPath extraPath) next ctx +} + open Giraffe.EndpointRouting -/// The endpoints defined in the above handlers -let endpoints = [ - GET [ - route "/" Post.home - ] - subRoute "/admin" [ - GET [ - route "" Admin.dashboard - subRoute "/categor" [ - route "ies" Admin.listCategories - routef "y/%s/edit" Admin.editCategory - ] - subRoute "/page" [ - route "s" (Admin.listPages 1) - routef "s/page/%d" Admin.listPages - routef "/%s/edit" Admin.editPage - routef "/%s/permalinks" Admin.editPagePermalinks - ] - subRoute "/post" [ - route "s" (Post.all 1) - routef "s/page/%d" Post.all - routef "/%s/edit" Post.edit - routef "/%s/permalinks" Post.editPermalinks - ] - route "/settings" Admin.settings - subRoute "/tag-mapping" [ - route "s" Admin.tagMappings - routef "/%s/edit" Admin.editMapping - ] - route "/user/edit" User.edit - ] - POST [ - subRoute "/category" [ - route "/save" Admin.saveCategory - routef "/%s/delete" Admin.deleteCategory - ] - subRoute "/page" [ - route "/save" Admin.savePage - route "/permalinks" Admin.savePagePermalinks - routef "/%s/delete" Admin.deletePage - ] - subRoute "/post" [ - route "/save" Post.save - route "/permalinks" Post.savePermalinks - routef "/%s/delete" Post.delete - ] - route "/settings" Admin.saveSettings - subRoute "/tag-mapping" [ - route "/save" Admin.saveMapping - routef "/%s/delete" Admin.deleteMapping - ] - route "/user/save" User.save - ] - ] - GET [ - route "/category/{**slug}" Post.pageOfCategorizedPosts - routef "/page/%d" Post.pageOfPosts - route "/tag/{**slug}" Post.pageOfTaggedPosts - ] - subRoute "/user" [ - GET [ - route "/log-on" (User.logOn None) - route "/log-off" User.logOff - ] - POST [ - route "/log-on" User.doLogOn - ] - ] - route "{**link}" Post.catchAll -] +/// Endpoint-routed handler to deal with sub-routes +let endpoint = [ route "{**url}" handleRoute ] diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index cfa4c69..9e7f9c6 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -41,7 +41,7 @@ open MyWebLog // POST /user/log-on let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () - let webLog = WebLogCache.get ctx + let webLog = webLog ctx match! Data.WebLogUser.findByEmail model.emailAddress webLog.id (conn ctx) with | Some user when user.passwordHash = hashedPassword model.password user.userName user.salt -> let claims = seq { @@ -56,7 +56,7 @@ let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) do! addMessage ctx { UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" } - return! redirectToGet (match model.returnTo with Some url -> url | None -> "/admin") next ctx + return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin"))) next ctx | _ -> do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" } return! logOn model.returnTo next ctx @@ -66,7 +66,7 @@ let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { let logOff : HttpHandler = fun next ctx -> task { do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme do! addMessage ctx { UserMessage.info with message = "Log off successful" } - return! redirectToGet "/" next ctx + return! redirectToGet (WebLog.relativeUrl (webLog ctx) Permalink.empty) next ctx } /// Display the user edit page, with information possibly filled in @@ -107,7 +107,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { do! Data.WebLogUser.update user conn let pwMsg = if model.newPassword = "" then "" else " and updated your password" do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } - return! redirectToGet "/admin/user/edit" next ctx + return! redirectToGet (WebLog.relativeUrl (webLog ctx) (Permalink "admin/user/edit")) next ctx | None -> return! Error.notFound next ctx else do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index e3991e8..0fa4905 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -15,6 +15,7 @@ + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 3fabce8..8291e78 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -1,100 +1,23 @@ -open System.Collections.Generic -open Microsoft.AspNetCore.Http -open Microsoft.Extensions.DependencyInjection +open Microsoft.AspNetCore.Http open MyWebLog -open RethinkDb.Driver.Net -open System /// Middleware to derive the current web log type WebLogMiddleware (next : RequestDelegate) = member this.InvokeAsync (ctx : HttpContext) = task { - match WebLogCache.exists ctx with - | true -> return! next.Invoke ctx - | false -> - let conn = ctx.RequestServices.GetRequiredService () - match! Data.WebLog.findByHost (Cache.makeKey ctx) conn with - | Some webLog -> - WebLogCache.set ctx webLog - do! PageListCache.update ctx - do! CategoryCache.update ctx - return! next.Invoke ctx - | None -> ctx.Response.StatusCode <- 404 + if WebLogCache.exists ctx then + ctx.Items["webLog"] <- WebLogCache.get ctx + if PageListCache.exists ctx then () else do! PageListCache.update ctx + if CategoryCache.exists ctx then () else do! CategoryCache.update ctx + return! next.Invoke ctx + else + ctx.Response.StatusCode <- 404 } -/// DotLiquid filters -module DotLiquidBespoke = - - open System.IO - open DotLiquid - open MyWebLog.ViewModels - - /// A filter to generate a link with posts categorized under the given category - type CategoryLinkFilter () = - static member CategoryLink (_ : Context, catObj : obj) = - match catObj with - | :? DisplayCategory as cat -> $"/category/{cat.slug}/" - | :? DropProxy as proxy -> $"""/category/{proxy["slug"]}/""" - | _ -> $"alert('unknown category object type {catObj.GetType().Name}')" - - /// A filter to generate a link that will edit a page - type EditPageLinkFilter () = - static member EditPageLink (_ : Context, postId : string) = - $"/admin/page/{postId}/edit" - - /// A filter to generate a link that will edit a post - type EditPostLinkFilter () = - static member EditPostLink (_ : Context, postId : string) = - $"/admin/post/{postId}/edit" - - /// A filter to generate nav links, highlighting the active link (exact match) - type NavLinkFilter () = - static member NavLink (ctx : Context, url : string, text : string) = - seq { - "
  • " - text - "
  • " - } - |> Seq.fold (+) "" - - /// A filter to generate a link with posts tagged with the given tag - type TagLinkFilter () = - static member TagLink (ctx : Context, tag : string) = - match ctx.Environments[0].["tag_mappings"] :?> TagMap list - |> List.tryFind (fun it -> it.tag = tag) with - | Some tagMap -> $"/tag/{tagMap.urlValue}/" - | None -> $"""/tag/{tag.Replace (" ", "+")}/""" - - /// 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 { - """" - } - |> Seq.iter result.WriteLine - - /// A filter to retrieve the value of a meta item from a list - // (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`) - type ValueFilter () = - static member Value (_ : Context, items : MetaItem list, name : string) = - match items |> List.tryFind (fun it -> it.name = name) with - | Some item -> item.value - | None -> $"-- {name} not found --" - +open System +open Microsoft.Extensions.DependencyInjection +open RethinkDb.Driver.Net /// Create the default information for a new web log module NewWebLog = @@ -220,7 +143,9 @@ module NewWebLog = } +open System.Collections.Generic open DotLiquid +open DotLiquidBespoke open Giraffe open Giraffe.EndpointRouting open Microsoft.AspNetCore.Antiforgery @@ -257,6 +182,7 @@ let main args = task { let! conn = rethinkCfg.CreateConnectionAsync () do! Data.Startup.ensureDb rethinkCfg (loggerFac.CreateLogger (nameof Data.Startup)) conn + do! WebLogCache.fill conn return conn } |> Async.AwaitTask |> Async.RunSynchronously let _ = builder.Services.AddSingleton conn @@ -273,13 +199,12 @@ let main args = let _ = builder.Services.AddGiraffe () // Set up DotLiquid - [ typeof; typeof - typeof; typeof - typeof; typeof + [ typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof ] |> List.iter Template.RegisterFilter - Template.RegisterTag "user_links" + Template.RegisterTag "user_links" [ // Domain types typeof; typeof; typeof; typeof @@ -308,7 +233,7 @@ let main args = let _ = app.UseStaticFiles () let _ = app.UseRouting () let _ = app.UseSession () - let _ = app.UseGiraffe Handlers.Routes.endpoints + let _ = app.UseGiraffe Handlers.Routes.endpoint app.Run() diff --git a/src/MyWebLog/themes/admin/category-edit.liquid b/src/MyWebLog/themes/admin/category-edit.liquid index 720f919..5a2fe39 100644 --- a/src/MyWebLog/themes/admin/category-edit.liquid +++ b/src/MyWebLog/themes/admin/category-edit.liquid @@ -1,6 +1,6 @@ 

    {{ page_title }}

    - +
    diff --git a/src/MyWebLog/themes/admin/category-list.liquid b/src/MyWebLog/themes/admin/category-list.liquid index 2cfe7ae..e98e9f0 100644 --- a/src/MyWebLog/themes/admin/category-list.liquid +++ b/src/MyWebLog/themes/admin/category-list.liquid @@ -1,6 +1,6 @@ 

    {{ page_title }}

    - Add a New Category + Add a New Category @@ -17,16 +17,19 @@ {%- endif %} {{ cat.name }}
    - {%- if cat.post_count > 0 %} - - View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} - + {%- if cat.post_count > 0 %} + + View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} + + + {%- endif %} + {%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%} + Edit - {%- endif %} - Edit - - + {%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%} + {%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%} + Delete diff --git a/src/MyWebLog/themes/admin/dashboard.liquid b/src/MyWebLog/themes/admin/dashboard.liquid index 452e4f3..fb0ece6 100644 --- a/src/MyWebLog/themes/admin/dashboard.liquid +++ b/src/MyWebLog/themes/admin/dashboard.liquid @@ -9,8 +9,8 @@ Published {{ model.posts }}   Drafts {{ model.drafts }} - View All - Write a New Post + View All + Write a New Post @@ -22,8 +22,8 @@ All {{ model.pages }}   Shown in Page List {{ model.listed_pages }} - View All - Create a New Page + View All + Create a New Page @@ -37,15 +37,15 @@ All {{ model.categories }}   Top Level {{ model.top_level_categories }} - View All - Add a New Category + View All + Add a New Category diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index 1a8fceb..c7c6b70 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -12,7 +12,7 @@
    @@ -17,12 +17,15 @@ {%- if pg.is_default %}   HOME PAGE{% endif -%} {%- if pg.show_in_page_list %}   IN PAGE LIST {% endif -%}
    - View Page + {%- capture pg_link %}{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{% endcapture -%} + View Page - Edit + Edit - + {%- capture pg_del %}admin/page/{{ pg.id }}/delete{% endcapture -%} + {%- capture pg_del_link %}{{ pg_del | relative_link }}{% endcapture -%} + Delete diff --git a/src/MyWebLog/themes/admin/permalinks.liquid b/src/MyWebLog/themes/admin/permalinks.liquid index 7acd1dc..9b82d8b 100644 --- a/src/MyWebLog/themes/admin/permalinks.liquid +++ b/src/MyWebLog/themes/admin/permalinks.liquid @@ -1,6 +1,7 @@ 

    {{ page_title }}

    - + {%- capture form_action %}admin/{{ model.entity }}/permalinks{% endcapture -%} +
    @@ -10,7 +11,8 @@ {{ model.current_title }}
    {{ model.current_permalink }}
    - « Back to Edit {{ model.entity | capitalize }} + {%- capture back_link %}admin/{{ model.entity }}/{{ model.id }}/edit{% endcapture -%} + « Back to Edit {{ model.entity | capitalize }}

    diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index ae6bbac..a3e04d6 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -1,6 +1,6 @@ 

    {{ page_title }}

    - +
    @@ -16,7 +16,8 @@ value="{{ model.permalink }}"> {%- if model.page_id != "new" %} - Manage Permalinks + {%- capture perm_edit %}admin/post/{{ model.post_id }}/permalinks{% endcapture -%} + Manage Permalinks {% endif -%}
    diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index 5f654ea..08d55aa 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -1,6 +1,6 @@

    {{ page_title }}

    @@ -23,12 +23,14 @@ + + + {% endif %} diff --git a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid index 9b72eb3..ca84bbf 100644 --- a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid +++ b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid @@ -1,35 +1,30 @@ -

    {{ page_title }}

    -
    -
    - - -
    - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - -
    +
    {{ page_title }}
    + + + +
    +
    +
    + +
    - -
    +
    +
    + + +
    +
    + +
    +
    + + + Cancel + +
    +
    + diff --git a/src/MyWebLog/themes/admin/tag-mapping-list-body.liquid b/src/MyWebLog/themes/admin/tag-mapping-list-body.liquid new file mode 100644 index 0000000..3b7544d --- /dev/null +++ b/src/MyWebLog/themes/admin/tag-mapping-list-body.liquid @@ -0,0 +1,34 @@ + + +
    + {%- assign map_count = mappings | size -%} + {% if map_count > 0 -%} + {% for map in mappings -%} + {%- assign map_id = mapping_ids | value: map.tag -%} +
    +
    + {{ map.tag }}
    + + {%- capture map_edit %}admin/settings/tag-mapping/{{ map_id }}/edit{% endcapture -%} + + Edit + + + {%- capture map_del %}admin/settings/tag-mapping/{{ map_id }}/delete{% endcapture -%} + {%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    {{ map.url_value }}
    +
    + {%- endfor %} + {%- else -%} +
    +
    This web log has no tag mappings
    +
    + {%- endif %} + diff --git a/src/MyWebLog/themes/admin/tag-mapping-list.liquid b/src/MyWebLog/themes/admin/tag-mapping-list.liquid index 48863dc..0b583c4 100644 --- a/src/MyWebLog/themes/admin/tag-mapping-list.liquid +++ b/src/MyWebLog/themes/admin/tag-mapping-list.liquid @@ -1,39 +1,14 @@ 

    {{ page_title }}

    -
    {{ post.title }}
    - View Post + View Post - Edit + Edit - + {%- capture post_del %}admin/post/{{ pg.id }}/delete{% endcapture -%} + {%- capture post_del_link %}{{ post_del | relative_link }}{% endcapture -%} + Delete @@ -44,12 +46,12 @@
    {% if model.newer_link %} -

    « Newer Posts

    +

    « Newer Posts

    {% endif %}
    {% if model.older_link %} -

    Older Posts »

    +

    Older Posts »

    {% endif %}
    diff --git a/src/MyWebLog/themes/admin/settings.liquid b/src/MyWebLog/themes/admin/settings.liquid index 116536d..df2e309 100644 --- a/src/MyWebLog/themes/admin/settings.liquid +++ b/src/MyWebLog/themes/admin/settings.liquid @@ -1,6 +1,6 @@ 

    {{ web_log.name }} Settings

    - +
    diff --git a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid index 8c8f0a9..af98b0b 100644 --- a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid +++ b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid @@ -1,12 +1,12 @@ 

    {{ page_title }}

    - +
    diff --git a/src/MyWebLog/themes/admin/tag-mapping-list.liquid b/src/MyWebLog/themes/admin/tag-mapping-list.liquid index c430ae7..856e864 100644 --- a/src/MyWebLog/themes/admin/tag-mapping-list.liquid +++ b/src/MyWebLog/themes/admin/tag-mapping-list.liquid @@ -1,6 +1,8 @@ 

    {{ page_title }}

    - Add a New Tag Mapping + + Add a New Tag Mapping + @@ -15,8 +17,13 @@ + + + {% endif %} + +
    {{ map.tag }}
    - + {%- capture map_edit %}admin/tag-mapping/{{ map_id }}/edit{% endcapture -%} + Edit + + {%- capture map_del %}admin/tag-mapping/{{ map_id }}/delete{% endcapture -%} + {%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%} + Delete diff --git a/src/MyWebLog/themes/admin/user-edit.liquid b/src/MyWebLog/themes/admin/user-edit.liquid index 5e13300..b82908e 100644 --- a/src/MyWebLog/themes/admin/user-edit.liquid +++ b/src/MyWebLog/themes/admin/user-edit.liquid @@ -1,6 +1,6 @@

    {{ page_title }}

    - +
    diff --git a/src/MyWebLog/themes/bit-badger/home-page.liquid b/src/MyWebLog/themes/bit-badger/home-page.liquid index 94ac8cb..7712f8c 100644 --- a/src/MyWebLog/themes/bit-badger/home-page.liquid +++ b/src/MyWebLog/themes/bit-badger/home-page.liquid @@ -2,7 +2,7 @@
    diff --git a/src/MyWebLog/themes/daniel-j-summers/index.liquid b/src/MyWebLog/themes/daniel-j-summers/index.liquid index 362806c..c1b5781 100644 --- a/src/MyWebLog/themes/daniel-j-summers/index.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/index.liquid @@ -8,7 +8,8 @@ {%- for post in model.posts %}

    - + {{ post.title }}

    @@ -24,7 +25,7 @@ {% if logged_on %} - Edit Post + Edit Post {% endif %} @@ -34,12 +35,12 @@ @@ -74,7 +75,7 @@ {% for cat in categories -%} {%- assign indent = cat.parent_names | size -%}
  • 0 %} style="padding-left:{{ indent }}rem;"{% endif %}> - {{ cat.name }} + {{ cat.name }} {{ cat.post_count }}
  • {%- endfor %} diff --git a/src/MyWebLog/themes/daniel-j-summers/layout.liquid b/src/MyWebLog/themes/daniel-j-summers/layout.liquid index d4bc29e..f3b5cdc 100644 --- a/src/MyWebLog/themes/daniel-j-summers/layout.liquid +++ b/src/MyWebLog/themes/daniel-j-summers/layout.liquid @@ -18,17 +18,19 @@ {%- if is_home %} - + + {%- endif %}
    diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index e3c6822..e9dafaf 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -156,58 +156,52 @@ }, /** - * Confirm and delete a category - * @param id The ID of the category to be deleted - * @param name The name of the category to be deleted + * Confirm and delete an item + * @param name The name of the item to be deleted + * @param url The URL to which the form should be posted */ - deleteCategory(id, name) { - if (confirm(`Are you sure you want to delete the category "${name}"? This action cannot be undone.`)) { + deleteItem(name, url) { + if (confirm(`Are you sure you want to delete the ${name}? This action cannot be undone.`)) { const form = document.getElementById("deleteForm") - form.action = `/admin/category/${id}/delete` + form.action = url form.submit() } return false }, + + /** + * Confirm and delete a category + * @param name The name of the category to be deleted + * @param url The URL to which the form should be posted + */ + deleteCategory(name, url) { + return this.deleteItem(`category "${name}"`, url) + }, /** * Confirm and delete a page - * @param id The ID of the page to be deleted * @param title The title of the page to be deleted + * @param url The URL to which the form should be posted */ - deletePage(id, title) { - if (confirm(`Are you sure you want to delete the page "${name}"? This action cannot be undone.`)) { - const form = document.getElementById("deleteForm") - form.action = `/admin/page/${id}/delete` - form.submit() - } - return false + deletePage(title, url) { + return this.deleteItem(`page "${title}"`, url) }, /** * Confirm and delete a post - * @param id The ID of the post to be deleted * @param title The title of the post to be deleted + * @param url The URL to which the form should be posted */ - deletePost(id, title) { - if (confirm(`Are you sure you want to delete the post "${name}"? This action cannot be undone.`)) { - const form = document.getElementById("deleteForm") - form.action = `/admin/post/${id}/delete` - form.submit() - } - return false + deletePost(title, url) { + return this.deleteItem(`post "${title}"`, url) }, /** * Confirm and delete a tag mapping - * @param id The ID of the mapping to be deleted * @param tag The tag for which the mapping will be deleted + * @param url The URL to which the form should be posted */ - deleteTagMapping(id, tag) { - if (confirm(`Are you sure you want to delete the mapping for "${tag}"? This action cannot be undone.`)) { - const form = document.getElementById("deleteForm") - form.action = `/admin/tag-mapping/${id}/delete` - form.submit() - } - return false + deleteTagMapping(tag, url) { + return this.deleteItem(`mapping for "${tag}"`, url) } } -- 2.45.1 From 8631a7621701aef73efa8ff20de3a37bc3da3528 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 22 May 2022 18:24:09 -0400 Subject: [PATCH 046/102] Use most specific URL base match - Add extensions for web log and data connection - Add forwarded header middleware --- src/MyWebLog/Caches.fs | 55 +++++++++--------- src/MyWebLog/Handlers/Admin.fs | 95 +++++++++++++++----------------- src/MyWebLog/Handlers/Error.fs | 4 +- src/MyWebLog/Handlers/Helpers.fs | 46 ++++++++++------ src/MyWebLog/Handlers/Post.fs | 70 ++++++++++++----------- src/MyWebLog/Handlers/Routes.fs | 6 +- src/MyWebLog/Handlers/User.fs | 16 +++--- src/MyWebLog/Program.fs | 12 ++-- src/MyWebLog/appsettings.json | 7 ++- 9 files changed, 167 insertions(+), 144 deletions(-) diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index e85850b..5e81eef 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -2,16 +2,22 @@ open Microsoft.AspNetCore.Http -/// Helper functions for caches -module Cache = +/// Extension properties on HTTP context for web log +[] +module Extensions = - /// Create the cache key for the web log for the current request - let makeKey (ctx : HttpContext) = (ctx.Items["webLog"] :?> WebLog).urlBase - + open Microsoft.Extensions.DependencyInjection + open RethinkDb.Driver.Net + + type HttpContext with + /// The web log for the current request + member this.WebLog = this.Items["webLog"] :?> WebLog + + /// The RethinkDB data connection + member this.Conn = this.RequestServices.GetRequiredService () + open System.Collections.Concurrent -open Microsoft.Extensions.DependencyInjection -open RethinkDb.Driver.Net /// /// In-memory cache of web log details @@ -27,15 +33,13 @@ module WebLogCache = /// The cache of web log details let mutable private _cache : WebLog list = [] - /// Does a host exist in the cache? - let exists ctx = + /// Try to get the web log for the current request (longest matching URL base wins) + let tryGet ctx = let path = fullPath ctx - _cache |> List.exists (fun wl -> path.StartsWith wl.urlBase) - - /// Get the web log for the current request - let get ctx = - let path = fullPath ctx - _cache |> List.find (fun wl -> path.StartsWith wl.urlBase) + _cache + |> List.filter (fun wl -> path.StartsWith wl.urlBase) + |> List.sortByDescending (fun wl -> wl.urlBase.Length) + |> List.tryHead /// Cache the web log for a particular host let set webLog = @@ -57,17 +61,16 @@ module PageListCache = let private _cache = ConcurrentDictionary () /// Are there pages cached for this web log? - let exists ctx = _cache.ContainsKey (Cache.makeKey ctx) + let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.urlBase /// Get the pages for the web log for this request - let get ctx = _cache[Cache.makeKey ctx] + let get (ctx : HttpContext) = _cache[ctx.WebLog.urlBase] /// Update the pages for the current web log let update (ctx : HttpContext) = backgroundTask { - let webLog = ctx.Items["webLog"] :?> WebLog - let conn = ctx.RequestServices.GetRequiredService () - let! pages = Data.Page.findListed webLog.id conn - _cache[Cache.makeKey ctx] <- pages |> List.map (DisplayPage.fromPage webLog) |> Array.ofList + let webLog = ctx.WebLog + let! pages = Data.Page.findListed webLog.id ctx.Conn + _cache[webLog.urlBase] <- pages |> List.map (DisplayPage.fromPage webLog) |> Array.ofList } @@ -80,17 +83,15 @@ module CategoryCache = let private _cache = ConcurrentDictionary () /// Are there categories cached for this web log? - let exists ctx = _cache.ContainsKey (Cache.makeKey ctx) + let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.urlBase /// Get the categories for the web log for this request - let get ctx = _cache[Cache.makeKey ctx] + let get (ctx : HttpContext) = _cache[ctx.WebLog.urlBase] /// Update the cache with fresh data let update (ctx : HttpContext) = backgroundTask { - let webLog = ctx.Items["webLog"] :?> WebLog - let conn = ctx.RequestServices.GetRequiredService () - let! cats = Data.Category.findAllForView webLog.id conn - _cache[Cache.makeKey ctx] <- cats + let! cats = Data.Category.findAllForView ctx.WebLog.id ctx.Conn + _cache[ctx.WebLog.urlBase] <- cats } diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 4940fe1..7d42077 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -1,8 +1,6 @@ /// Handlers to manipulate admin functions module MyWebLog.Handlers.Admin -// TODO: remove requireUser, as this is applied in the router - open System.Collections.Generic open System.IO @@ -22,10 +20,10 @@ open MyWebLog.ViewModels open RethinkDb.Driver.Net // GET /admin -let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx - let getCount (f : WebLogId -> IConnection -> Task) = f webLog.id conn +let dashboard : HttpHandler = fun next ctx -> task { + let webLogId = ctx.WebLog.id + let conn = ctx.Conn + let getCount (f : WebLogId -> IConnection -> Task) = f webLogId conn let! posts = Data.Post.countByStatus Published |> getCount let! drafts = Data.Post.countByStatus Draft |> getCount let! pages = Data.Page.countAll |> getCount @@ -50,7 +48,7 @@ let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { // -- CATEGORIES -- // GET /admin/categories -let listCategories : HttpHandler = requireUser >=> fun next ctx -> task { +let listCategories : HttpHandler = fun next ctx -> task { return! Hash.FromAnonymousObject {| categories = CategoryCache.get ctx @@ -61,14 +59,12 @@ let listCategories : HttpHandler = requireUser >=> fun next ctx -> task { } // GET /admin/category/{id}/edit -let editCategory catId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx +let editCategory catId : HttpHandler = fun next ctx -> task { let! result = task { match catId with | "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" }) | _ -> - match! Data.Category.findById (CategoryId catId) webLog.id conn with + match! Data.Category.findById (CategoryId catId) ctx.WebLog.id ctx.Conn with | Some cat -> return Some ("Edit Category", cat) | None -> return None } @@ -86,10 +82,10 @@ let editCategory catId : HttpHandler = requireUser >=> fun next ctx -> task { } // POST /admin/category/save -let saveCategory : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { +let saveCategory : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let conn = ctx.Conn let! model = ctx.BindFormAsync () - let webLog = webLog ctx - let conn = conn ctx let! category = task { match model.categoryId with | "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLog.id } @@ -114,10 +110,9 @@ let saveCategory : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx - } // POST /admin/category/{id}/delete -let deleteCategory catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx - match! Data.Category.delete (CategoryId catId) webLog.id conn with +let deleteCategory catId : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + match! Data.Category.delete (CategoryId catId) webLog.id ctx.Conn with | true -> do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with message = "Category deleted successfully" } @@ -129,9 +124,9 @@ let deleteCategory catId : HttpHandler = requireUser >=> validateCsrf >=> fun ne // GET /admin/pages // GET /admin/pages/page/{pageNbr} -let listPages pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = webLog ctx - let! pages = Data.Page.findPageOfPages webLog.id pageNbr (conn ctx) +let listPages pageNbr : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let! pages = Data.Page.findPageOfPages webLog.id pageNbr ctx.Conn return! Hash.FromAnonymousObject {| pages = pages |> List.map (DisplayPage.fromPageMinimal webLog) @@ -141,12 +136,12 @@ let listPages pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { } // GET /admin/page/{id}/edit -let editPage pgId : HttpHandler = requireUser >=> fun next ctx -> task { +let editPage pgId : HttpHandler = fun next ctx -> task { let! result = task { match pgId with | "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" }) | _ -> - match! Data.Page.findByFullId (PageId pgId) (webLog ctx).id (conn ctx) with + match! Data.Page.findByFullId (PageId pgId) ctx.WebLog.id ctx.Conn with | Some page -> return Some ("Edit Page", page) | None -> return None } @@ -167,8 +162,8 @@ let editPage pgId : HttpHandler = requireUser >=> fun next ctx -> task { } // GET /admin/page/{id}/permalinks -let editPagePermalinks pgId : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.Page.findByFullId (PageId pgId) (webLog ctx).id (conn ctx) with +let editPagePermalinks pgId : HttpHandler = fun next ctx -> task { + match! Data.Page.findByFullId (PageId pgId) ctx.WebLog.id ctx.Conn with | Some pg -> return! Hash.FromAnonymousObject {| @@ -181,11 +176,11 @@ let editPagePermalinks pgId : HttpHandler = requireUser >=> fun next ctx -> task } // POST /admin/page/permalinks -let savePagePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx +let savePagePermalinks : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog let! model = ctx.BindFormAsync () let links = model.prior |> Array.map Permalink |> List.ofArray - match! Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links (conn ctx) with + match! Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links ctx.Conn with | true -> do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{model.id}/permalinks")) next ctx @@ -193,9 +188,9 @@ let savePagePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next } // POST /admin/page/{id}/delete -let deletePage pgId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx - match! Data.Page.delete (PageId pgId) webLog.id (conn ctx) with +let deletePage pgId : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + match! Data.Page.delete (PageId pgId) webLog.id ctx.Conn with | true -> do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx @@ -206,10 +201,10 @@ open System #nowarn "3511" // POST /admin/page/save -let savePage : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { +let savePage : HttpHandler = fun next ctx -> task { let! model = ctx.BindFormAsync () - let webLog = webLog ctx - let conn = conn ctx + let webLog = ctx.WebLog + let conn = ctx.Conn let now = DateTime.UtcNow let! pg = task { match model.pageId with @@ -261,9 +256,9 @@ let savePage : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> ta // -- WEB LOG SETTINGS -- // GET /admin/settings -let settings : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = webLog ctx - let! allPages = Data.Page.findAll webLog.id (conn ctx) +let settings : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let! allPages = Data.Page.findAll webLog.id ctx.Conn return! Hash.FromAnonymousObject {| csrf = csrfToken ctx @@ -284,9 +279,9 @@ let settings : HttpHandler = requireUser >=> fun next ctx -> task { } // POST /admin/settings -let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx +let saveSettings : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let conn = ctx.Conn let! model = ctx.BindFormAsync () match! Data.WebLog.findById webLog.id conn with | Some webLog -> @@ -312,8 +307,8 @@ let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx - // -- TAG MAPPINGS -- // GET /admin/tag-mappings -let tagMappings : HttpHandler = requireUser >=> fun next ctx -> task { - let! mappings = Data.TagMap.findByWebLogId (webLog ctx).id (conn ctx) +let tagMappings : HttpHandler = fun next ctx -> task { + let! mappings = Data.TagMap.findByWebLogId ctx.WebLog.id ctx.Conn return! Hash.FromAnonymousObject {| csrf = csrfToken ctx @@ -325,13 +320,13 @@ let tagMappings : HttpHandler = requireUser >=> fun next ctx -> task { } // GET /admin/tag-mapping/{id}/edit -let editMapping tagMapId : HttpHandler = requireUser >=> fun next ctx -> task { +let editMapping tagMapId : HttpHandler = fun next ctx -> task { let isNew = tagMapId = "new" let tagMap = if isNew then Task.FromResult (Some { TagMap.empty with id = TagMapId "new" }) else - Data.TagMap.findById (TagMapId tagMapId) (webLog ctx).id (conn ctx) + Data.TagMap.findById (TagMapId tagMapId) ctx.WebLog.id ctx.Conn match! tagMap with | Some tm -> return! @@ -345,9 +340,9 @@ let editMapping tagMapId : HttpHandler = requireUser >=> fun next ctx -> task { } // POST /admin/tag-mapping/save -let saveMapping : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx +let saveMapping : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let conn = ctx.Conn let! model = ctx.BindFormAsync () let tagMap = if model.id = "new" then @@ -365,9 +360,9 @@ let saveMapping : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> } // POST /admin/tag-mapping/{id}/delete -let deleteMapping tagMapId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx - match! Data.TagMap.delete (TagMapId tagMapId) webLog.id (conn ctx) with +let deleteMapping tagMapId : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + match! Data.TagMap.delete (TagMapId tagMapId) webLog.id ctx.Conn with | true -> do! addMessage ctx { UserMessage.success with message = "Tag mapping deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Tag mapping not found; nothing deleted" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/tag-mappings")) next ctx diff --git a/src/MyWebLog/Handlers/Error.fs b/src/MyWebLog/Handlers/Error.fs index dfb00ee..0f7157c 100644 --- a/src/MyWebLog/Handlers/Error.fs +++ b/src/MyWebLog/Handlers/Error.fs @@ -9,10 +9,10 @@ open MyWebLog /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response let notAuthorized : HttpHandler = fun next ctx -> task { - let webLog = ctx.Items["webLog"] :?> WebLog if ctx.Request.Method = "GET" then let returnUrl = WebUtility.UrlEncode ctx.Request.Path - return! redirectTo false (WebLog.relativeUrl webLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx + return! + redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx else return! (setStatusCode 401 >=> fun _ _ -> Task.FromResult None) next ctx } diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index eb9644e..33e2445 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -64,20 +64,18 @@ let generator (ctx : HttpContext) = | Some gen -> gen | None -> let cfg = ctx.RequestServices.GetRequiredService () - generatorString <- Option.ofObj cfg["Generator"] - defaultArg generatorString "generator not configured" + generatorString <- + match Option.ofObj cfg["Generator"] with + | Some gen -> Some gen + | None -> Some "generator not configured" + generatorString.Value open MyWebLog - -/// Get the web log for the request from the context (established by middleware) -let webLog (ctx : HttpContext) = - ctx.Items["webLog"] :?> WebLog - open DotLiquid /// Either get the web log from the hash, or get it from the cache and add it to the hash -let private deriveWebLogFromHash (hash : Hash) ctx = - if hash.ContainsKey "web_log" then () else hash.Add ("web_log", webLog ctx) +let private deriveWebLogFromHash (hash : Hash) (ctx : HttpContext) = + if hash.ContainsKey "web_log" then () else hash.Add ("web_log", ctx.WebLog) hash["web_log"] :?> WebLog open Giraffe @@ -125,11 +123,6 @@ open System.Security.Claims let userId (ctx : HttpContext) = WebLogUserId (ctx.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value -open RethinkDb.Driver.Net - -/// Get the RethinkDB connection -let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () - open Microsoft.AspNetCore.Antiforgery /// Get the Anti-CSRF service @@ -153,11 +146,11 @@ open System.Collections.Generic open System.IO /// Get the templates available for the current web log's theme (in a key/value pair list) -let templatesForTheme ctx (typ : string) = +let templatesForTheme (ctx : HttpContext) (typ : string) = seq { KeyValuePair.Create ("", $"- Default (single-{typ}) -") yield! - Path.Combine ("themes", (webLog ctx).themePath) + Path.Combine ("themes", ctx.WebLog.themePath) |> Directory.EnumerateFiles |> Seq.filter (fun it -> it.EndsWith $"{typ}.liquid") |> Seq.map (fun it -> @@ -167,3 +160,24 @@ let templatesForTheme ctx (typ : string) = } |> Array.ofSeq +open Microsoft.Extensions.Logging + +/// Log level for debugging +let mutable private debugEnabled : bool option = None + +/// Is debug enabled for handlers? +let private isDebugEnabled (ctx : HttpContext) = + match debugEnabled with + | Some flag -> flag + | None -> + let fac = ctx.RequestServices.GetRequiredService () + let log = fac.CreateLogger "MyWebLog.Handlers" + debugEnabled <- Some (log.IsEnabled LogLevel.Debug) + debugEnabled.Value + +/// Log a debug message +let debug name ctx (msg : unit -> string) = + if isDebugEnabled ctx then + let fac = ctx.RequestServices.GetRequiredService () + let log = fac.CreateLogger $"MyWebLog.Handlers.{name}" + log.LogDebug (msg ()) diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 2978b74..a066abc 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -2,6 +2,7 @@ module MyWebLog.Handlers.Post open System +open Microsoft.AspNetCore.Http /// Parse a slug and page number from an "everything else" URL let private parseSlugAndPage (slugAndPage : string seq) = @@ -98,8 +99,8 @@ open Giraffe // GET /page/{pageNbr} let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx + let webLog = ctx.WebLog + let conn = ctx.Conn let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage conn let! hash = preparePostList webLog posts PostList "" pageNbr webLog.postsPerPage ctx conn let title = @@ -115,8 +116,8 @@ let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { // GET /category/{slug}/ // GET /category/{slug}/page/{pageNbr} let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx + let webLog = ctx.WebLog + let conn = ctx.Conn match parseSlugAndPage slugAndPage with | Some pageNbr, slug -> let allCats = CategoryCache.get ctx @@ -145,8 +146,8 @@ open System.Web // GET /tag/{tag}/ // GET /tag/{tag}/page/{pageNbr} let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx + let webLog = ctx.WebLog + let conn = ctx.Conn match parseSlugAndPage slugAndPage with | Some pageNbr, rawTag -> let urlTag = HttpUtility.UrlDecode rawTag @@ -178,11 +179,11 @@ let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task { // GET / let home : HttpHandler = fun next ctx -> task { - let webLog = webLog ctx + let webLog = ctx.WebLog match webLog.defaultPage with | "posts" -> return! pageOfPosts 1 next ctx | pageId -> - match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with + match! Data.Page.findById (PageId pageId) webLog.id ctx.Conn with | Some page -> return! Hash.FromAnonymousObject {| @@ -203,8 +204,8 @@ open System.Xml // GET /feed.xml // (Routing handled by catch-all handler for future configurability) let generateFeed : HttpHandler = fun next ctx -> backgroundTask { - let conn = conn ctx - let webLog = webLog ctx + let webLog = ctx.WebLog + let conn = ctx.Conn // TODO: hard-coded number of items let! posts = Data.Post.findPageOfPublishedPosts webLog.id 1 10 conn let! authors = getAuthors webLog posts conn @@ -274,13 +275,16 @@ let generateFeed : HttpHandler = fun next ctx -> backgroundTask { } /// Sequence where the first returned value is the proper handler for the link -let private deriveAction ctx : HttpHandler seq = - let webLog = webLog ctx - let conn = conn ctx - let _, extra = WebLog.hostAndPath webLog - let textLink = if extra = "" then ctx.Request.Path.Value else ctx.Request.Path.Value.Substring extra.Length - let await it = (Async.AwaitTask >> Async.RunSynchronously) it +let private deriveAction (ctx : HttpContext) : HttpHandler seq = + let webLog = ctx.WebLog + let conn = ctx.Conn + let textLink = + let _, extra = WebLog.hostAndPath webLog + let url = string ctx.Request.Path + if extra = "" then url else url.Substring extra.Length + let await it = (Async.AwaitTask >> Async.RunSynchronously) it seq { + debug "Post" ctx (fun () -> $"Considering URL {textLink}") // Home page directory without the directory slash if textLink = "" then yield redirectTo true (WebLog.relativeUrl webLog Permalink.empty) let permalink = Permalink (textLink.Substring 1) @@ -329,9 +333,9 @@ let catchAll : HttpHandler = fun next ctx -> task { // GET /admin/posts // GET /admin/posts/page/{pageNbr} -let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx +let all pageNbr : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let conn = ctx.Conn let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx conn hash.Add ("page_title", "Posts") @@ -339,9 +343,9 @@ let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task { } // GET /admin/post/{id}/edit -let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { - let webLog = webLog ctx - let conn = conn ctx +let edit postId : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + let conn = ctx.Conn let! result = task { match postId with | "new" -> return Some ("Write a New Post", { Post.empty with id = PostId "new" }) @@ -365,8 +369,8 @@ let edit postId : HttpHandler = requireUser >=> fun next ctx -> task { } // GET /admin/post/{id}/permalinks -let editPermalinks postId : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.Post.findByFullId (PostId postId) (webLog ctx).id (conn ctx) with +let editPermalinks postId : HttpHandler = fun next ctx -> task { + match! Data.Post.findByFullId (PostId postId) ctx.WebLog.id ctx.Conn with | Some post -> return! Hash.FromAnonymousObject {| @@ -379,11 +383,11 @@ let editPermalinks postId : HttpHandler = requireUser >=> fun next ctx -> task { } // POST /admin/post/permalinks -let savePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx +let savePermalinks : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog let! model = ctx.BindFormAsync () let links = model.prior |> Array.map Permalink |> List.ofArray - match! Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links (conn ctx) with + match! Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links ctx.Conn with | true -> do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{model.id}/permalinks")) next ctx @@ -391,9 +395,9 @@ let savePermalinks : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx } // POST /admin/post/{id}/delete -let delete postId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { - let webLog = webLog ctx - match! Data.Post.delete (PostId postId) webLog.id (conn ctx) with +let delete postId : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + match! Data.Post.delete (PostId postId) webLog.id ctx.Conn with | true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/posts")) next ctx @@ -402,10 +406,10 @@ let delete postId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx #nowarn "3511" // POST /admin/post/save -let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { +let save : HttpHandler = fun next ctx -> task { let! model = ctx.BindFormAsync () - let webLog = webLog ctx - let conn = conn ctx + let webLog = ctx.WebLog + let conn = ctx.Conn let now = DateTime.UtcNow let! pst = task { match model.postId with diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 6694986..4f0fbda 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -34,7 +34,7 @@ let router : HttpHandler = choose [ ]) route "/user/edit" >=> User.edit ] - POST >=> choose [ + POST >=> validateCsrf >=> choose [ subRoute "/category" (choose [ route "/save" >=> Admin.saveCategory routef "/%s/delete" Admin.deleteCategory @@ -65,7 +65,7 @@ let router : HttpHandler = choose [ route "/log-on" >=> User.logOn None route "/log-off" >=> User.logOff ] - POST >=> choose [ + POST >=> validateCsrf >=> choose [ route "/log-on" >=> User.doLogOn ] ]) @@ -79,7 +79,7 @@ let routerWithPath extraPath : HttpHandler = /// Handler to apply Giraffe routing with a possible sub-route let handleRoute : HttpHandler = fun next ctx -> task { - let _, extraPath = WebLog.hostAndPath (webLog ctx) + let _, extraPath = WebLog.hostAndPath ctx.WebLog return! (if extraPath = "" then router else routerWithPath extraPath) next ctx } diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index 9e7f9c6..c0c7939 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -39,10 +39,10 @@ open Microsoft.AspNetCore.Authentication.Cookies open MyWebLog // POST /user/log-on -let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { +let doLogOn : HttpHandler = fun next ctx -> task { let! model = ctx.BindFormAsync () - let webLog = webLog ctx - match! Data.WebLogUser.findByEmail model.emailAddress webLog.id (conn ctx) with + let webLog = ctx.WebLog + match! Data.WebLogUser.findByEmail model.emailAddress webLog.id ctx.Conn with | Some user when user.passwordHash = hashedPassword model.password user.userName user.salt -> let claims = seq { Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id) @@ -66,7 +66,7 @@ let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task { let logOff : HttpHandler = fun next ctx -> task { do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme do! addMessage ctx { UserMessage.info with message = "Log off successful" } - return! redirectToGet (WebLog.relativeUrl (webLog ctx) Permalink.empty) next ctx + return! redirectToGet (WebLog.relativeUrl ctx.WebLog Permalink.empty) next ctx } /// Display the user edit page, with information possibly filled in @@ -77,8 +77,8 @@ let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { } // GET /admin/user/edit -let edit : HttpHandler = requireUser >=> fun next ctx -> task { - match! Data.WebLogUser.findById (userId ctx) (conn ctx) with +let edit : HttpHandler = fun next ctx -> task { + match! Data.WebLogUser.findById (userId ctx) ctx.Conn with | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx | None -> return! Error.notFound next ctx } @@ -87,7 +87,7 @@ let edit : HttpHandler = requireUser >=> fun next ctx -> task { let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () if model.newPassword = model.newPasswordConfirm then - let conn = conn ctx + let conn = ctx.Conn match! Data.WebLogUser.findById (userId ctx) conn with | Some user -> let pw, salt = @@ -107,7 +107,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { do! Data.WebLogUser.update user conn let pwMsg = if model.newPassword = "" then "" else " and updated your password" do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } - return! redirectToGet (WebLog.relativeUrl (webLog ctx) (Permalink "admin/user/edit")) next ctx + return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/user/edit")) next ctx | None -> return! Error.notFound next ctx else do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 8291e78..dde6415 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -5,13 +5,13 @@ open MyWebLog type WebLogMiddleware (next : RequestDelegate) = member this.InvokeAsync (ctx : HttpContext) = task { - if WebLogCache.exists ctx then - ctx.Items["webLog"] <- WebLogCache.get ctx + match WebLogCache.tryGet ctx with + | Some webLog -> + ctx.Items["webLog"] <- webLog if PageListCache.exists ctx then () else do! PageListCache.update ctx if CategoryCache.exists ctx then () else do! CategoryCache.update ctx return! next.Invoke ctx - else - ctx.Response.StatusCode <- 404 + | None -> ctx.Response.StatusCode <- 404 } @@ -151,6 +151,7 @@ open Giraffe.EndpointRouting open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.HttpOverrides open Microsoft.Extensions.Configuration open Microsoft.Extensions.Logging open MyWebLog.ViewModels @@ -161,6 +162,8 @@ open RethinkDb.Driver.FSharp let main args = let builder = WebApplication.CreateBuilder(args) + let _ = builder.Services.Configure(fun (opts : ForwardedHeadersOptions) -> + opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto) let _ = builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) @@ -227,6 +230,7 @@ let main args = | Some it when it = "import-permalinks" -> NewWebLog.importPermalinks args app.Services |> Async.AwaitTask |> Async.RunSynchronously | _ -> + let _ = app.UseForwardedHeaders () let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseMiddleware () let _ = app.UseAuthentication () diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 4cb4a6e..f7733ab 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,5 +3,10 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha08" + "Generator": "myWebLog 2.0-alpha10", + "Logging": { + "LogLevel": { + "MyWebLog.Handlers": "Debug" + } + } } -- 2.45.1 From 2c2db62e65d77c7f6e1e04c54f18eebd92f0d494 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 22 May 2022 18:59:48 -0400 Subject: [PATCH 047/102] Add debug logging to web log determination --- src/MyWebLog/Caches.fs | 7 +------ src/MyWebLog/Program.fs | 18 +++++++++++++----- src/MyWebLog/appsettings.json | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 5e81eef..2fd0b1e 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -26,16 +26,11 @@ open System.Collections.Concurrent /// settings update page module WebLogCache = - /// Create the full path of the request - let private fullPath (ctx : HttpContext) = - $"{ctx.Request.Scheme}://{ctx.Request.Host.Value}{ctx.Request.Path.Value}" - /// The cache of web log details let mutable private _cache : WebLog list = [] /// Try to get the web log for the current request (longest matching URL base wins) - let tryGet ctx = - let path = fullPath ctx + let tryGet (path : string) = _cache |> List.filter (fun wl -> path.StartsWith wl.urlBase) |> List.sortByDescending (fun wl -> wl.urlBase.Length) diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index dde6415..e58ff1c 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -1,17 +1,26 @@ open Microsoft.AspNetCore.Http +open Microsoft.Extensions.Logging open MyWebLog /// Middleware to derive the current web log -type WebLogMiddleware (next : RequestDelegate) = - +type WebLogMiddleware (next : RequestDelegate, log : ILogger) = + + /// Is the debug level enabled on the logger? + let isDebug = log.IsEnabled LogLevel.Debug + member this.InvokeAsync (ctx : HttpContext) = task { - match WebLogCache.tryGet ctx with + /// Create the full path of the request + let path = $"{ctx.Request.Scheme}://{ctx.Request.Host.Value}{ctx.Request.Path.Value}" + match WebLogCache.tryGet path with | Some webLog -> + if isDebug then log.LogDebug $"Resolved web log {WebLogId.toString webLog.id} for {path}" ctx.Items["webLog"] <- webLog if PageListCache.exists ctx then () else do! PageListCache.update ctx if CategoryCache.exists ctx then () else do! CategoryCache.update ctx return! next.Invoke ctx - | None -> ctx.Response.StatusCode <- 404 + | None -> + if isDebug then log.LogDebug $"No resolved web log for {path}" + ctx.Response.StatusCode <- 404 } @@ -153,7 +162,6 @@ open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.HttpOverrides open Microsoft.Extensions.Configuration -open Microsoft.Extensions.Logging open MyWebLog.ViewModels open RethinkDB.DistributedCache open RethinkDb.Driver.FSharp diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index f7733ab..381b010 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha10", + "Generator": "myWebLog 2.0-alpha11", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" -- 2.45.1 From 664704d3d5c828890edb07fe132454bf2b4a84da Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 23 May 2022 00:09:09 -0400 Subject: [PATCH 048/102] Fix post edit action - Add CSRF to post/page list pages (for deletion) --- src/MyWebLog/Handlers/Admin.fs | 9 ++++++--- src/MyWebLog/Handlers/Post.fs | 1 + src/MyWebLog/themes/admin/post-edit.liquid | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 7d42077..ea2a22a 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -129,8 +129,9 @@ let listPages pageNbr : HttpHandler = fun next ctx -> task { let! pages = Data.Page.findPageOfPages webLog.id pageNbr ctx.Conn return! Hash.FromAnonymousObject - {| pages = pages |> List.map (DisplayPage.fromPageMinimal webLog) - page_title = "Pages" + {| csrf = csrfToken ctx + pages = pages |> List.map (DisplayPage.fromPageMinimal webLog) + page_title = "Pages" |} |> viewForTheme "admin" "page-list" next ctx } @@ -191,7 +192,9 @@ let savePagePermalinks : HttpHandler = fun next ctx -> task { let deletePage pgId : HttpHandler = fun next ctx -> task { let webLog = ctx.WebLog match! Data.Page.delete (PageId pgId) webLog.id ctx.Conn with - | true -> do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } + | true -> + do! PageListCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx } diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index a066abc..7d13df9 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -339,6 +339,7 @@ let all pageNbr : HttpHandler = fun next ctx -> task { let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx conn hash.Add ("page_title", "Posts") + hash.Add ("csrf", csrfToken ctx) return! viewForTheme "admin" "post-list" next ctx hash } diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index a3e04d6..043c7cd 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -1,6 +1,6 @@ 

    {{ page_title }}

    - +
    -- 2.45.1 From 7219b65f4f48268057ac39511d083447792089c3 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 23 May 2022 22:17:14 -0400 Subject: [PATCH 049/102] - Rework page edit layout - Add project template to bit-badger theme - Add code styling to bit-badger theme --- src/MyWebLog/themes/admin/page-edit.liquid | 30 ++++++++----------- src/MyWebLog/themes/admin/post-list.liquid | 4 +-- .../themes/bit-badger/project-page.liquid | 19 ++++++++++++ src/MyWebLog/wwwroot/themes/admin/admin.css | 3 ++ .../wwwroot/themes/bit-badger/style.css | 22 ++++++++++++++ 5 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 src/MyWebLog/themes/bit-badger/project-page.liquid diff --git a/src/MyWebLog/themes/admin/page-edit.liquid b/src/MyWebLog/themes/admin/page-edit.liquid index a5b7181..1baf201 100644 --- a/src/MyWebLog/themes/admin/page-edit.liquid +++ b/src/MyWebLog/themes/admin/page-edit.liquid @@ -11,7 +11,7 @@ value="{{ model.title }}">
    -
    +
    @@ -20,6 +20,18 @@ Manage Permalinks {% endif -%}
    +
    +     + + + + +
    +
    + +
    @@ -39,22 +51,6 @@
    -
    -
    -     - - - - -
    -
    -
    -
    - -
    -
    diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index 08d55aa..41c5296 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -1,6 +1,6 @@

    {{ page_title }}

    - Write a New Post + Write a New Post @@ -27,7 +27,7 @@ Edit - {%- capture post_del %}admin/post/{{ pg.id }}/delete{% endcapture -%} + {%- capture post_del %}admin/post/{{ post.id }}/delete{% endcapture -%} {%- capture post_del_link %}{{ post_del | relative_link }}{% endcapture -%} diff --git a/src/MyWebLog/themes/bit-badger/project-page.liquid b/src/MyWebLog/themes/bit-badger/project-page.liquid new file mode 100644 index 0000000..ac22fed --- /dev/null +++ b/src/MyWebLog/themes/bit-badger/project-page.liquid @@ -0,0 +1,19 @@ +
    + {%- assign parts = page.title | split: ' » ' -%} + {%- assign parts_count = parts | size -%} + {% if parts_count == 1 -%} +

    {{ page.title }}

    + {%- else -%} +

    {{ parts[0] }}
    {{ parts[1] }}

    + {%- endif %} + {{ page.text }} + {% if parts_count > 1 -%} + {%- assign project = parts[0] | downcase | replace: '\.', '-' -%} + {%- capture project_url %}open-source/{{ project }}/{% endcapture -%} + {% comment %}{{ project_url | relative_link }}{% endcomment %} +


    « {{ parts[0] }} Home

    + {%- endif %} + {% if logged_on -%} +

    Edit This Page

    + {%- endif %} +
    diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.css b/src/MyWebLog/wwwroot/themes/admin/admin.css index 354bd87..e17d067 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.css +++ b/src/MyWebLog/wwwroot/themes/admin/admin.css @@ -51,6 +51,9 @@ textarea { font-family: monospace; font-size: .8rem !important; } +#text { + min-height: 50vh; +} .no-wrap { white-space: nowrap; } diff --git a/src/MyWebLog/wwwroot/themes/bit-badger/style.css b/src/MyWebLog/wwwroot/themes/bit-badger/style.css index 1a83359..62f96c4 100644 --- a/src/MyWebLog/wwwroot/themes/bit-badger/style.css +++ b/src/MyWebLog/wwwroot/themes/bit-badger/style.css @@ -44,6 +44,28 @@ h2, h3 { p { margin: 1rem 0; } +code, pre { + font-family: "JetBrains Mono","SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; + font-size: 80%; +} +code { + background-color: rgba(0, 0, 0, .1); + padding: 0 .25rem; + white-space: pre; +} +pre { + background-color: rgba(0, 0, 0, .9); + color: rgba(255, 255, 255, .9); + padding: .5rem; + border-radius: .5rem; + overflow: auto; +} +pre > code { + background-color: unset; +} +div[style="color:#DADADA;background-color:#1E1E1E;"] { + background-color: unset !important; +} #content { margin: 0 1rem; } -- 2.45.1 From ff560d1d2f021695ce3ae2b991f037001025a874 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 24 May 2022 18:30:01 -0400 Subject: [PATCH 050/102] Tweak bit-badger project template --- src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/bit-badger/layout.liquid | 1 + .../themes/bit-badger/project-page.liquid | 38 +++++++++++++------ .../wwwroot/themes/bit-badger/style.css | 10 +++++ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 381b010..09c4c5b 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha11", + "Generator": "myWebLog 2.0-alpha12", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" diff --git a/src/MyWebLog/themes/bit-badger/layout.liquid b/src/MyWebLog/themes/bit-badger/layout.liquid index b06826e..d75e4fc 100644 --- a/src/MyWebLog/themes/bit-badger/layout.liquid +++ b/src/MyWebLog/themes/bit-badger/layout.liquid @@ -1,6 +1,7 @@ + {{ page_title }} » Bit Badger Solutions diff --git a/src/MyWebLog/themes/bit-badger/project-page.liquid b/src/MyWebLog/themes/bit-badger/project-page.liquid index ac22fed..020f7e5 100644 --- a/src/MyWebLog/themes/bit-badger/project-page.liquid +++ b/src/MyWebLog/themes/bit-badger/project-page.liquid @@ -1,19 +1,33 @@
    {%- assign parts = page.title | split: ' » ' -%} {%- assign parts_count = parts | size -%} - {% if parts_count == 1 -%} -

    {{ page.title }}

    - {%- else -%} -

    {{ parts[0] }}
    {{ parts[1] }}

    - {%- endif %} +

    + {% if parts_count == 1 -%} + {{ page.title }}
    + + + View on GitHub + + {% if logged_on %} • Edit Page{% endif %} + + {%- else -%} + + {{ parts[0] }} + {% if logged_on %} • Edit{% endif %} + +
    + {{ parts[1] }} + {%- endif %} +

    {{ page.text }} {% if parts_count > 1 -%} - {%- assign project = parts[0] | downcase | replace: '\.', '-' -%} - {%- capture project_url %}open-source/{{ project }}/{% endcapture -%} - {% comment %}{{ project_url | relative_link }}{% endcomment %} -


    « {{ parts[0] }} Home

    - {%- endif %} - {% if logged_on -%} -

    Edit This Page

    + {%- endif %}
    diff --git a/src/MyWebLog/wwwroot/themes/bit-badger/style.css b/src/MyWebLog/wwwroot/themes/bit-badger/style.css index 62f96c4..76ff447 100644 --- a/src/MyWebLog/wwwroot/themes/bit-badger/style.css +++ b/src/MyWebLog/wwwroot/themes/bit-badger/style.css @@ -27,6 +27,10 @@ h1 { margin: 1.4rem 0; font-size: 2rem; } +h1.project-title { + margin-top: 0; + line-height: 1.2; +} h2 { margin: 1.2rem 0; } @@ -266,6 +270,12 @@ blockquote { padding-left: 1rem; } /* Footer */ +.project-footer { + padding-top: 1rem; + display: flex; + flex-flow: row wrap; + justify-content: space-between; +} footer { display: flex; flex-flow: row wrap; -- 2.45.1 From b15fb78d5b9426bbbca4b5feee737b841d4eb709 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 25 May 2022 20:20:15 -0400 Subject: [PATCH 051/102] Tweak tech blog theme - Upgrade deps --- src/MyWebLog.Data/MyWebLog.Data.fsproj | 2 +- src/MyWebLog/MyWebLog.fsproj | 2 +- src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/tech-blog/index.liquid | 18 +++++++--------- src/MyWebLog/themes/tech-blog/layout.liquid | 1 + .../themes/tech-blog/single-post.liquid | 21 +++++++------------ .../wwwroot/themes/tech-blog/style.css | 8 +------ 7 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index 588f479..5a868b4 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -14,7 +14,7 @@ - + diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 0fa4905..5ad84b1 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -22,7 +22,7 @@ - + diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 09c4c5b..491cff4 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha12", + "Generator": "myWebLog 2.0-alpha13", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" diff --git a/src/MyWebLog/themes/tech-blog/index.liquid b/src/MyWebLog/themes/tech-blog/index.liquid index ebbde67..6f8ceb4 100644 --- a/src/MyWebLog/themes/tech-blog/index.liquid +++ b/src/MyWebLog/themes/tech-blog/index.liquid @@ -19,24 +19,22 @@
    {{ post.text }}
    {%- assign cat_count = post.category_ids | size -%} {%- if cat_count > 0 %} - + Categorized under - {%- for cat_id in post.category_ids %} + {% for cat_id in post.category_ids -%} {%- assign cat = categories | where: "id", cat_id | first -%} - - + {% unless forloop.last %}, {% endunless %} {%- endfor %}
    {%- endif %} {%- assign tag_count = post.tags | size -%} {%- if tag_count > 0 %} - + Tagged - {%- for tag in post.tags %} - - - + {% for tag in post.tags -%} + {% unless forloop.last %}, {% endunless %} {%- endfor %}
    {%- endif %} diff --git a/src/MyWebLog/themes/tech-blog/layout.liquid b/src/MyWebLog/themes/tech-blog/layout.liquid index c5a3669..db76556 100644 --- a/src/MyWebLog/themes/tech-blog/layout.liquid +++ b/src/MyWebLog/themes/tech-blog/layout.liquid @@ -18,6 +18,7 @@ href="{{ "feed.xml" | absolute_link }}"> {%- endif %} +
    -
    +
    {{ content }} -
    +
    + + + + + + + + + {% for feed in model.custom_feeds %} + + + + + + {% endfor %} + +
    SourcePathPodcast?
    + {{ feed.source }} + + {{ feed.path }}{% if feed.is_podcast %}Yes{% else %}No{% endif %}
    + {% else %} +

    No custom feeds defined

    + {% endif %} +
    -- 2.45.1 From 50179ffab966f4e7baf262365f7e4059c46d0b06 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 29 May 2022 12:12:57 -0400 Subject: [PATCH 056/102] WIP on RSS settings page - Move tag mappings to /settings URLs - Remove container wrap for table pages - Add notes for empty tables --- src/MyWebLog.Domain/SupportTypes.fs | 4 + src/MyWebLog/Handlers/Feed.fs | 10 +- src/MyWebLog/Handlers/Routes.fs | 24 ++-- src/MyWebLog/Program.fs | 8 +- .../themes/admin/category-list.liquid | 65 ++++++----- src/MyWebLog/themes/admin/layout.liquid | 2 +- src/MyWebLog/themes/admin/page-list.liquid | 53 +++++---- src/MyWebLog/themes/admin/post-list.liquid | 61 +++++----- src/MyWebLog/themes/admin/rss-settings.liquid | 105 +++++++++++------- src/MyWebLog/themes/admin/settings.liquid | 4 + .../themes/admin/tag-mapping-edit.liquid | 4 +- .../themes/admin/tag-mapping-list.liquid | 6 +- 12 files changed, 199 insertions(+), 147 deletions(-) diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index f0e226b..3922b8e 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -302,6 +302,9 @@ type RssOptions = /// Whether feeds are enabled for all tags tagEnabled : bool + /// A copyright string to be placed in all feeds + copyright : string option + /// Custom feeds for this web log customFeeds: CustomFeed list } @@ -316,6 +319,7 @@ module RssOptions = itemsInFeed = None categoryEnabled = true tagEnabled = true + copyright = None customFeeds = [] } diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index f5ee7a9..c9f6d3b 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -199,7 +199,6 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = "link", feedUrl ] |> List.fold (fun doc (name, value) -> addChild doc name "" value) (XmlDocument ())) - // TODO: is copyright required? rssFeed.ElementExtensions.Add ("summary", "itunes", podcast.summary) rssFeed.ElementExtensions.Add ("author", "itunes", podcast.displayedAuthor) podcast.subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", "itunes", sub)) @@ -238,10 +237,11 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg feed.Items <- posts |> Seq.ofList |> Seq.map toItem feed.Language <- "en" feed.Id <- webLog.urlBase + webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) + // TODO: adjust this link for non-root feeds feed.Links.Add (SyndicationLink (Uri $"{webLog.urlBase}/feed.xml", "self", "", "application/rss+xml", 0L)) - feed.AttributeExtensions.Add - (XmlQualifiedName ("content", "http://www.w3.org/2000/xmlns/"), "http://purl.org/rss/1.0/modules/content/") + addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" feed.ElementExtensions.Add ("link", "", webLog.urlBase) podcast |> Option.iter (addPodcast webLog feed) @@ -273,7 +273,9 @@ let editSettings : HttpHandler = fun next ctx -> task { // TODO: stopped here return! Hash.FromAnonymousObject - {| csrf = csrfToken ctx + {| csrf = csrfToken ctx + model = ctx.WebLog.rss + page_title = "RSS Settings" |} |> viewForTheme "admin" "rss-settings" next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index e629997..38aafcd 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -93,13 +93,13 @@ let router : HttpHandler = choose [ routef "/%s/edit" Post.edit routef "/%s/permalinks" Post.editPermalinks ]) - subRoute "/rss" (choose [ - route "/settings" >=> Feed.editSettings - ]) - route "/settings" >=> Admin.settings - subRoute "/tag-mapping" (choose [ - route "s" >=> Admin.tagMappings - routef "/%s/edit" Admin.editMapping + subRoute "/settings" (choose [ + route "" >=> Admin.settings + route "/rss" >=> Feed.editSettings + subRoute "/tag-mapping" (choose [ + route "s" >=> Admin.tagMappings + routef "/%s/edit" Admin.editMapping + ]) ]) route "/user/edit" >=> User.edit ] @@ -118,10 +118,12 @@ let router : HttpHandler = choose [ route "/permalinks" >=> Post.savePermalinks routef "/%s/delete" Post.delete ]) - route "/settings" >=> Admin.saveSettings - subRoute "/tag-mapping" (choose [ - route "/save" >=> Admin.saveMapping - routef "/%s/delete" Admin.deleteMapping + subRoute "/settings" (choose [ + route "" >=> Admin.saveSettings + subRoute "/tag-mapping" (choose [ + route "/save" >=> Admin.saveMapping + routef "/%s/delete" Admin.deleteMapping + ]) ]) route "/user/save" >=> User.save ] diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index e58ff1c..2ae829a 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -202,7 +202,7 @@ let main args = opts.TableName <- "Session" opts.Connection <- conn) let _ = builder.Services.AddSession(fun opts -> - opts.IdleTimeout <- TimeSpan.FromMinutes 30 + opts.IdleTimeout <- TimeSpan.FromMinutes 60 opts.Cookie.HttpOnly <- true opts.Cookie.IsEssential <- true) @@ -218,15 +218,15 @@ let main args = Template.RegisterTag "user_links" [ // Domain types - typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof; typeof; typeof // View models typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof typeof; typeof // Framework types - typeof; typeof; typeof; typeof - typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof ] |> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |])) diff --git a/src/MyWebLog/themes/admin/category-list.liquid b/src/MyWebLog/themes/admin/category-list.liquid index e98e9f0..71d2c9f 100644 --- a/src/MyWebLog/themes/admin/category-list.liquid +++ b/src/MyWebLog/themes/admin/category-list.liquid @@ -1,5 +1,5 @@ 

    {{ page_title }}

    -
    +
    Add a New Category @@ -9,36 +9,43 @@ - {% for cat in categories -%} - - + - + {{ cat.name }}
    + + {%- if cat.post_count > 0 %} + + View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} + + + {%- endif %} + {%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%} + Edit + + {%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%} + {%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%} + + Delete + + + + + + {%- endfor %} + {%- else -%} + + - {%- endfor %} + {%- endif %}
    - {%- if cat.parent_names %} - {% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %} - {%- endif %} - {{ cat.name }}
    - - {%- if cat.post_count > 0 %} - - View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} - - + {%- assign cat_count = categories | size -%} + {% if cat_count > 0 %} + {% for cat in categories -%} +
    + {%- if cat.parent_names %} + {% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %} {%- endif %} - {%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%} - Edit - - {%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%} - {%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%} - - Delete - - - - {%- if cat.description %}{{ cat.description.value }}{% else %}none{% endif %} - + {%- if cat.description %}{{ cat.description.value }}{% else %}none{% endif %} +
    This web log has no categores defined
    diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index f03e8f0..55efeba 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -24,7 +24,7 @@ {{ "admin/pages" | nav_link: "Pages" }} {{ "admin/posts" | nav_link: "Posts" }} {{ "admin/categories" | nav_link: "Categories" }} - {{ "admin/tag-mappings" | nav_link: "Tag Mappings" }} + {{ "admin/settings" | nav_link: "Settings" }} {%- endif %}
    No custom feeds defined
    diff --git a/src/MyWebLog/themes/admin/settings.liquid b/src/MyWebLog/themes/admin/settings.liquid index df2e309..c3234b9 100644 --- a/src/MyWebLog/themes/admin/settings.liquid +++ b/src/MyWebLog/themes/admin/settings.liquid @@ -1,4 +1,8 @@ 

    {{ web_log.name }} Settings

    +

    + Other Settings: Tag Mappings • + RSS Settings +

    diff --git a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid index 93724e8..9b72eb3 100644 --- a/src/MyWebLog/themes/admin/tag-mapping-edit.liquid +++ b/src/MyWebLog/themes/admin/tag-mapping-edit.liquid @@ -1,12 +1,12 @@ 

    {{ page_title }}

    - +
    diff --git a/src/MyWebLog/themes/admin/tag-mapping-list.liquid b/src/MyWebLog/themes/admin/tag-mapping-list.liquid index 856e864..48863dc 100644 --- a/src/MyWebLog/themes/admin/tag-mapping-list.liquid +++ b/src/MyWebLog/themes/admin/tag-mapping-list.liquid @@ -1,6 +1,6 @@ 

    {{ page_title }}

    - + Add a New Tag Mapping @@ -17,10 +17,10 @@ - + - {%- assign feed_count = model.custom_feeds | size -%} + {%- assign feed_count = custom_feeds | size -%} {% if feed_count > 0 %} - {% for feed in model.custom_feeds %} + {% for feed in custom_feeds %} @@ -93,4 +105,7 @@ {% endif %}
    {{ map.tag }}
    - {%- capture map_edit %}admin/tag-mapping/{{ map_id }}/edit{% endcapture -%} + {%- capture map_edit %}admin/settings/tag-mapping/{{ map_id }}/edit{% endcapture -%} Edit - {%- capture map_del %}admin/tag-mapping/{{ map_id }}/delete{% endcapture -%} + {%- capture map_del %}admin/settings/tag-mapping/{{ map_id }}/delete{% endcapture -%} {%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%} -- 2.45.1 From 46d6c4f5f1e3d2759ac0bdc572cc03e489f978d8 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 29 May 2022 14:13:37 -0400 Subject: [PATCH 057/102] Save RSS settings - Add route for custom feed deletion - Add ID for custom feed --- src/MyWebLog.Data/Converters.fs | 8 ++ src/MyWebLog.Data/Data.fs | 20 +++- src/MyWebLog.Domain/SupportTypes.fs | 21 +++- src/MyWebLog.Domain/ViewModels.fs | 74 ++++++++++++++ src/MyWebLog/Handlers/Feed.fs | 98 ++++++++++++++----- src/MyWebLog/Handlers/Routes.fs | 6 +- src/MyWebLog/Program.fs | 8 +- src/MyWebLog/themes/admin/rss-settings.liquid | 27 +++-- src/MyWebLog/wwwroot/themes/admin/admin.js | 9 ++ 9 files changed, 235 insertions(+), 36 deletions(-) diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index 49a08c0..13f8673 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -20,6 +20,13 @@ type CommentIdConverter () = override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) = (string >> CommentId) reader.Value +type CustomFeedIdConverter () = + inherit JsonConverter () + override _.WriteJson (writer : JsonWriter, value : CustomFeedId, _ : JsonSerializer) = + writer.WriteValue (CustomFeedId.toString value) + override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedId, _ : bool, _ : JsonSerializer) = + (string >> CustomFeedId) reader.Value + type CustomFeedSourceConverter () = inherit JsonConverter () override _.WriteJson (writer : JsonWriter, value : CustomFeedSource, _ : JsonSerializer) = @@ -91,6 +98,7 @@ let all () : JsonConverter seq = // Our converters CategoryIdConverter () CommentIdConverter () + CustomFeedIdConverter () CustomFeedSourceConverter () ExplicitRatingConverter () MarkupTextConverter () diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 06b0087..6139d8a 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -731,12 +731,28 @@ module WebLog = resultOption; withRetryOptionDefault } - /// Update web log settings (updates all values) + /// Update RSS options for a web log + let updateRssOptions (webLog : WebLog) = + rethink { + withTable Table.WebLog + get webLog.id + update [ "rss", webLog.rss :> obj ] + write; withRetryDefault; ignoreResult + } + + /// Update web log settings (from settings page) let updateSettings (webLog : WebLog) = rethink { withTable Table.WebLog get webLog.id - replace webLog + update [ + "name", webLog.name :> obj + "subtitle", webLog.subtitle + "defaultPage", webLog.defaultPage + "postsPerPage", webLog.postsPerPage + "timeZone", webLog.timeZone + "themePath", webLog.themePath + ] write; withRetryDefault; ignoreResult } diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 3922b8e..6cdbd9f 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -187,6 +187,22 @@ module PostId = let create () = PostId (newId ()) +/// An identifier for a custom feed +type CustomFeedId = CustomFeedId of string + +/// Functions to support custom feed IDs +module CustomFeedId = + + /// An empty custom feed ID + let empty = CustomFeedId "" + + /// Convert a custom feed ID to a string + let toString = function CustomFeedId pi -> pi + + /// Create a new custom feed ID + let create () = CustomFeedId (newId ()) + + /// The source for a custom feed type CustomFeedSource = /// A feed based on a particular category @@ -274,7 +290,10 @@ type PodcastOptions = /// A custom feed type CustomFeed = - { /// The source for the custom feed + { /// The ID of the custom feed + id : CustomFeedId + + /// The source for the custom feed source : CustomFeedSource /// The path for the custom feed diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 022c46f..3bf3e73 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -26,6 +26,34 @@ type DisplayCategory = } +/// A display version of a custom feed definition +type DisplayCustomFeed = + { /// The ID of the custom feed + id : string + + /// The source of the custom feed + source : string + + /// The relative path at which the custom feed is served + path : string + + /// Whether this custom feed is for a podcast + isPodcast : bool + } + + /// Create a display version from a custom feed + static member fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = + let source = + match feed.source with + | Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.id = catId)).name}" + | Tag tag -> $"Tag: {tag}" + { id = CustomFeedId.toString feed.id + source = source + path = Permalink.toString feed.path + isPodcast = Option.isSome feed.podcast + } + + /// Details about a page used to display page lists [] type DisplayPage = @@ -255,6 +283,50 @@ type EditPostModel = } +/// View model to edit RSS settings +[] +type EditRssModel = + { /// Whether the site feed of posts is enabled + feedEnabled : bool + + /// The name of the file generated for the site feed + feedName : string + + /// Override the "posts per page" setting for the site feed + itemsInFeed : int + + /// Whether feeds are enabled for all categories + categoryEnabled : bool + + /// Whether feeds are enabled for all tags + tagEnabled : bool + + /// A copyright string to be placed in all feeds + copyright : string + } + + /// Create an edit model from a set of RSS options + static member fromRssOptions (rss : RssOptions) = + { feedEnabled = rss.feedEnabled + feedName = rss.feedName + itemsInFeed = defaultArg rss.itemsInFeed 0 + categoryEnabled = rss.categoryEnabled + tagEnabled = rss.tagEnabled + copyright = defaultArg rss.copyright "" + } + + /// Update RSS options from values in this mode + member this.updateOptions (rss : RssOptions) = + { rss with + feedEnabled = this.feedEnabled + feedName = this.feedName + itemsInFeed = if this.itemsInFeed = 0 then None else Some this.itemsInFeed + categoryEnabled = this.categoryEnabled + tagEnabled = this.tagEnabled + copyright = if this.copyright.Trim () = "" then None else Some (this.copyright.Trim ()) + } + + /// View model to edit a tag mapping [] type EditTagMapModel = @@ -305,6 +377,8 @@ type EditUserModel = newPassword = "" newPasswordConfirm = "" } + + /// The model to use to allow a user to log on [] type LogOnModel = diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index c9f6d3b..6334428 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -15,10 +15,10 @@ open MyWebLog.ViewModels /// The type of feed to generate type FeedType = - | StandardFeed - | CategoryFeed of CategoryId - | TagFeed of string - | Custom of CustomFeed + | StandardFeed of string + | CategoryFeed of CategoryId * string + | TagFeed of string * string + | Custom of CustomFeed * string /// Derive the type of RSS feed requested let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = @@ -27,21 +27,21 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = let postCount = defaultArg webLog.rss.itemsInFeed webLog.postsPerPage // Standard feed match webLog.rss.feedEnabled && feedPath = name with - | true -> Some (StandardFeed, postCount) + | true -> Some (StandardFeed feedPath, postCount) | false -> // Category feed match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = feedPath.Replace (name, "")) with - | Some cat -> Some (CategoryFeed (CategoryId cat.id), postCount) + | Some cat -> Some (CategoryFeed (CategoryId cat.id, feedPath), postCount) | None -> // Tag feed match feedPath.StartsWith "/tag/" with - | true -> Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, "")), postCount) + | true -> Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, ""), feedPath), postCount) | false -> // Custom feed match webLog.rss.customFeeds |> List.tryFind (fun it -> (Permalink.toString it.path).EndsWith feedPath) with | Some feed -> - Some (Custom feed, + Some (Custom (feed, feedPath), feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) | None -> // No feed @@ -50,10 +50,10 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = /// Determine the function to retrieve posts for the given feed let private getFeedPosts (webLog : WebLog) feedType = match feedType with - | StandardFeed -> Data.Post.findPageOfPublishedPosts webLog.id 1 - | CategoryFeed catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 - | TagFeed tag -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 - | Custom feed -> + | StandardFeed _ -> Data.Post.findPageOfPublishedPosts webLog.id 1 + | CategoryFeed (catId, _) -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 + | TagFeed (tag, _) -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 + | Custom (feed, _) -> match feed.source with | Category catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 | Tag tag -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 @@ -213,6 +213,16 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = rssFeed.ElementExtensions.Add ("explicit", "itunes", ExplicitRating.toString podcast.explicit) rssFeed.ElementExtensions.Add ("subscribe", "rawvoice", feedUrl) +/// Get the feed's self reference and non-feed link +let private selfAndLink webLog feedType = + match feedType with + | StandardFeed path -> path + | CategoryFeed (_, path) -> path + | TagFeed (_, path) -> path + | Custom (_, path) -> path + |> function + | path -> Permalink path, Permalink (path.Replace ($"/{webLog.rss.feedName}", "")) + /// Create a feed with a known non-zero-length list of posts let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask { let webLog = ctx.WebLog @@ -220,7 +230,7 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg let! authors = Post.getAuthors webLog posts conn let! tagMaps = Post.getTagMappings webLog posts conn let cats = CategoryCache.get ctx - let podcast = match feedType with Custom feed when Option.isSome feed.podcast -> Some feed | _ -> None + let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None let toItem post = let item = toFeedItem webLog authors cats tagMaps post @@ -229,7 +239,9 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg addEpisode webLog feed post item | _ -> item - let feed = SyndicationFeed () + let feed = SyndicationFeed () + addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" + feed.Title <- TextSyndicationContent webLog.name feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn @@ -239,10 +251,10 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg feed.Id <- webLog.urlBase webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) - // TODO: adjust this link for non-root feeds - feed.Links.Add (SyndicationLink (Uri $"{webLog.urlBase}/feed.xml", "self", "", "application/rss+xml", 0L)) - addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" - feed.ElementExtensions.Add ("link", "", webLog.urlBase) + let self, link = selfAndLink webLog feedType + feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L)) + feed.ElementExtensions.Add ("link", "", WebLog.absoluteUrl webLog link) + podcast |> Option.iter (addPodcast webLog feed) use mem = new MemoryStream () @@ -270,12 +282,54 @@ open DotLiquid // GET: /admin/rss/settings let editSettings : HttpHandler = fun next ctx -> task { - // TODO: stopped here + let webLog = ctx.WebLog + let feeds = + webLog.rss.customFeeds + |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) + |> Array.ofList return! Hash.FromAnonymousObject - {| csrf = csrfToken ctx - model = ctx.WebLog.rss - page_title = "RSS Settings" + {| csrf = csrfToken ctx + page_title = "RSS Settings" + model = EditRssModel.fromRssOptions webLog.rss + custom_feeds = feeds |} |> viewForTheme "admin" "rss-settings" next ctx } + +// POST: /admin/rss/settings +let saveSettings : HttpHandler = fun next ctx -> task { + let conn = ctx.Conn + let! model = ctx.BindFormAsync () + match! Data.WebLog.findById ctx.WebLog.id conn with + | Some webLog -> + let webLog = { webLog with rss = model.updateOptions webLog.rss } + do! Data.WebLog.updateRssOptions webLog conn + WebLogCache.set webLog + do! addMessage ctx { UserMessage.success with message = "RSS settings updated successfully" } + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/rss/{id}/delete +let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task { + let conn = ctx.Conn + match! Data.WebLog.findById ctx.WebLog.id conn with + | Some webLog -> + let customId = CustomFeedId feedId + if webLog.rss.customFeeds |> List.exists (fun f -> f.id = customId) then + let webLog = { + webLog with + rss = { + webLog.rss with + customFeeds = webLog.rss.customFeeds |> List.filter (fun f -> f.id <> customId) + } + } + do! Data.WebLog.updateRssOptions webLog conn + WebLogCache.set webLog + do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" } + else + do! addMessage ctx { UserMessage.warning with message = "Post not found; nothing deleted" } + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx + | None -> return! Error.notFound next ctx +} diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 38aafcd..17f6d01 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -119,7 +119,11 @@ let router : HttpHandler = choose [ routef "/%s/delete" Post.delete ]) subRoute "/settings" (choose [ - route "" >=> Admin.saveSettings + route "" >=> Admin.saveSettings + subRoute "/rss" (choose [ + route "" >=> Feed.saveSettings + routef "/%s/delete" Feed.deleteCustomFeed + ]) subRoute "/tag-mapping" (choose [ route "/save" >=> Admin.saveMapping routef "/%s/delete" Admin.deleteMapping diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 2ae829a..2e7da5e 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -220,10 +220,10 @@ let main args = [ // Domain types typeof; typeof; typeof; typeof; typeof; typeof // View models - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof // Framework types typeof; typeof; typeof; typeof typeof; typeof; typeof diff --git a/src/MyWebLog/themes/admin/rss-settings.liquid b/src/MyWebLog/themes/admin/rss-settings.liquid index 89ebee2..9ba2fad 100644 --- a/src/MyWebLog/themes/admin/rss-settings.liquid +++ b/src/MyWebLog/themes/admin/rss-settings.liquid @@ -45,7 +45,7 @@
    + value="{{ model.copyright }}"> Can be a @@ -69,18 +69,30 @@
    SourcePathRelative Path Podcast?
    - {{ feed.source }} - + {{ feed.source }}
    + + View Feed + + {%- capture feed_edit %}admin/rss/{{ feed.id }}/edit{% endcapture -%} + Edit + + {%- capture feed_del %}admin/rss/{{ feed.id }}/delete{% endcapture -%} + {%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%} + + Delete + +
    {{ feed.path }} {% if feed.is_podcast %}Yes{% else %}No{% endif %}
    + + +
    diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index e9dafaf..b6bd865 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -178,6 +178,15 @@ return this.deleteItem(`category "${name}"`, url) }, + /** + * Confirm and delete a custom RSS feed + * @param source The source for the feed to be deleted + * @param url The URL to which the form should be posted + */ + deleteCustomFeed(source, url) { + return this.deleteItem(`custom RSS feed based on ${source}`, url) + }, + /** * Confirm and delete a page * @param title The title of the page to be deleted -- 2.45.1 From 7757bd8394f8454e12959caa5670e635371a9f6c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 29 May 2022 18:18:46 -0400 Subject: [PATCH 058/102] WIP on custom feed edit page --- src/MyWebLog.Domain/SupportTypes.fs | 16 +- src/MyWebLog.Domain/ViewModels.fs | 104 ++++++++++ src/MyWebLog/Handlers/Feed.fs | 47 +++-- src/MyWebLog/Handlers/Routes.fs | 6 +- src/MyWebLog/Program.fs | 9 +- .../themes/admin/custom-feed-edit.liquid | 186 ++++++++++++++++++ src/MyWebLog/themes/admin/rss-settings.liquid | 4 +- src/MyWebLog/wwwroot/themes/admin/admin.js | 25 +++ 8 files changed, 379 insertions(+), 18 deletions(-) create mode 100644 src/MyWebLog/themes/admin/custom-feed-edit.liquid diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 6cdbd9f..9de2868 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -278,13 +278,16 @@ type PodcastOptions = iTunesCategory : string /// A further refinement of the categorization of this podcast (iTunes field / values) - iTunesSubcategory : string + iTunesSubcategory : string option /// The explictness rating (iTunes field) explicit : ExplicitRating /// The default media type for files in this podcast defaultMediaType : string option + + /// The base URL for relative URL media files for this podcast (optional; defaults to web log base) + mediaBaseUrl : string option } @@ -303,6 +306,17 @@ type CustomFeed = podcast : PodcastOptions option } +/// Functions to support custom feeds +module CustomFeed = + + /// An empty custom feed + let empty = + { id = CustomFeedId "" + source = Category (CategoryId "") + path = Permalink "" + podcast = None + } + /// Really Simple Syndication (RSS) options for this web log type RssOptions = diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 3bf3e73..0cde291 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -164,6 +164,110 @@ type EditCategoryModel = } +/// View model to edit a custom RSS feed +[] +type EditCustomFeedModel = + { /// The ID of the feed being editing + id : string + + /// The type of source for this feed ("category" or "tag") + sourceType : string + + /// The category ID or tag on which this feed is based + sourceValue : string + + /// The relative path at which this feed is served + path : string + + /// Whether this feed defines a podcast + isPodcast : bool + + /// The title of the podcast + title : string + + /// A subtitle for the podcast + subtitle : string + + /// The number of items in the podcast feed + itemsInFeed : int + + /// A summary of the podcast (iTunes field) + summary : string + + /// The display name of the podcast author (iTunes field) + displayedAuthor : string + + /// The e-mail address of the user who registered the podcast at iTunes + email : string + + /// The link to the image for the podcast + imageUrl : string + + /// The category from iTunes under which this podcast is categorized + itunesCategory : string + + /// A further refinement of the categorization of this podcast (iTunes field / values) + itunesSubcategory : string + + /// The explictness rating (iTunes field) + explicit : string + + /// The default media type for files in this podcast + defaultMediaType : string + + /// The base URL for relative URL media files for this podcast (optional; defaults to web log base) + mediaBaseUrl : string + } + + /// An empty custom feed model + static member empty = + { id = "" + sourceType = "category" + sourceValue = "" + path = "" + isPodcast = false + title = "" + subtitle = "" + itemsInFeed = 25 + summary = "" + displayedAuthor = "" + email = "" + imageUrl = "" + itunesCategory = "" + itunesSubcategory = "" + explicit = "no" + defaultMediaType = "audio/mpeg" + mediaBaseUrl = "" + } + + /// Create a model from a custom feed + static member fromFeed (feed : CustomFeed) = + let rss = + { EditCustomFeedModel.empty with + id = CustomFeedId.toString feed.id + sourceType = match feed.source with Category _ -> "category" | Tag _ -> "tag" + sourceValue = match feed.source with Category (CategoryId catId) -> catId | Tag tag -> tag + path = Permalink.toString feed.path + } + match feed.podcast with + | Some p -> + { rss with + isPodcast = true + title = p.title + subtitle = defaultArg p.subtitle "" + itemsInFeed = p.itemsInFeed + summary = p.summary + displayedAuthor = p.displayedAuthor + email = p.email + itunesCategory = p.iTunesCategory + itunesSubcategory = defaultArg p.iTunesSubcategory "" + explicit = ExplicitRating.toString p.explicit + defaultMediaType = defaultArg p.defaultMediaType "" + mediaBaseUrl = defaultArg p.mediaBaseUrl "" + } + | None -> rss + + /// View model to edit a page [] type EditPageModel = diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 6334428..03a87e6 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -180,9 +180,11 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = let categoryXml = XmlDocument () let catElt = categoryXml.CreateElement ("itunes", "category", "") catElt.SetAttribute ("text", podcast.iTunesCategory) - let subCat = categoryXml.CreateElement ("itunes", "category", "") - subCat.SetAttribute ("text", podcast.iTunesSubcategory) - catElt.AppendChild subCat |> ignore + podcast.iTunesSubcategory + |> Option.iter (fun subCat -> + let subCatElt = categoryXml.CreateElement ("itunes", "category", "") + subCatElt.SetAttribute ("text", subCat) + catElt.AppendChild subCatElt |> ignore) categoryXml.AppendChild catElt |> ignore [ "dc", "http://purl.org/dc/elements/1.1/" @@ -287,13 +289,12 @@ let editSettings : HttpHandler = fun next ctx -> task { webLog.rss.customFeeds |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) |> Array.ofList - return! - Hash.FromAnonymousObject - {| csrf = csrfToken ctx - page_title = "RSS Settings" - model = EditRssModel.fromRssOptions webLog.rss - custom_feeds = feeds - |} + return! Hash.FromAnonymousObject + {| csrf = csrfToken ctx + page_title = "RSS Settings" + model = EditRssModel.fromRssOptions webLog.rss + custom_feeds = feeds + |} |> viewForTheme "admin" "rss-settings" next ctx } @@ -311,6 +312,30 @@ let saveSettings : HttpHandler = fun next ctx -> task { | None -> return! Error.notFound next ctx } +// GET: /admin/rss/{id}/edit +let editCustomFeed feedId : HttpHandler = fun next ctx -> task { + let customFeed = + match feedId with + | "new" -> Some CustomFeed.empty + | _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId) + match customFeed with + | Some f -> + return! Hash.FromAnonymousObject + {| csrf = csrfToken ctx + page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" + model = EditCustomFeedModel.fromFeed f + categories = CategoryCache.get ctx + |} + |> viewForTheme "admin" "custom-feed-edit" next ctx + | None -> return! Error.notFound next ctx +} + +// POST: /admin/rss/save +let saveCustomFeed : HttpHandler = fun next ctx -> task { + // TODO: stub + return! Error.notFound next ctx +} + // POST /admin/rss/{id}/delete let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task { let conn = ctx.Conn @@ -329,7 +354,7 @@ let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task { WebLogCache.set webLog do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" } else - do! addMessage ctx { UserMessage.warning with message = "Post not found; nothing deleted" } + do! addMessage ctx { UserMessage.warning with message = "Custom feed not found; no action taken" } return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 17f6d01..95c10fb 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -95,7 +95,10 @@ let router : HttpHandler = choose [ ]) subRoute "/settings" (choose [ route "" >=> Admin.settings - route "/rss" >=> Feed.editSettings + subRoute "/rss" (choose [ + route "" >=> Feed.editSettings + routef "/%s/edit" Feed.editCustomFeed + ]) subRoute "/tag-mapping" (choose [ route "s" >=> Admin.tagMappings routef "/%s/edit" Admin.editMapping @@ -122,6 +125,7 @@ let router : HttpHandler = choose [ route "" >=> Admin.saveSettings subRoute "/rss" (choose [ route "" >=> Feed.saveSettings + route "/save" >=> Feed.saveCustomFeed routef "/%s/delete" Feed.deleteCustomFeed ]) subRoute "/tag-mapping" (choose [ diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 2e7da5e..189931e 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -220,10 +220,11 @@ let main args = [ // Domain types typeof; typeof; typeof; typeof; typeof; typeof // View models - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof // Framework types typeof; typeof; typeof; typeof typeof; typeof; typeof diff --git a/src/MyWebLog/themes/admin/custom-feed-edit.liquid b/src/MyWebLog/themes/admin/custom-feed-edit.liquid new file mode 100644 index 0000000..e74794a --- /dev/null +++ b/src/MyWebLog/themes/admin/custom-feed-edit.liquid @@ -0,0 +1,186 @@ +

    {{ page_title }}

    +
    +
    + + {%- assign is_cat = model.source_type == "category" -%} +
    +
    +
    +
    + Feed Source +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + Appended to {{ web_log.url_base }}/ +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + Podcast Settings +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + + Displayed in podcast directories +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + + For iTunes, must match registered e-mail +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + + iTunes Category / Subcategory List + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + + Optional; blank for no default +
    +
    +
    +
    + + + Optional; prepended to episode media file if present +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/src/MyWebLog/themes/admin/rss-settings.liquid b/src/MyWebLog/themes/admin/rss-settings.liquid index 9ba2fad..41b2ec4 100644 --- a/src/MyWebLog/themes/admin/rss-settings.liquid +++ b/src/MyWebLog/themes/admin/rss-settings.liquid @@ -64,7 +64,9 @@

    Custom Feeds

    - Add a New Custom Feed + + Add a New Custom Feed + diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index b6bd865..20c5bef 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -139,6 +139,31 @@ this.nextPermalink++ }, + /** + * Check to enable or disable podcast fields + */ + checkPodcast() { + document.getElementById("podcastFields").disabled = !document.getElementById("isPodcast").checked + }, + + /** + * Toggle the source of a custom RSS feed + * @param source The source that was selected + */ + customFeedBy(source) { + const categoryInput = document.getElementById("sourceValueCat") + const tagInput = document.getElementById("sourceValueTag") + if (source === "category") { + tagInput.value = "" + tagInput.disabled = true + categoryInput.disabled = false + } else { + categoryInput.selectedIndex = -1 + categoryInput.disabled = true + tagInput.disabled = false + } + }, + /** * Remove a metadata item * @param idx The index of the metadata item to remove -- 2.45.1 From f99623d1cb85f7f3d604a07a7349f136f99336ac Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 29 May 2022 22:44:51 -0400 Subject: [PATCH 059/102] Add / update / delete custom feed complete --- src/MyWebLog.Domain/ViewModels.fs | 38 ++++- src/MyWebLog/Handlers/Feed.fs | 26 ++- .../themes/admin/custom-feed-edit.liquid | 150 ++++++++++-------- src/MyWebLog/themes/admin/rss-settings.liquid | 4 +- 4 files changed, 147 insertions(+), 71 deletions(-) diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 0cde291..d72d807 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -3,6 +3,15 @@ open System open MyWebLog +/// Helper functions for view models +[] +module private Helpers = + + /// Create a string option if a string is blank + let noneIfBlank (it : string) = + match it.Trim () with "" -> None | trimmed -> Some trimmed + + /// Details about a category, used to display category lists [] type DisplayCategory = @@ -259,6 +268,7 @@ type EditCustomFeedModel = summary = p.summary displayedAuthor = p.displayedAuthor email = p.email + imageUrl = Permalink.toString p.imageUrl itunesCategory = p.iTunesCategory itunesSubcategory = defaultArg p.iTunesSubcategory "" explicit = ExplicitRating.toString p.explicit @@ -266,7 +276,31 @@ type EditCustomFeedModel = mediaBaseUrl = defaultArg p.mediaBaseUrl "" } | None -> rss - + + /// Update a feed with values from this model + member this.updateFeed (feed : CustomFeed) = + { feed with + source = if this.sourceType = "tag" then Tag this.sourceValue else Category (CategoryId this.sourceValue) + path = Permalink this.path + podcast = + if this.isPodcast then + Some { + title = this.title + subtitle = noneIfBlank this.subtitle + itemsInFeed = this.itemsInFeed + summary = this.summary + displayedAuthor = this.displayedAuthor + email = this.email + imageUrl = Permalink this.imageUrl + iTunesCategory = this.itunesCategory + iTunesSubcategory = noneIfBlank this.itunesSubcategory + explicit = ExplicitRating.parse this.explicit + defaultMediaType = noneIfBlank this.defaultMediaType + mediaBaseUrl = noneIfBlank this.mediaBaseUrl + } + else + None + } /// View model to edit a page [] @@ -427,7 +461,7 @@ type EditRssModel = itemsInFeed = if this.itemsInFeed = 0 then None else Some this.itemsInFeed categoryEnabled = this.categoryEnabled tagEnabled = this.tagEnabled - copyright = if this.copyright.Trim () = "" then None else Some (this.copyright.Trim ()) + copyright = noneIfBlank this.copyright } diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 03a87e6..dc3df97 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -316,7 +316,7 @@ let saveSettings : HttpHandler = fun next ctx -> task { let editCustomFeed feedId : HttpHandler = fun next ctx -> task { let customFeed = match feedId with - | "new" -> Some CustomFeed.empty + | "new" -> Some { CustomFeed.empty with id = CustomFeedId "new" } | _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId) match customFeed with | Some f -> @@ -332,8 +332,28 @@ let editCustomFeed feedId : HttpHandler = fun next ctx -> task { // POST: /admin/rss/save let saveCustomFeed : HttpHandler = fun next ctx -> task { - // TODO: stub - return! Error.notFound next ctx + let conn = ctx.Conn + match! Data.WebLog.findById ctx.WebLog.id conn with + | Some webLog -> + let! model = ctx.BindFormAsync () + let theFeed = + match model.id with + | "new" -> Some { CustomFeed.empty with id = CustomFeedId.create () } + | _ -> webLog.rss.customFeeds |> List.tryFind (fun it -> CustomFeedId.toString it.id = model.id) + match theFeed with + | Some feed -> + let feeds = model.updateFeed feed :: (webLog.rss.customFeeds |> List.filter (fun it -> it.id <> feed.id)) + let webLog = { webLog with rss = { webLog.rss with customFeeds = feeds } } + do! Data.WebLog.updateRssOptions webLog conn + WebLogCache.set webLog + do! addMessage ctx { + UserMessage.success with + message = $"""Successfully {if model.id = "new" then "add" else "sav"}ed custom feed""" + } + let nextUrl = $"admin/settings/rss/{CustomFeedId.toString feed.id}/edit" + return! redirectToGet (WebLog.relativeUrl webLog (Permalink nextUrl)) next ctx + | None -> return! Error.notFound next ctx + | None -> return! Error.notFound next ctx } // POST /admin/rss/{id}/delete diff --git a/src/MyWebLog/themes/admin/custom-feed-edit.liquid b/src/MyWebLog/themes/admin/custom-feed-edit.liquid index e74794a..c20c29f 100644 --- a/src/MyWebLog/themes/admin/custom-feed-edit.liquid +++ b/src/MyWebLog/themes/admin/custom-feed-edit.liquid @@ -2,28 +2,59 @@
    - {%- assign is_cat = model.source_type == "category" -%} + + {%- assign typ = model.source_type -%}
    - +
    +
    +
    + Identification +
    +
    +
    + + + Appended to {{ web_log.url_base }}/ +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    Feed Source
    + {%- unless typ == "tag" %} checked="checked" {% endunless -%} + onclick="Admin.customFeedBy('category')">
    -
    +
    + {%- if typ == "tag" %} checked="checked"{% endif %} onclick="Admin.customFeedBy('tag')">
    -
    +
    + {%- unless typ == "tag" %} disabled="disabled"{% endunless %} required + {%- if typ == "tag" %} value="{{ model.source_value }}"{% endif %}>
    @@ -50,70 +81,26 @@
    -
    -
    -
    - - - Appended to {{ web_log.url_base }}/ -
    -
    -
    -
    - - -
    -
    -
    Podcast Settings
    -
    +
    -
    +
    -
    -
    -
    -
    - - - Displayed in podcast directories -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - - - For iTunes, must match registered e-mail -
    -
    -
    +
    @@ -122,7 +109,7 @@
    -
    +
    @@ -135,16 +122,14 @@
    -
    +
    -
    -
    -
    +
    + +
    +
    +
    +
    + + + For iTunes, must match registered e-mail +
    +
    +
    @@ -164,7 +166,27 @@ Optional; blank for no default
    -
    +
    +
    + + + Relative URL will be appended to {{ web_log.url_base }}/ +
    +
    +
    +
    +
    +
    + + + Displayed in podcast directories +
    +
    +
    +
    +
    diff --git a/src/MyWebLog/themes/admin/rss-settings.liquid b/src/MyWebLog/themes/admin/rss-settings.liquid index 41b2ec4..6bb7a99 100644 --- a/src/MyWebLog/themes/admin/rss-settings.liquid +++ b/src/MyWebLog/themes/admin/rss-settings.liquid @@ -85,10 +85,10 @@ View Feed - {%- capture feed_edit %}admin/rss/{{ feed.id }}/edit{% endcapture -%} + {%- capture feed_edit %}admin/settings/rss/{{ feed.id }}/edit{% endcapture -%} Edit - {%- capture feed_del %}admin/rss/{{ feed.id }}/delete{% endcapture -%} + {%- capture feed_del %}admin/settings/rss/{{ feed.id }}/delete{% endcapture -%} {%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%} -- 2.45.1 From d1d384812e9c116b97533d9ead2ede3fc0e6750e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 30 May 2022 12:13:04 -0400 Subject: [PATCH 060/102] Fix podcast identifying data Still need to test episode data, but it is thought-fixed as well --- src/MyWebLog/Handlers/Feed.fs | 178 ++++++++++++++++++------------- src/MyWebLog/Handlers/Helpers.fs | 9 +- src/MyWebLog/Handlers/Routes.fs | 26 +++-- 3 files changed, 132 insertions(+), 81 deletions(-) diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index dc3df97..68ab950 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -23,28 +23,37 @@ type FeedType = /// Derive the type of RSS feed requested let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = let webLog = ctx.WebLog + let debug = debug "Feed" ctx let name = $"/{webLog.rss.feedName}" let postCount = defaultArg webLog.rss.itemsInFeed webLog.postsPerPage + debug (fun () -> $"Considering potential feed for {feedPath} (configured feed name {name})") // Standard feed match webLog.rss.feedEnabled && feedPath = name with - | true -> Some (StandardFeed feedPath, postCount) + | true -> + debug (fun () -> "Found standard feed") + Some (StandardFeed feedPath, postCount) | false -> // Category feed match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = feedPath.Replace (name, "")) with - | Some cat -> Some (CategoryFeed (CategoryId cat.id, feedPath), postCount) + | Some cat -> + debug (fun () -> "Found category feed") + Some (CategoryFeed (CategoryId cat.id, feedPath), postCount) | None -> // Tag feed match feedPath.StartsWith "/tag/" with - | true -> Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, ""), feedPath), postCount) + | true -> + debug (fun () -> "Found tag feed") + Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, ""), feedPath), postCount) | false -> // Custom feed match webLog.rss.customFeeds - |> List.tryFind (fun it -> (Permalink.toString it.path).EndsWith feedPath) with + |> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with | Some feed -> + debug (fun () -> "Found custom feed") Some (Custom (feed, feedPath), feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) | None -> - // No feed + debug (fun () -> $"No matching feed found") None /// Determine the function to retrieve posts for the given feed @@ -61,6 +70,25 @@ let private getFeedPosts (webLog : WebLog) feedType = /// Strip HTML from a string let private stripHtml text = Regex.Replace (text, "<(.|\n)*?>", "") +/// XML namespaces for building RSS feeds +[] +module private Namespace = + + /// Enables encoded (HTML) content + let content = "http://purl.org/rss/1.0/modules/content/" + + /// The dc XML namespace + let dc = "http://purl.org/dc/elements/1.1/" + + /// iTunes elements + let iTunes = "http://www.itunes.com/dtds/podcast-1.0.dtd" + + /// Enables chapters + let psc = "http://podlove.org/simple-chapters/" + + /// Enables another "subscribe" option + let rawVoice = "http://www.rawvoice.com/rawvoiceRssModule/" + /// Create a feed item from the given post let private toFeedItem webLog (authors : MetaItem list) (cats : DisplayCategory[]) (tagMaps : TagMap list) (post : Post) = @@ -78,7 +106,7 @@ let private toFeedItem webLog (authors : MetaItem list) (cats : DisplayCategory[ let encoded = post.text.Replace("src=\"/", $"src=\"{webLog.urlBase}/").Replace ("href=\"/", $"href=\"{webLog.urlBase}/") - item.ElementExtensions.Add ("encoded", "http://purl.org/rss/1.0/modules/content/", encoded) + item.ElementExtensions.Add ("encoded", Namespace.content, encoded) item.Authors.Add (SyndicationPerson ( Name = (authors |> List.find (fun a -> a.name = WebLogUserId.toString post.authorId)).value)) [ post.categoryIds @@ -122,26 +150,25 @@ let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : Syndicat |> ExplicitRating.toString with :? ArgumentException -> ExplicitRating.toString podcast.explicit - let encXml = XmlDocument () - let encElt = encXml.CreateElement "enclosure" - encElt.SetAttribute ("url", epMediaUrl) - meta "length" |> Option.iter (fun it -> encElt.SetAttribute ("length", it.value)) - epMediaType |> Option.iter (fun typ -> encElt.SetAttribute ("type", typ)) - item.ElementExtensions.Add ("enclosure", "", encXml) + let xmlDoc = XmlDocument () + let enclosure = xmlDoc.CreateElement "enclosure" + enclosure.SetAttribute ("url", epMediaUrl) + meta "length" |> Option.iter (fun it -> enclosure.SetAttribute ("length", it.value)) + epMediaType |> Option.iter (fun typ -> enclosure.SetAttribute ("type", typ)) + item.ElementExtensions.Add enclosure - item.ElementExtensions.Add ("creator", "dc", podcast.displayedAuthor) - item.ElementExtensions.Add ("author", "itunes", podcast.displayedAuthor) - meta "subtitle" |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", "itunes", it.value)) - item.ElementExtensions.Add ("summary", "itunes", stripHtml post.text) - item.ElementExtensions.Add ("image", "itunes", epImageUrl) - item.ElementExtensions.Add ("explicit", "itunes", epExplicit) - meta "duration" |> Option.iter (fun it -> item.ElementExtensions.Add ("duration", "itunes", it.value)) + item.ElementExtensions.Add ("creator", Namespace.dc, podcast.displayedAuthor) + item.ElementExtensions.Add ("author", Namespace.iTunes, podcast.displayedAuthor) + item.ElementExtensions.Add ("summary", Namespace.iTunes, stripHtml post.text) + item.ElementExtensions.Add ("image", Namespace.iTunes, epImageUrl) + item.ElementExtensions.Add ("explicit", Namespace.iTunes, epExplicit) + meta "subtitle" |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it.value)) + meta "duration" |> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it.value)) if post.metadata |> List.exists (fun it -> it.name = "chapter") then try - let chapXml = XmlDocument () - let chapsElt = chapXml.CreateElement ("psc", "chapters", "") - chapsElt.SetAttribute ("version", "1.2") + let chapters = xmlDoc.CreateElement ("psc", "chapters", Namespace.psc) + chapters.SetAttribute ("version", "1.2") post.metadata |> List.filter (fun it -> it.name = "chapter") @@ -149,13 +176,12 @@ let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : Syndicat TimeSpan.Parse (it.value.Split(" ")[0]), it.value.Substring (it.value.IndexOf(" ") + 1)) |> List.sortBy fst |> List.iter (fun chap -> - let chapElt = chapXml.CreateElement ("psc", "chapter", "") - chapElt.SetAttribute ("start", (fst chap).ToString "hh:mm:ss") - chapElt.SetAttribute ("title", snd chap) - chapsElt.AppendChild chapElt |> ignore) + let chapter = xmlDoc.CreateElement ("psc", "chapter", Namespace.psc) + chapter.SetAttribute ("start", (fst chap).ToString "hh:mm:ss") + chapter.SetAttribute ("title", snd chap) + chapters.AppendChild chapter |> ignore) - chapXml.AppendChild chapsElt |> ignore - item.ElementExtensions.Add ("chapters", "psc", chapXml) + item.ElementExtensions.Add chapters with _ -> () item @@ -165,10 +191,12 @@ let private addNamespace (feed : SyndicationFeed) alias nsUrl = /// Add items to the top of the feed required for podcasts let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = - let addChild (doc : XmlDocument) name prefix value = - let child = doc.CreateElement (name, prefix, "") |> doc.AppendChild - child.Value <- value - doc + let addChild (doc : XmlDocument) ns prefix name value (elt : XmlElement) = + let child = + if ns = "" then doc.CreateElement name else doc.CreateElement (prefix, name, ns) + |> elt.AppendChild + child.InnerText <- value + elt let podcast = Option.get feed.podcast let feedUrl = WebLog.absoluteUrl webLog feed.path @@ -177,43 +205,42 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = | Permalink link when link.StartsWith "http" -> link | Permalink _ -> WebLog.absoluteUrl webLog podcast.imageUrl - let categoryXml = XmlDocument () - let catElt = categoryXml.CreateElement ("itunes", "category", "") - catElt.SetAttribute ("text", podcast.iTunesCategory) - podcast.iTunesSubcategory - |> Option.iter (fun subCat -> - let subCatElt = categoryXml.CreateElement ("itunes", "category", "") - subCatElt.SetAttribute ("text", subCat) - catElt.AppendChild subCatElt |> ignore) - categoryXml.AppendChild catElt |> ignore + let xmlDoc = XmlDocument () - [ "dc", "http://purl.org/dc/elements/1.1/" - "itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd" - "psc", "http://podlove.org/simple-chapters/" - "rawvoice", "http://www.rawvoice.com/rawvoiceRssModule/" - ] + [ "dc", Namespace.dc; "itunes", Namespace.iTunes; "psc", Namespace.psc; "rawvoice", Namespace.rawVoice ] |> List.iter (fun (alias, nsUrl) -> addNamespace rssFeed alias nsUrl) - rssFeed.ElementExtensions.Add - ("image", "", - [ "title", podcast.title - "url", imageUrl - "link", feedUrl - ] - |> List.fold (fun doc (name, value) -> addChild doc name "" value) (XmlDocument ())) - rssFeed.ElementExtensions.Add ("summary", "itunes", podcast.summary) - rssFeed.ElementExtensions.Add ("author", "itunes", podcast.displayedAuthor) - podcast.subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", "itunes", sub)) - rssFeed.ElementExtensions.Add - ("owner", "itunes", - [ "name", podcast.displayedAuthor - "email", podcast.email - ] - |> List.fold (fun doc (name, value) -> addChild doc name "itunes" value) (XmlDocument ())) - rssFeed.ElementExtensions.Add ("image", "itunes", imageUrl) - rssFeed.ElementExtensions.Add ("category", "itunes", categoryXml) - rssFeed.ElementExtensions.Add ("explicit", "itunes", ExplicitRating.toString podcast.explicit) - rssFeed.ElementExtensions.Add ("subscribe", "rawvoice", feedUrl) + let categorization = + let it = xmlDoc.CreateElement ("itunes", "category", Namespace.iTunes) + it.SetAttribute ("text", podcast.iTunesCategory) + podcast.iTunesSubcategory + |> Option.iter (fun subCat -> + let subCatElt = xmlDoc.CreateElement ("itunes", "category", Namespace.iTunes) + subCatElt.SetAttribute ("text", subCat) + it.AppendChild subCatElt |> ignore) + it + let image = + [ "title", podcast.title + "url", imageUrl + "link", feedUrl + ] + |> List.fold (fun elt (name, value) -> addChild xmlDoc "" "" name value elt) (xmlDoc.CreateElement "image") + let owner = + [ "name", podcast.displayedAuthor + "email", podcast.email + ] + |> List.fold (fun elt (name, value) -> addChild xmlDoc Namespace.iTunes "itunes" name value elt) + (xmlDoc.CreateElement ("itunes", "owner", Namespace.iTunes)) + + rssFeed.ElementExtensions.Add image + rssFeed.ElementExtensions.Add owner + rssFeed.ElementExtensions.Add categorization + rssFeed.ElementExtensions.Add ("summary", Namespace.iTunes, podcast.summary) + rssFeed.ElementExtensions.Add ("author", Namespace.iTunes, podcast.displayedAuthor) + rssFeed.ElementExtensions.Add ("image", Namespace.iTunes, imageUrl) + rssFeed.ElementExtensions.Add ("explicit", Namespace.iTunes, ExplicitRating.toString podcast.explicit) + rssFeed.ElementExtensions.Add ("subscribe", Namespace.rawVoice, feedUrl) + podcast.subtitle |> Option.iter (fun sub -> rssFeed.ElementExtensions.Add ("subtitle", Namespace.iTunes, sub)) /// Get the feed's self reference and non-feed link let private selfAndLink webLog feedType = @@ -227,22 +254,26 @@ let private selfAndLink webLog feedType = /// Create a feed with a known non-zero-length list of posts let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask { - let webLog = ctx.WebLog - let conn = ctx.Conn - let! authors = Post.getAuthors webLog posts conn - let! tagMaps = Post.getTagMappings webLog posts conn - let cats = CategoryCache.get ctx - let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None + let webLog = ctx.WebLog + let conn = ctx.Conn + let! authors = Post.getAuthors webLog posts conn + let! tagMaps = Post.getTagMappings webLog posts conn + let cats = CategoryCache.get ctx + let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None + let self, link = selfAndLink webLog feedType let toItem post = let item = toFeedItem webLog authors cats tagMaps post match podcast with | Some feed when post.metadata |> List.exists (fun it -> it.name = "media") -> addEpisode webLog feed post item + | Some _ -> + warn "Feed" ctx $"[{webLog.name} {Permalink.toString self}] \"{stripHtml post.title}\" has no media" + item | _ -> item let feed = SyndicationFeed () - addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" + addNamespace feed "content" Namespace.content feed.Title <- TextSyndicationContent webLog.name feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name @@ -253,7 +284,6 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg feed.Id <- webLog.urlBase webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) - let self, link = selfAndLink webLog feedType feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L)) feed.ElementExtensions.Add ("link", "", WebLog.absoluteUrl webLog link) diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index 33e2445..b9a428d 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -176,8 +176,15 @@ let private isDebugEnabled (ctx : HttpContext) = debugEnabled.Value /// Log a debug message -let debug name ctx (msg : unit -> string) = +let debug (name : string) ctx msg = if isDebugEnabled ctx then let fac = ctx.RequestServices.GetRequiredService () let log = fac.CreateLogger $"MyWebLog.Handlers.{name}" log.LogDebug (msg ()) + +/// Log a warning message +let warn (name : string) (ctx : HttpContext) msg = + let fac = ctx.RequestServices.GetRequiredService () + let log = fac.CreateLogger $"MyWebLog.Handlers.{name}" + log.LogWarning msg + \ No newline at end of file diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 95c10fb..42f3be8 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -15,19 +15,21 @@ module CatchAll = let private deriveAction (ctx : HttpContext) : HttpHandler seq = let webLog = ctx.WebLog let conn = ctx.Conn + let debug = debug "Routes.CatchAll" ctx let textLink = let _, extra = WebLog.hostAndPath webLog let url = string ctx.Request.Path (if extra = "" then url else url.Substring extra.Length).ToLowerInvariant () let await it = (Async.AwaitTask >> Async.RunSynchronously) it seq { - debug "Post" ctx (fun () -> $"Considering URL {textLink}") + debug (fun () -> $"Considering URL {textLink}") // Home page directory without the directory slash if textLink = "" then yield redirectTo true (WebLog.relativeUrl webLog Permalink.empty) let permalink = Permalink (textLink.Substring 1) // Current post match Data.Post.findByPermalink permalink webLog.id conn |> await with | Some post -> + debug (fun () -> $"Found post by permalink") let model = Post.preparePostList webLog [ post ] Post.ListType.SinglePost "" 1 1 ctx conn |> await model.Add ("page_title", post.title) yield fun next ctx -> themedView "single-post" next ctx model @@ -35,31 +37,43 @@ module CatchAll = // Current page match Data.Page.findByPermalink permalink webLog.id conn |> await with | Some page -> + debug (fun () -> $"Found page by permalink") yield fun next ctx -> Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} |> themedView (defaultArg page.template "single-page") next ctx | None -> () // RSS feed match Feed.deriveFeedType ctx textLink with - | Some (feedType, postCount) -> yield Feed.generate feedType postCount + | Some (feedType, postCount) -> + debug (fun () -> $"Found RSS feed") + yield Feed.generate feedType postCount | None -> () // Post differing only by trailing slash let altLink = Permalink (if textLink.EndsWith "/" then textLink[..textLink.Length - 2] else $"{textLink}/") match Data.Post.findByPermalink altLink webLog.id conn |> await with - | Some post -> yield redirectTo true (WebLog.relativeUrl webLog post.permalink) + | Some post -> + debug (fun () -> $"Found post by trailing-slash-agnostic permalink") + yield redirectTo true (WebLog.relativeUrl webLog post.permalink) | None -> () // Page differing only by trailing slash match Data.Page.findByPermalink altLink webLog.id conn |> await with - | Some page -> yield redirectTo true (WebLog.relativeUrl webLog page.permalink) + | Some page -> + debug (fun () -> $"Found page by trailing-slash-agnostic permalink") + yield redirectTo true (WebLog.relativeUrl webLog page.permalink) | None -> () // Prior post match Data.Post.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with - | Some link -> yield redirectTo true (WebLog.relativeUrl webLog link) + | Some link -> + debug (fun () -> $"Found post by prior permalink") + yield redirectTo true (WebLog.relativeUrl webLog link) | None -> () // Prior page match Data.Page.findCurrentPermalink [ permalink; altLink ] webLog.id conn |> await with - | Some link -> yield redirectTo true (WebLog.relativeUrl webLog link) + | Some link -> + debug (fun () -> $"Found page by prior permalink") + yield redirectTo true (WebLog.relativeUrl webLog link) | None -> () + debug (fun () -> $"No content found") } // GET {all-of-the-above} -- 2.45.1 From b971a343a4afa4c6a3a55d73ebd11b20bb0a4b3c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 30 May 2022 22:01:13 -0400 Subject: [PATCH 061/102] Add category/tag feeds - Add page_head tag to add feed links, canonical URLs, generator, and theme files - Use page_head in all current themes --- src/MyWebLog/DotLiquidBespoke.fs | 57 ++++++++ src/MyWebLog/Handlers/Feed.fs | 68 ++++++---- src/MyWebLog/Handlers/Helpers.fs | 26 ++++ src/MyWebLog/Handlers/Post.fs | 128 ++++++++++-------- src/MyWebLog/Handlers/Routes.fs | 6 +- src/MyWebLog/MyWebLog.fsproj | 2 +- src/MyWebLog/Program.fs | 1 + src/MyWebLog/themes/bit-badger/layout.liquid | 4 +- .../themes/daniel-j-summers/layout.liquid | 14 +- src/MyWebLog/themes/default/layout.liquid | 3 +- src/MyWebLog/themes/tech-blog/layout.liquid | 15 +- .../wwwroot/themes/tech-blog/favicon.ico | Bin 0 -> 9528 bytes 12 files changed, 210 insertions(+), 114 deletions(-) create mode 100644 src/MyWebLog/wwwroot/themes/tech-blog/favicon.ico diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 3b29deb..cb43451 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -3,6 +3,7 @@ module MyWebLog.DotLiquidBespoke open System open System.IO +open System.Web open DotLiquid open MyWebLog.ViewModels @@ -22,11 +23,13 @@ let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> st | Some link -> linkFunc (webLog ctx) (Permalink link) | None -> $"alert('unknown item type {item.GetType().Name}')" + /// A filter to generate an absolute link type AbsoluteLinkFilter () = static member AbsoluteLink (ctx : Context, item : obj) = permalink ctx item WebLog.absoluteUrl + /// A filter to generate a link with posts categorized under the given category type CategoryLinkFilter () = static member CategoryLink (ctx : Context, catObj : obj) = @@ -50,6 +53,7 @@ type EditPageLinkFilter () = |> function | Some pageId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/page/{pageId}/edit") | None -> $"alert('unknown page object type {pageObj.GetType().Name}')" + /// A filter to generate a link that will edit a post type EditPostLinkFilter () = @@ -62,6 +66,7 @@ type EditPostLinkFilter () = |> function | Some postId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/post/{postId}/edit") | None -> $"alert('unknown post object type {postObj.GetType().Name}')" + /// A filter to generate nav links, highlighting the active link (exact match) type NavLinkFilter () = @@ -78,11 +83,62 @@ type NavLinkFilter () = } |> Seq.fold (+) "" + +/// Create various items in the page header based on the state of the page being generated +type PageHeadTag () = + inherit Tag () + + override this.Render (context : Context, result : TextWriter) = + let webLog = webLog context + // spacer + let s = " " + let getBool name = + context.Environments[0].[name] |> Option.ofObj |> Option.map Convert.ToBoolean |> Option.defaultValue false + + result.WriteLine $"""""" + + // Theme assets + let has fileName = File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName)) + if has "style.css" then + result.WriteLine $"""{s}""" + if has "favicon.ico" then + result.WriteLine $"""{s}""" + + // RSS feeds and canonical URLs + let feedLink title url = + let escTitle = HttpUtility.HtmlAttributeEncode title + let relUrl = WebLog.relativeUrl webLog (Permalink url) + $"""{s}""" + + if webLog.rss.feedEnabled && getBool "is_home" then + result.WriteLine (feedLink webLog.name webLog.rss.feedName) + result.WriteLine $"""{s}""" + + if webLog.rss.categoryEnabled && getBool "is_category_home" then + let slug = context.Environments[0].["slug"] :?> string + result.WriteLine (feedLink webLog.name $"category/{slug}/{webLog.rss.feedName}") + + if webLog.rss.tagEnabled && getBool "is_tag_home" then + let slug = context.Environments[0].["slug"] :?> string + result.WriteLine (feedLink webLog.name $"tag/{slug}/{webLog.rss.feedName}") + + if getBool "is_post" then + let post = context.Environments[0].["model"] :?> PostDisplay + let url = WebLog.absoluteUrl webLog (Permalink post.posts[0].permalink) + result.WriteLine $"""{s}""" + + if getBool "is_page" then + let page = context.Environments[0].["page"] :?> DisplayPage + let url = WebLog.absoluteUrl webLog (Permalink page.permalink) + result.WriteLine $"""{s}""" + + /// A filter to generate a relative link type RelativeLinkFilter () = static member RelativeLink (ctx : Context, item : obj) = permalink ctx item WebLog.relativeUrl + /// A filter to generate a link with posts tagged with the given tag type TagLinkFilter () = static member TagLink (ctx : Context, tag : string) = @@ -92,6 +148,7 @@ type TagLinkFilter () = | Some tagMap -> tagMap.urlValue | None -> tag.Replace (" ", "+") |> function tagUrl -> WebLog.relativeUrl (webLog ctx) (Permalink $"tag/{tagUrl}/") + /// Create links for a user to log on or off, and a dashboard link if they are logged off type UserLinksTag () = diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 68ab950..cfc5b35 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -33,28 +33,16 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = debug (fun () -> "Found standard feed") Some (StandardFeed feedPath, postCount) | false -> - // Category feed - match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = feedPath.Replace (name, "")) with - | Some cat -> - debug (fun () -> "Found category feed") - Some (CategoryFeed (CategoryId cat.id, feedPath), postCount) + // Category and tag feeds are handled by defined routes; check for custom feed + match webLog.rss.customFeeds + |> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with + | Some feed -> + debug (fun () -> "Found custom feed") + Some (Custom (feed, feedPath), + feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) | None -> - // Tag feed - match feedPath.StartsWith "/tag/" with - | true -> - debug (fun () -> "Found tag feed") - Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, ""), feedPath), postCount) - | false -> - // Custom feed - match webLog.rss.customFeeds - |> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with - | Some feed -> - debug (fun () -> "Found custom feed") - Some (Custom (feed, feedPath), - feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) - | None -> - debug (fun () -> $"No matching feed found") - None + debug (fun () -> $"No matching feed found") + None /// Determine the function to retrieve posts for the given feed let private getFeedPosts (webLog : WebLog) feedType = @@ -252,12 +240,41 @@ let private selfAndLink webLog feedType = |> function | path -> Permalink path, Permalink (path.Replace ($"/{webLog.rss.feedName}", "")) +/// Set the title and description of the feed based on its source +let private setTitleAndDescription feedType (webLog : WebLog) (cats : DisplayCategory[]) (feed : SyndicationFeed) = + let cleanText opt def = TextSyndicationContent (stripHtml (defaultArg opt def)) + match feedType with + | StandardFeed _ -> + feed.Title <- cleanText None webLog.name + feed.Description <- cleanText webLog.subtitle webLog.name + | CategoryFeed (CategoryId catId, _) -> + let cat = cats |> Array.find (fun it -> it.id = catId) + feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category""" + feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """ + | TagFeed (tag, _) -> + feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag""" + feed.Description <- cleanText None $"""Posts with the "{tag}" tag""" + | Custom (custom, _) -> + match custom.podcast with + | Some podcast -> + feed.Title <- cleanText None podcast.title + feed.Description <- cleanText podcast.subtitle podcast.title + | None -> + match custom.source with + | Category (CategoryId catId) -> + let cat = cats |> Array.find (fun it -> it.id = catId) + feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category""" + feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """ + | Tag tag -> + feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag""" + feed.Description <- cleanText None $"""Posts with the "{tag}" tag""" + /// Create a feed with a known non-zero-length list of posts let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask { let webLog = ctx.WebLog let conn = ctx.Conn - let! authors = Post.getAuthors webLog posts conn - let! tagMaps = Post.getTagMappings webLog posts conn + let! authors = getAuthors webLog posts conn + let! tagMaps = getTagMappings webLog posts conn let cats = CategoryCache.get ctx let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None let self, link = selfAndLink webLog feedType @@ -274,14 +291,13 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg let feed = SyndicationFeed () addNamespace feed "content" Namespace.content + setTitleAndDescription feedType webLog cats feed - feed.Title <- TextSyndicationContent webLog.name - feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn feed.Generator <- generator ctx feed.Items <- posts |> Seq.ofList |> Seq.map toItem feed.Language <- "en" - feed.Id <- webLog.urlBase + feed.Id <- WebLog.absoluteUrl webLog link webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L)) diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index b9a428d..98d6984 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -160,6 +160,32 @@ let templatesForTheme (ctx : HttpContext) (typ : string) = } |> Array.ofSeq +/// Get all authors for a list of posts as metadata items +let getAuthors (webLog : WebLog) (posts : Post list) conn = + posts + |> List.map (fun p -> p.authorId) + |> List.distinct + |> Data.WebLogUser.findNames webLog.id conn + +/// Get all tag mappings for a list of posts as metadata items +let getTagMappings (webLog : WebLog) (posts : Post list) = + posts + |> List.map (fun p -> p.tags) + |> List.concat + |> List.distinct + |> fun tags -> Data.TagMap.findMappingForTags tags webLog.id + +/// Get all category IDs for the given slug (includes owned subcategories) +let getCategoryIds slug ctx = + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + allCats + |> Seq.ofArray + |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) + |> Seq.map (fun c -> CategoryId c.id) + |> List.ofSeq + open Microsoft.Extensions.Logging /// Log level for debugging diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index ca502e1..10e99ee 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -2,10 +2,21 @@ module MyWebLog.Handlers.Post open System +open MyWebLog /// Parse a slug and page number from an "everything else" URL -let private parseSlugAndPage (slugAndPage : string seq) = - let slugs = (slugAndPage |> Seq.skip 1 |> Seq.head).Split "/" |> Array.filter (fun it -> it <> "") +let private parseSlugAndPage webLog (slugAndPage : string seq) = + let fullPath = slugAndPage |> Seq.head + let slugPath = slugAndPage |> Seq.skip 1 |> Seq.head + let slugs, isFeed = + let feedName = $"/{webLog.rss.feedName}" + let notBlank = Array.filter (fun it -> it <> "") + if ( (webLog.rss.categoryEnabled && fullPath.StartsWith "/category/") + || (webLog.rss.tagEnabled && fullPath.StartsWith "/tag/" )) + && slugPath.EndsWith feedName then + notBlank (slugPath.Replace(feedName, "").Split "/"), true + else + notBlank (slugPath.Split "/"), false let pageIdx = Array.IndexOf (slugs, "page") let pageNbr = match pageIdx with @@ -13,7 +24,7 @@ let private parseSlugAndPage (slugAndPage : string seq) = | idx when idx + 2 = slugs.Length -> Some (int slugs[pageIdx + 1]) | _ -> None let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs - pageNbr, String.Join ("/", slugParts) + pageNbr, String.Join ("/", slugParts), isFeed /// The type of post list being prepared type ListType = @@ -23,23 +34,6 @@ type ListType = | SinglePost | TagList -open MyWebLog - -/// Get all authors for a list of posts as metadata items -let getAuthors (webLog : WebLog) (posts : Post list) conn = - posts - |> List.map (fun p -> p.authorId) - |> List.distinct - |> Data.WebLogUser.findNames webLog.id conn - -/// Get all tag mappings for a list of posts as metadata items -let getTagMappings (webLog : WebLog) (posts : Post list) = - posts - |> List.map (fun p -> p.tags) - |> List.concat - |> List.distinct - |> fun tags -> Data.TagMap.findMappingForTags tags webLog.id - open System.Threading.Tasks open DotLiquid open MyWebLog.ViewModels @@ -91,7 +85,12 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage ctx con olderLink = olderLink olderName = olderPost |> Option.map (fun p -> p.title) } - return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx; tag_mappings = tagMappings |} + return Hash.FromAnonymousObject {| + model = model + categories = CategoryCache.get ctx + tag_mappings = tagMappings + is_post = match listType with SinglePost -> true | _ -> false + |} } open Giraffe @@ -117,27 +116,30 @@ let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { let webLog = ctx.WebLog let conn = ctx.Conn - match parseSlugAndPage slugAndPage with - | Some pageNbr, slug -> + match parseSlugAndPage webLog slugAndPage with + | Some pageNbr, slug, isFeed -> let allCats = CategoryCache.get ctx let cat = allCats |> Array.find (fun cat -> cat.slug = slug) - // Category pages include posts in subcategories - let catIds = - allCats - |> Seq.ofArray - |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) - |> Seq.map (fun c -> CategoryId c.id) - |> List.ofSeq - match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") - hash.Add ("subtitle", cat.description.Value) - hash.Add ("is_category", true) - return! themedView "index" next ctx hash - | _ -> return! Error.notFound next ctx - | None, _ -> return! Error.notFound next ctx + if isFeed then + return! Feed.generate (Feed.CategoryFeed ((CategoryId cat.id), $"category/{slug}/{webLog.rss.feedName}")) + (defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx + else + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + match! Data.Post.findPageOfCategorizedPosts webLog.id (getCategoryIds slug ctx) pageNbr webLog.postsPerPage + conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") + hash.Add ("subtitle", defaultArg cat.description "") + hash.Add ("is_category", true) + hash.Add ("is_category_home", (pageNbr = 1)) + hash.Add ("slug", slug) + return! themedView "index" next ctx hash + | _ -> return! Error.notFound next ctx + | None, _, _ -> return! Error.notFound next ctx } open System.Web @@ -147,33 +149,39 @@ open System.Web let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task { let webLog = ctx.WebLog let conn = ctx.Conn - match parseSlugAndPage slugAndPage with - | Some pageNbr, rawTag -> + match parseSlugAndPage webLog slugAndPage with + | Some pageNbr, rawTag, isFeed -> let urlTag = HttpUtility.UrlDecode rawTag let! tag = backgroundTask { match! Data.TagMap.findByUrlValue urlTag webLog.id conn with | Some m -> return m.tag | None -> return urlTag } - match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") - hash.Add ("is_tag", true) - return! themedView "index" next ctx hash - // Other systems use hyphens for spaces; redirect if this is an old tag link - | _ -> - let spacedTag = tag.Replace ("-", " ") - match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + if isFeed then + return! Feed.generate (Feed.TagFeed (tag, $"tag/{rawTag}/{webLog.rss.feedName}")) + (defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx + else + match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> - let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}" - return! - redirectTo true - (WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}""")) - next ctx - | _ -> return! Error.notFound next ctx - | None, _ -> return! Error.notFound next ctx + let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") + hash.Add ("is_tag", true) + hash.Add ("is_tag_home", (pageNbr = 1)) + hash.Add ("slug", rawTag) + return! themedView "index" next ctx hash + // Other systems use hyphens for spaces; redirect if this is an old tag link + | _ -> + let spacedTag = tag.Replace ("-", " ") + match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + | posts when List.length posts > 0 -> + let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}" + return! + redirectTo true + (WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}""")) + next ctx + | _ -> return! Error.notFound next ctx + | None, _, _ -> return! Error.notFound next ctx } // GET / diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 42f3be8..589c4c4 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -39,7 +39,11 @@ module CatchAll = | Some page -> debug (fun () -> $"Found page by permalink") yield fun next ctx -> - Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} + Hash.FromAnonymousObject {| + page = DisplayPage.fromPage webLog page + page_title = page.title + is_page = true + |} |> themedView (defaultArg page.template "single-page") next ctx | None -> () // RSS feed diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index ab6d573..09c7773 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -12,8 +12,8 @@ - + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 189931e..645c9b0 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -215,6 +215,7 @@ let main args = ] |> List.iter Template.RegisterFilter + Template.RegisterTag "page_head" Template.RegisterTag "user_links" [ // Domain types diff --git a/src/MyWebLog/themes/bit-badger/layout.liquid b/src/MyWebLog/themes/bit-badger/layout.liquid index d75e4fc..5cf46a6 100644 --- a/src/MyWebLog/themes/bit-badger/layout.liquid +++ b/src/MyWebLog/themes/bit-badger/layout.liquid @@ -3,11 +3,9 @@ - {{ page_title }} » Bit Badger Solutions - - + {% page_head -%}
    @@ -10,7 +11,6 @@ - {%- assign page_count = pages | size -%} {% if page_count > 0 %} {% for pg in pages -%} @@ -43,6 +43,22 @@ {% endif %}
    + {% if page_nbr > 1 or page_count == 25 %} +
    +
    + {% if page_nbr > 1 %} + {%- capture prev_link %}admin/pages{{ prev_page }}{% endcapture -%} +

    « Previous

    + {% endif %} +
    +
    + {% if page_count == 25 %} + {%- capture next_link %}admin/pages{{ next_page }}{% endcapture -%} +

    Next »

    + {% endif %} +
    +
    + {% endif %} diff --git a/src/MyWebLog/themes/bit-badger/home-page.liquid b/src/MyWebLog/themes/bit-badger/home-page.liquid index 7712f8c..04a7b22 100644 --- a/src/MyWebLog/themes/bit-badger/home-page.liquid +++ b/src/MyWebLog/themes/bit-badger/home-page.liquid @@ -91,14 +91,17 @@

    Biloxi, Mississippi

    +
    +
    +
    myWebLog

    The Bit Badger Blog
    About • - + Visit

    -- 2.45.1 From aa3ee239f6a9a0583aa7e3b56e1a424d3be7f8ac Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 31 May 2022 09:48:19 -0400 Subject: [PATCH 065/102] Return 404 vs. 500 for invalid category --- src/MyWebLog/Handlers/Post.fs | 14 ++++++-------- src/MyWebLog/appsettings.json | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 10e99ee..93e4a09 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -118,20 +118,17 @@ let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { let conn = ctx.Conn match parseSlugAndPage webLog slugAndPage with | Some pageNbr, slug, isFeed -> - let allCats = CategoryCache.get ctx - let cat = allCats |> Array.find (fun cat -> cat.slug = slug) - if isFeed then + match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = slug) with + | Some cat when isFeed -> return! Feed.generate (Feed.CategoryFeed ((CategoryId cat.id), $"category/{slug}/{webLog.rss.feedName}")) (defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx - else - let allCats = CategoryCache.get ctx - let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + | Some cat -> // Category pages include posts in subcategories match! Data.Post.findPageOfCategorizedPosts webLog.id (getCategoryIds slug ctx) pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" + let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") hash.Add ("subtitle", defaultArg cat.description "") hash.Add ("is_category", true) @@ -139,6 +136,7 @@ let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { hash.Add ("slug", slug) return! themedView "index" next ctx hash | _ -> return! Error.notFound next ctx + | None -> return! Error.notFound next ctx | None, _, _ -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 0be4f82..bcc5748 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha17", + "Generator": "myWebLog 2.0-alpha18", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" -- 2.45.1 From 019ac229fbd41032eeca994ae53a6f4468fdfa8b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 31 May 2022 10:11:06 -0400 Subject: [PATCH 066/102] Add categories to page render context - Add post IDs to tech blog index template --- src/MyWebLog/Handlers/Post.fs | 1 + src/MyWebLog/Handlers/Routes.fs | 1 + src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/tech-blog/index.liquid | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 93e4a09..7f6da60 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -193,6 +193,7 @@ let home : HttpHandler = fun next ctx -> task { return! Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page + categories = CategoryCache.get ctx page_title = page.title is_home = true |} diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 589c4c4..eeff169 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -41,6 +41,7 @@ module CatchAll = yield fun next ctx -> Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page + categories = CategoryCache.get ctx page_title = page.title is_page = true |} diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index bcc5748..5c0852c 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha18", + "Generator": "myWebLog 2.0-alpha19", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" diff --git a/src/MyWebLog/themes/tech-blog/index.liquid b/src/MyWebLog/themes/tech-blog/index.liquid index 6f8ceb4..4028fe3 100644 --- a/src/MyWebLog/themes/tech-blog/index.liquid +++ b/src/MyWebLog/themes/tech-blog/index.liquid @@ -11,6 +11,7 @@ {{ post.published_on | date: "dddd, MMMM d, yyyy" }}
      {{ post.title }} -- 2.45.1 From 1fd2bfd08e82f00fc2b2d426e92113119918d5a5 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 31 May 2022 15:28:11 -0400 Subject: [PATCH 067/102] - Add autoHtmx field / htmx partial support - Add page_foot tag for scripts - Add htmx to admin area - Move create/permalink import to its own module - Add htmx to tech-blog theme - Move dashboard to admin/dashboard --- src/MyWebLog.Data/Data.fs | 1 + src/MyWebLog.Domain/DataTypes.fs | 4 + src/MyWebLog.Domain/ViewModels.fs | 17 ++ src/MyWebLog/DotLiquidBespoke.fs | 60 ++++++- src/MyWebLog/Handlers/Admin.fs | 14 +- src/MyWebLog/Handlers/Helpers.fs | 10 +- src/MyWebLog/Handlers/Routes.fs | 2 +- src/MyWebLog/Handlers/User.fs | 3 +- src/MyWebLog/Maintenance.fs | 126 ++++++++++++++ src/MyWebLog/MyWebLog.fsproj | 3 + src/MyWebLog/Program.fs | 162 +----------------- src/MyWebLog/appsettings.json | 2 +- .../themes/admin/layout-partial.liquid | 62 +++++++ src/MyWebLog/themes/admin/layout.liquid | 7 +- src/MyWebLog/themes/admin/settings.liquid | 14 +- src/MyWebLog/themes/bit-badger/layout.liquid | 2 +- .../themes/daniel-j-summers/layout.liquid | 2 +- .../themes/tech-blog/layout-partial.liquid | 14 ++ src/MyWebLog/themes/tech-blog/layout.liquid | 23 ++- .../wwwroot/themes/tech-blog/style.css | 58 +++++++ 20 files changed, 396 insertions(+), 190 deletions(-) create mode 100644 src/MyWebLog/Maintenance.fs create mode 100644 src/MyWebLog/themes/admin/layout-partial.liquid create mode 100644 src/MyWebLog/themes/tech-blog/layout-partial.liquid diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 6139d8a..49389da 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -752,6 +752,7 @@ module WebLog = "postsPerPage", webLog.postsPerPage "timeZone", webLog.timeZone "themePath", webLog.themePath + "autoHtmx", webLog.autoHtmx ] write; withRetryDefault; ignoreResult } diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index a8e8089..8f33973 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -275,6 +275,9 @@ type WebLog = /// The RSS options for this web log rss : RssOptions + + /// Whether to automatically load htmx + autoHtmx : bool } /// Functions to support web logs @@ -291,6 +294,7 @@ module WebLog = urlBase = "" timeZone = "" rss = RssOptions.empty + autoHtmx = false } /// Get the host (including scheme) and extra path from the URL base diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 7f1327e..a495625 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -676,7 +676,11 @@ type SettingsModel = /// The theme to use to display the web log themePath : string + + /// Whether to automatically load htmx + autoHtmx : bool } + /// Create a settings model from a web log static member fromWebLog (webLog : WebLog) = { name = webLog.name @@ -685,6 +689,19 @@ type SettingsModel = postsPerPage = webLog.postsPerPage timeZone = webLog.timeZone themePath = webLog.themePath + autoHtmx = webLog.autoHtmx + } + + /// Update a web log with settings from the form + member this.update (webLog : WebLog) = + { webLog with + name = this.name + subtitle = if this.subtitle = "" then None else Some this.subtitle + defaultPage = this.defaultPage + postsPerPage = this.postsPerPage + timeZone = this.timeZone + themePath = this.themePath + autoHtmx = this.autoHtmx } diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 122ca0f..95c1578 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -5,12 +5,17 @@ open System open System.IO open System.Web open DotLiquid +open Giraffe.ViewEngine open MyWebLog.ViewModels /// Get the current web log from the DotLiquid context let webLog (ctx : Context) = ctx.Environments[0].["web_log"] :?> WebLog +/// Does an asset exist for the current theme? +let assetExists fileName (webLog : WebLog) = + File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName)) + /// Obtain the link from known types let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) = match item with @@ -72,9 +77,11 @@ type EditPostLinkFilter () = type NavLinkFilter () = static member NavLink (ctx : Context, url : string, text : string) = let webLog = webLog ctx + let _, path = WebLog.hostAndPath webLog + let path = if path = "" then path else $"{path.Substring 1}/" seq { "
  • " @@ -98,10 +105,9 @@ type PageHeadTag () = result.WriteLine $"""""" // Theme assets - let has fileName = File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName)) - if has "style.css" then + if assetExists "style.css" webLog then result.WriteLine $"""{s}""" - if has "favicon.ico" then + if assetExists "favicon.ico" webLog then result.WriteLine $"""{s}""" // RSS feeds and canonical URLs @@ -132,6 +138,22 @@ type PageHeadTag () = let url = WebLog.absoluteUrl webLog (Permalink page.permalink) result.WriteLine $"""{s}""" + +/// Create various items in the page header based on the state of the page being generated +type PageFootTag () = + inherit Tag () + + override this.Render (context : Context, result : TextWriter) = + let webLog = webLog context + // spacer + let s = " " + + if webLog.autoHtmx then + result.WriteLine $"{s}{RenderView.AsString.htmlNode Htmx.Script.minified}" + + if assetExists "script.js" webLog then + result.WriteLine $"""{s}""" + /// A filter to generate a relative link type RelativeLinkFilter () = @@ -167,7 +189,7 @@ type UserLinksTag () = """
  • diff --git a/src/MyWebLog/wwwroot/themes/bit-badger/style.css b/src/MyWebLog/wwwroot/themes/bit-badger/style.css index e606a80..90a9e9e 100644 --- a/src/MyWebLog/wwwroot/themes/bit-badger/style.css +++ b/src/MyWebLog/wwwroot/themes/bit-badger/style.css @@ -305,4 +305,56 @@ footer { } footer a:link, footer a:visited { color: black; -} \ No newline at end of file +} +/* htmx "Loading" overlay */ +.load-overlay { + position: fixed; + display: flex; + flex-flow: row; + align-items: center; + width: 95%; + margin-left: 2.5%; + height: 0; + z-index: 2000; + background-color: rgba(0, 0, 0, .5); + border-radius: 1rem; + animation: fadeOut .25s ease-in-out; + overflow: hidden; +} +.load-overlay h1 { + color: white; + background-color: rgba(0, 0, 0, .75); + margin-left: auto; + margin-right: auto; + border-radius: 1rem; + width: 50%; + padding: 1rem; +} +.load-overlay.htmx-request { + height: 80vh; + animation: fadeIn .25s ease-in-out; +} +@keyframes fadeIn { + 0% { + opacity: 0; + height: 80vh; + } + 100% { + opacity: 1; + height: 80vh; + } +} +@keyframes fadeOut { + 0% { + opacity: 1; + height: 80vh; + } + 99% { + opacity: 0; + height: 80vh; + } + 100% { + opacity: 0; + height: 0; + } +} diff --git a/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css b/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css index c1cc720..37b92e0 100644 --- a/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css +++ b/src/MyWebLog/wwwroot/themes/daniel-j-summers/style.css @@ -6,12 +6,14 @@ --hdr-text-color: hsl(0, 0%, 100%); --hdr-bkg-color: hsl(0, 0%, 95%); --item-bkg-color: hsl(0, 0%, 100%); + --overlay-bkg-color: rgba(0, 0, 0, .5) } @media ( prefers-color-scheme: dark ) { :root { --text-color: rgb(210, 210, 210); --hdr-bkg-color: hsl(0, 0%, 7%); --item-bkg-color: hsl(0, 0%, 12%); + --overlay-bgk-color: rgba(255, 255, 255, .2) } } html { @@ -22,9 +24,6 @@ body { font-size: 1.2rem; background-color: var(--bkg-color); margin: 0; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto; color: var(--text-color); } a:link, a:visited { @@ -62,6 +61,11 @@ sup { sub { vertical-align: baseline; } +main { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto; +} .content img { max-width: 100%; border-radius: 1rem; @@ -92,6 +96,9 @@ sub { background-image: -moz-linear-gradient(top, var(--accent-color), var(--bkg-color)); background-image: linear-gradient(to bottom, var(--accent-color), var(--bkg-color)); } +.site-header p { + margin: 0; +} .site-header p a.nav-home { font-family: Oswald, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-weight: bold; @@ -257,9 +264,60 @@ footer.part-3 { white-space: nowrap; } +/* ----- OVERLAY ----- */ +.load-overlay { + position: fixed; + top: 4rem; + left: 1rem; + width: 50%; + height: 0; + z-index: 2000; + background-color: var(--overlay-bgk-color); + border-radius: 1rem; + animation: fadeOut .25s ease-in-out; + overflow: hidden; +} +.load-overlay h1 { + color: white; + background-color: rgba(0, 0, 0, .75); + margin: 1.5rem auto; + border-radius: 1rem; + width: 50%; + padding: 1rem; + text-align: center; +} +.load-overlay.htmx-request { + height: unset; + animation: fadeIn .25s ease-in-out; +} +@keyframes fadeIn { + 0% { + opacity: 0; + height: unset; + } + 100% { + opacity: 1; + height: unset; + } +} +@keyframes fadeOut { + 0% { + opacity: 1; + height: unset; + } + 99% { + opacity: 0; + height: unset; + } + 100% { + opacity: 0; + height: 0; + } +} + /* ----- SCALE UP STYLES ----- */ @media screen and ( min-width: 50rem ) { - body { + main { grid-template-columns: 1fr 16rem; } .desktop { @@ -268,7 +326,14 @@ footer.part-3 { .mobile { display: none; } - .site-header, .single, footer { + .site-header p { + margin-inline-end: 1.2rem; + margin-bottom: 1rem; + } + .load-overlay { + width: 25%; + } + main > .single { grid-column: 1 / -1; } .sidebar { diff --git a/src/MyWebLog/wwwroot/themes/tech-blog/style.css b/src/MyWebLog/wwwroot/themes/tech-blog/style.css index f343dce..ba517b1 100644 --- a/src/MyWebLog/wwwroot/themes/tech-blog/style.css +++ b/src/MyWebLog/wwwroot/themes/tech-blog/style.css @@ -40,7 +40,7 @@ acronym { border-bottom:dotted 1px black; text-decoration: none; } -header, h1, h2, h3, footer a, .home-lead a, .highlight { +.site-header, h1, h2, h3, .site-footer a, .home-lead a, .highlight { font-family: var(--heading-fonts); } h1 { @@ -162,7 +162,7 @@ article.page .metadata { .strike { text-decoration: line-through; } -footer { +.site-footer { padding: 20px 15px 10px 15px; display: flex; flex-direction: row; @@ -175,7 +175,7 @@ footer { background-image: -moz-linear-gradient(top, var(--bkg-color), var(--edge-color)); background-image: linear-gradient(to bottom, var(--bkg-color), var(--edge-color)); } -footer a:link, footer a:visited { +.site-footer a:link, .site-footer a:visited { color: black; } .alignleft { @@ -232,7 +232,7 @@ li { @keyframes fadeIn { 0% { opacity: 0; - height: 0; + height: 80vh; } 100% { opacity: 1; @@ -350,15 +350,21 @@ li { .entry-title { line-height: 1; } -.entry-meta { - font-size: 1rem; +.entry-header { text-align: center; font-weight: bold; - margin: 0 0 .5rem 0; + font-size: 1rem; } .entry-content { margin-top: 1.5rem; } +.entry-footer { + font-size: 85%; + border-top: solid 1px rgba(0, 0, 0, .2); +} +.entry-header p, .entry-footer p { + margin: .5rem 0; +} .cat-list-count { font-size: .8rem; } -- 2.45.1 From a050df4378302931c9a1545e4361750a87848d2a Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 31 May 2022 21:42:51 -0400 Subject: [PATCH 069/102] Disable boost on edit page/post links - Add transition to solution page collapsible panels --- src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/bit-badger/home-page.liquid | 2 +- src/MyWebLog/themes/bit-badger/project-page.liquid | 4 ++-- src/MyWebLog/themes/bit-badger/single-page.liquid | 2 +- src/MyWebLog/themes/bit-badger/solution-page.liquid | 10 +++++----- src/MyWebLog/themes/daniel-j-summers/index.liquid | 2 +- .../themes/daniel-j-summers/single-post.liquid | 6 +++++- src/MyWebLog/themes/tech-blog/index.liquid | 2 +- src/MyWebLog/themes/tech-blog/single-page.liquid | 4 +++- src/MyWebLog/themes/tech-blog/single-post.liquid | 8 +++++++- src/MyWebLog/wwwroot/themes/bit-badger/style.css | 12 ++++++++++++ 11 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index c6c2488..16074cc 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha21", + "Generator": "myWebLog 2.0-alpha22", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" diff --git a/src/MyWebLog/themes/bit-badger/home-page.liquid b/src/MyWebLog/themes/bit-badger/home-page.liquid index 04a7b22..0cf61d0 100644 --- a/src/MyWebLog/themes/bit-badger/home-page.liquid +++ b/src/MyWebLog/themes/bit-badger/home-page.liquid @@ -2,7 +2,7 @@
    diff --git a/src/MyWebLog/themes/bit-badger/solution-page.liquid b/src/MyWebLog/themes/bit-badger/solution-page.liquid index 89b02df..568fab8 100644 --- a/src/MyWebLog/themes/bit-badger/solution-page.liquid +++ b/src/MyWebLog/themes/bit-badger/solution-page.liquid @@ -59,7 +59,7 @@

    The Technology Stack

    - -
    - {% if messages %} -
    - {% for msg in messages %} - - {% endfor %} -
    - {% endif %} +
    +
    + {% for msg in messages %} + + {% endfor %} +
    {{ content }}
    @@ -58,5 +56,6 @@
    + diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid index 69cb7a1..239eaa1 100644 --- a/src/MyWebLog/themes/admin/layout.liquid +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -1,7 +1,7 @@ - + {{ page_title | escape }} « Admin « {{ web_log.name | escape }} -
    - {% if messages %} -
    - {% for msg in messages %} - - {% endfor %} -
    - {% endif %} +
    +
    + {% for msg in messages %} + + {% endfor %} +
    {{ content }}
    @@ -80,5 +78,6 @@ }, 2000) + diff --git a/src/MyWebLog/themes/admin/page-list.liquid b/src/MyWebLog/themes/admin/page-list.liquid index bdcc956..5e48d5a 100644 --- a/src/MyWebLog/themes/admin/page-list.liquid +++ b/src/MyWebLog/themes/admin/page-list.liquid @@ -2,49 +2,57 @@
    Create a New Page {%- assign page_count = pages | size -%} - - - - - - - - - - {% if page_count > 0 %} - {% for pg in pages -%} - - - - - - {%- endfor %} - {% else %} - - - - {% endif %} - -
    TitlePermalinkLast Updated
    - {{ pg.title }} - {%- if pg.is_default %}   HOME PAGE{% endif -%} - {%- if pg.show_in_page_list %}   IN PAGE LIST {% endif -%}
    - - {%- capture pg_link %}{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{% endcapture -%} - View Page - - Edit - - {%- capture pg_del %}admin/page/{{ pg.id }}/delete{% endcapture -%} - {%- capture pg_del_link %}{{ pg_del | relative_link }}{% endcapture -%} - - Delete - - -
    /{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{{ pg.updated_on | date: "MMMM d, yyyy" }}
    This web log has no pages
    + {%- assign title_col = "col-12 col-md-5" -%} + {%- assign link_col = "col-12 col-md-5" -%} + {%- assign upd8_col = "col-12 col-md-2" -%} +
    + +
    +
    + TitlePage +
    + +
    Updated
    +
    + {% if page_count > 0 %} + {% for pg in pages -%} +
    +
    + {{ pg.title }} + {%- if pg.is_default %}   HOME PAGE{% endif -%} + {%- if pg.show_in_page_list %}   IN PAGE LIST {% endif -%}
    + + {%- capture pg_link %}{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{% endcapture -%} + View Page + + Edit + + {%- capture pg_del %}admin/page/{{ pg.id }}/delete{% endcapture -%} + {%- capture pg_del_link %}{{ pg_del | relative_link }}{% endcapture -%} + + Delete + + +
    + +
    + Updated {{ pg.updated_on | date: "MMMM d, yyyy" }} + {{ pg.updated_on | date: "MMMM d, yyyy" }} +
    +
    + {%- endfor %} + {% else %} +
    +
    This web log has no pages
    +
    + {% endif %} +
    {% if page_nbr > 1 or page_count == 25 %} -
    +
    {% if page_nbr > 1 %} {%- capture prev_link %}admin/pages{{ prev_page }}{% endcapture -%} @@ -59,7 +67,4 @@
    {% endif %} -
    - -
    diff --git a/src/MyWebLog/themes/admin/post-list.liquid b/src/MyWebLog/themes/admin/post-list.liquid index 7ec742d..e649fc0 100644 --- a/src/MyWebLog/themes/admin/post-list.liquid +++ b/src/MyWebLog/themes/admin/post-list.liquid @@ -1,54 +1,84 @@

    {{ page_title }}

    Write a New Post - - - - - - - - - - - - {%- assign post_count = model.posts | size -%} - {%- if post_count > 0 %} - {% for post in model.posts -%} - - - - - - - - {%- endfor %} - {% else %} - - - - {% endif %} - -
    DateTitleAuthorStatusTags
    - {% if post.published_on %}{{ post.published_on | date: "MMMM d, yyyy" }}{% else %}Not Published{% endif %} +
    + + {%- assign post_count = model.posts | size -%} + {%- assign date_col = "col-xs-12 col-md-3 col-lg-2" -%} + {%- assign title_col = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" -%} + {%- assign author_col = "col-xs-12 col-md-2 col-lg-1" -%} + {%- assign tag_col = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" -%} +
    +
    + PostDate +
    +
    Title
    +
    Author
    +
    Tags
    +
    + {%- if post_count > 0 %} + {% for post in model.posts -%} +
    +
    + + {%- if post.published_on -%} + Published {{ post.published_on | date: "MMMM d, yyyy" }} + {%- else -%} + Not Published + {%- endif -%} + {%- if post.published_on != post.updated_on -%} + (Updated {{ post.updated_on | date: "MMMM d, yyyy" }}) + {%- endif %} + + + {%- if post.published_on -%} + {{ post.published_on | date: "MMMM d, yyyy" }} + {%- else -%} + Not Published + {%- endif -%} {%- if post.published_on != post.updated_on %}
    {{ post.updated_on | date: "MMMM d, yyyy" }} {%- endif %} -
    - {{ post.title }}
    - - View Post - - Edit - - {%- capture post_del %}admin/post/{{ post.id }}/delete{% endcapture -%} - {%- capture post_del_link %}{{ post_del | relative_link }}{% endcapture -%} - - Delete - - -
    {{ model.authors | value: post.author_id }}{{ post.status }}{{ post.tags | join: ", " }}
    This web log has no posts
    + + +
    + {{ post.title }}
    + + View Post + + Edit + + {%- capture post_del %}admin/post/{{ post.id }}/delete{% endcapture -%} + {%- capture post_del_link %}{{ post_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    + {%- assign tag_count = post.tags | size -%} + + Authored by {{ model.authors | value: post.author_id }} | + {% if tag_count == 0 -%} + No + {%- else -%} + {{ tag_count }} + {%- endif %} Tag{% unless tag_count == 1 %}s{% endunless %} + + {{ model.authors | value: post.author_id }} +
    +
    + {{ post.tags | join: ", " }} +
    + + {%- endfor %} + {% else %} +
    +
    This web log has no posts
    +
    + {% endif %} + {% if model.newer_link or model.older_link %}
    @@ -63,7 +93,4 @@
    {% endif %} -
    - -
    diff --git a/src/MyWebLog/themes/admin/rss-settings.liquid b/src/MyWebLog/themes/admin/rss-settings.liquid index 6bb7a99..65ef133 100644 --- a/src/MyWebLog/themes/admin/rss-settings.liquid +++ b/src/MyWebLog/themes/admin/rss-settings.liquid @@ -67,47 +67,47 @@ Add a New Custom Feed - - - - - - - - - - {%- assign feed_count = custom_feeds | size -%} - {% if feed_count > 0 %} - {% for feed in custom_feeds %} - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
    SourceRelative PathPodcast?
    - {{ feed.source }}
    - - View Feed - - {%- capture feed_edit %}admin/settings/rss/{{ feed.id }}/edit{% endcapture -%} - Edit - - {%- capture feed_del %}admin/settings/rss/{{ feed.id }}/delete{% endcapture -%} - {%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%} - - Delete - - -
    {{ feed.path }}{% if feed.is_podcast %}Yes{% else %}No{% endif %}
    No custom feeds defined
    -
    + + {%- assign source_col = "col-12 col-md-6" -%} + {%- assign path_col = "col-12 col-md-6" -%} +
    +
    + FeedSource +
    +
    Relative Path
    +
    + {%- assign feed_count = custom_feeds | size -%} + {% if feed_count > 0 %} + {% for feed in custom_feeds %} +
    +
    + {{ feed.source }} + {%- if feed.is_podcast %}   PODCAST{% endif %}
    + + View Feed + + {%- capture feed_edit %}admin/settings/rss/{{ feed.id }}/edit{% endcapture -%} + Edit + + {%- capture feed_del %}admin/settings/rss/{{ feed.id }}/delete{% endcapture -%} + {%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%} + + Delete + + +
    +
    + Served at {{ feed.path }} + {{ feed.path }} +
    +
    + {% endfor %} + {% else %} +
    No custom feeds defined
    - - - - - - - - {% for map in mappings -%} - {%- assign map_id = mapping_ids | value: map.tag -%} - - - - - {%- endfor %} - -
    TagURL Value
    - {{ map.tag }}
    - - {%- capture map_edit %}admin/settings/tag-mapping/{{ map_id }}/edit{% endcapture -%} - Edit - - {%- capture map_del %}admin/settings/tag-mapping/{{ map_id }}/delete{% endcapture -%} - {%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%} - - Delete - - -
    {{ map.url_value }}
    -
    - -
    +
    +
    +
    Tag
    +
    URL Value
    +
    +
    + {{ tag_mapping_list }}
    diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.css b/src/MyWebLog/wwwroot/themes/admin/admin.css index e17d067..3802723 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.css +++ b/src/MyWebLog/wwwroot/themes/admin/admin.css @@ -1,5 +1,6 @@ :root { --dark-gray: #212529; + --light-accent: rgba(0, 0, 0, .075); } html { background-color: var(--dark-gray); @@ -72,3 +73,15 @@ a.text-danger:link:hover, a.text-danger:visited:hover { border-radius: 0.25rem; color: white !important; } +.mwl-table-heading { + font-size: 1.1rem; + font-weight: bold; + border-bottom: solid 1px var(--bs-dark); +} +.mwl-table-detail { + border-bottom: solid 1px var(--light-accent); +} +.mwl-table-detail:hover { + background-color: var(--light-accent); + color: var(--dark-gray); +} diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index 20c5bef..9c2b9b9 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -181,61 +181,51 @@ }, /** - * Confirm and delete an item - * @param name The name of the item to be deleted - * @param url The URL to which the form should be posted + * Show messages that may have come with an htmx response + * @param messages The messages from the response */ - deleteItem(name, url) { - if (confirm(`Are you sure you want to delete the ${name}? This action cannot be undone.`)) { - const form = document.getElementById("deleteForm") - form.action = url - form.submit() - } - return false - }, - - /** - * Confirm and delete a category - * @param name The name of the category to be deleted - * @param url The URL to which the form should be posted - */ - deleteCategory(name, url) { - return this.deleteItem(`category "${name}"`, url) + showMessage(messages) { + const msgs = messages.split(", ") + msgs.forEach(msg => { + const parts = msg.split("|||") + if (parts.length < 2) return + + const msgDiv = document.createElement("div") + msgDiv.className = `alert alert-${parts[0]} alert-dismissible fade show` + msgDiv.setAttribute("role", "alert") + msgDiv.innerHTML = parts[1] + + const closeBtn = document.createElement("button") + closeBtn.type = "button" + closeBtn.className = "btn-close" + closeBtn.setAttribute("data-bs-dismiss", "alert") + closeBtn.setAttribute("aria-label", "Close") + msgDiv.appendChild(closeBtn) + + if (parts.length === 3) { + msgDiv.innerHTML += `
    ${parts[2]}` + } + document.getElementById("msgContainer").appendChild(msgDiv) + }) }, /** - * Confirm and delete a custom RSS feed - * @param source The source for the feed to be deleted - * @param url The URL to which the form should be posted + * Set all "success" alerts to close after 4 seconds */ - deleteCustomFeed(source, url) { - return this.deleteItem(`custom RSS feed based on ${source}`, url) - }, - - /** - * Confirm and delete a page - * @param title The title of the page to be deleted - * @param url The URL to which the form should be posted - */ - deletePage(title, url) { - return this.deleteItem(`page "${title}"`, url) - }, - - /** - * Confirm and delete a post - * @param title The title of the post to be deleted - * @param url The URL to which the form should be posted - */ - deletePost(title, url) { - return this.deleteItem(`post "${title}"`, url) - }, - - /** - * Confirm and delete a tag mapping - * @param tag The tag for which the mapping will be deleted - * @param url The URL to which the form should be posted - */ - deleteTagMapping(tag, url) { - return this.deleteItem(`mapping for "${tag}"`, url) + dismissSuccesses() { + [...document.querySelectorAll(".alert-success")].forEach(alert => { + setTimeout(() => { + (bootstrap.Alert.getInstance(alert) ?? new bootstrap.Alert(alert)).close() + }, 4000) + }) } } + +htmx.on("htmx:afterOnLoad", function (evt) { + const hdrs = evt.detail.xhr.getAllResponseHeaders() + // Show messages if there were any in the response + if (hdrs.indexOf("x-message") >= 0) { + Admin.showMessage(evt.detail.xhr.getResponseHeader("x-message")) + Admin.dismissSuccesses() + } +}) \ No newline at end of file -- 2.45.1 From dd3bba8310c2d5266b8720501146c55846185f22 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 2 Jun 2022 07:37:11 -0400 Subject: [PATCH 074/102] Fix /page/x/ URLs - Make log on push URL to history - Fix dashboard post links --- src/MyWebLog/Handlers/Post.fs | 4 ++++ src/MyWebLog/Handlers/Routes.fs | 1 + src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/admin/dashboard.liquid | 4 ++-- src/MyWebLog/themes/admin/log-on.liquid | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 7f6da60..faa7aff 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -111,6 +111,10 @@ let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { return! themedView "index" next ctx hash } +// GET /page/{pageNbr}/ +let redirectToPageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> + redirectTo true (WebLog.relativeUrl ctx.WebLog (Permalink $"page/{pageNbr}")) next ctx + // GET /category/{slug}/ // GET /category/{slug}/page/{pageNbr} let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 75ac4c3..f67d24b 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -160,6 +160,7 @@ let router : HttpHandler = choose [ ]) GET >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts GET >=> routef "/page/%i" Post.pageOfPosts + GET >=> routef "/page/%i/" Post.redirectToPageOfPosts GET >=> routexp "/tag/(.*)" Post.pageOfTaggedPosts subRoute "/user" (choose [ GET >=> choose [ diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index 29f11b0..e2577c7 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha26", + "Generator": "myWebLog 2.0-alpha27", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" diff --git a/src/MyWebLog/themes/admin/dashboard.liquid b/src/MyWebLog/themes/admin/dashboard.liquid index fb0ece6..c7046ea 100644 --- a/src/MyWebLog/themes/admin/dashboard.liquid +++ b/src/MyWebLog/themes/admin/dashboard.liquid @@ -9,8 +9,8 @@ Published {{ model.posts }}   Drafts {{ model.drafts }} - View All - Write a New Post + View All + Write a New Post
    diff --git a/src/MyWebLog/themes/admin/log-on.liquid b/src/MyWebLog/themes/admin/log-on.liquid index 5452859..ef5960a 100644 --- a/src/MyWebLog/themes/admin/log-on.liquid +++ b/src/MyWebLog/themes/admin/log-on.liquid @@ -1,6 +1,6 @@ 

    Log On to {{ web_log.name }}

    -
    + {% if model.return_to %} -- 2.45.1 From e65fcc3831fbf10edd94eb1cd3458bc564a3d3b3 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 2 Jun 2022 07:38:53 -0400 Subject: [PATCH 075/102] Remove old project --- src/MyWebLog.App/App.fs | 155 ------ src/MyWebLog.App/AppConfig.fs | 33 -- src/MyWebLog.App/AssemblyInfo.fs | 21 - src/MyWebLog.App/Data/Category.fs | 140 ----- src/MyWebLog.App/Data/DataConfig.fs | 43 -- src/MyWebLog.App/Data/Extensions.fs | 16 - src/MyWebLog.App/Data/Page.fs | 98 ---- src/MyWebLog.App/Data/Post.fs | 225 -------- src/MyWebLog.App/Data/RethinkMyWebLogData.fs | 48 -- src/MyWebLog.App/Data/SetUp.fs | 100 ---- src/MyWebLog.App/Data/Table.fs | 21 - src/MyWebLog.App/Data/User.fs | 31 -- src/MyWebLog.App/Data/WebLog.fs | 39 -- src/MyWebLog.App/Entities/Entities.fs | 301 ----------- src/MyWebLog.App/Entities/IMyWebLogData.fs | 117 ---- src/MyWebLog.App/Keys.fs | 17 - src/MyWebLog.App/Logic/Category.fs | 56 -- src/MyWebLog.App/Logic/Page.fs | 29 - src/MyWebLog.App/Logic/Post.fs | 60 --- src/MyWebLog.App/Logic/User.fs | 9 - src/MyWebLog.App/Logic/WebLog.fs | 11 - src/MyWebLog.App/Modules/AdminModule.fs | 22 - src/MyWebLog.App/Modules/CategoryModule.fs | 97 ---- src/MyWebLog.App/Modules/ModuleExtensions.fs | 36 -- src/MyWebLog.App/Modules/PageModule.fs | 97 ---- src/MyWebLog.App/Modules/PostModule.fs | 317 ----------- src/MyWebLog.App/Modules/UserModule.fs | 64 --- src/MyWebLog.App/MyWebLog.App.xproj | 21 - src/MyWebLog.App/Strings.fs | 42 -- src/MyWebLog.App/ViewModels.fs | 504 ------------------ src/MyWebLog.App/en-US.json | 83 --- src/MyWebLog.App/project.json | 64 --- src/MyWebLog.Old/MyWebLog.xproj | 21 - src/MyWebLog.Old/Program.cs | 10 - src/MyWebLog.Old/Properties/AssemblyInfo.cs | 15 - src/MyWebLog.Old/config.json | 17 - src/MyWebLog.Old/content/logo-dark.png | Bin 3362 -> 0 bytes src/MyWebLog.Old/content/logo-light.png | Bin 4135 -> 0 bytes src/MyWebLog.Old/project.json | 26 - .../views/admin/admin-layout.html | 52 -- .../views/admin/category/edit.html | 55 -- .../views/admin/category/list.html | 51 -- .../views/admin/content/admin.css | 5 - .../views/admin/content/tinymce-init.js | 10 - src/MyWebLog.Old/views/admin/dashboard.html | 31 -- src/MyWebLog.Old/views/admin/page/edit.html | 61 --- src/MyWebLog.Old/views/admin/page/list.html | 42 -- src/MyWebLog.Old/views/admin/post/edit.html | 90 ---- src/MyWebLog.Old/views/admin/post/list.html | 49 -- src/MyWebLog.Old/views/admin/user/log-on.html | 41 -- .../views/themes/default/comment.html | 4 - .../default/content/bootstrap-theme.css | 476 ----------------- .../default/content/bootstrap-theme.css.map | 1 - .../default/content/bootstrap-theme.min.css | 5 - .../views/themes/default/footer.html | 10 - .../views/themes/default/index-content.html | 43 -- .../views/themes/default/index.html | 9 - .../views/themes/default/layout.html | 48 -- .../views/themes/default/page-content.html | 4 - .../views/themes/default/page.html | 9 - .../views/themes/default/single-content.html | 67 --- .../views/themes/default/single.html | 9 - src/MyWebLog.Tests/MyWebLog.Tests.fs | 4 - src/MyWebLog.Tests/MyWebLog.Tests.fsproj | 70 --- 64 files changed, 4252 deletions(-) delete mode 100644 src/MyWebLog.App/App.fs delete mode 100644 src/MyWebLog.App/AppConfig.fs delete mode 100644 src/MyWebLog.App/AssemblyInfo.fs delete mode 100644 src/MyWebLog.App/Data/Category.fs delete mode 100644 src/MyWebLog.App/Data/DataConfig.fs delete mode 100644 src/MyWebLog.App/Data/Extensions.fs delete mode 100644 src/MyWebLog.App/Data/Page.fs delete mode 100644 src/MyWebLog.App/Data/Post.fs delete mode 100644 src/MyWebLog.App/Data/RethinkMyWebLogData.fs delete mode 100644 src/MyWebLog.App/Data/SetUp.fs delete mode 100644 src/MyWebLog.App/Data/Table.fs delete mode 100644 src/MyWebLog.App/Data/User.fs delete mode 100644 src/MyWebLog.App/Data/WebLog.fs delete mode 100644 src/MyWebLog.App/Entities/Entities.fs delete mode 100644 src/MyWebLog.App/Entities/IMyWebLogData.fs delete mode 100644 src/MyWebLog.App/Keys.fs delete mode 100644 src/MyWebLog.App/Logic/Category.fs delete mode 100644 src/MyWebLog.App/Logic/Page.fs delete mode 100644 src/MyWebLog.App/Logic/Post.fs delete mode 100644 src/MyWebLog.App/Logic/User.fs delete mode 100644 src/MyWebLog.App/Logic/WebLog.fs delete mode 100644 src/MyWebLog.App/Modules/AdminModule.fs delete mode 100644 src/MyWebLog.App/Modules/CategoryModule.fs delete mode 100644 src/MyWebLog.App/Modules/ModuleExtensions.fs delete mode 100644 src/MyWebLog.App/Modules/PageModule.fs delete mode 100644 src/MyWebLog.App/Modules/PostModule.fs delete mode 100644 src/MyWebLog.App/Modules/UserModule.fs delete mode 100644 src/MyWebLog.App/MyWebLog.App.xproj delete mode 100644 src/MyWebLog.App/Strings.fs delete mode 100644 src/MyWebLog.App/ViewModels.fs delete mode 100644 src/MyWebLog.App/en-US.json delete mode 100644 src/MyWebLog.App/project.json delete mode 100644 src/MyWebLog.Old/MyWebLog.xproj delete mode 100644 src/MyWebLog.Old/Program.cs delete mode 100644 src/MyWebLog.Old/Properties/AssemblyInfo.cs delete mode 100644 src/MyWebLog.Old/config.json delete mode 100644 src/MyWebLog.Old/content/logo-dark.png delete mode 100644 src/MyWebLog.Old/content/logo-light.png delete mode 100644 src/MyWebLog.Old/project.json delete mode 100644 src/MyWebLog.Old/views/admin/admin-layout.html delete mode 100644 src/MyWebLog.Old/views/admin/category/edit.html delete mode 100644 src/MyWebLog.Old/views/admin/category/list.html delete mode 100644 src/MyWebLog.Old/views/admin/content/admin.css delete mode 100644 src/MyWebLog.Old/views/admin/content/tinymce-init.js delete mode 100644 src/MyWebLog.Old/views/admin/dashboard.html delete mode 100644 src/MyWebLog.Old/views/admin/page/edit.html delete mode 100644 src/MyWebLog.Old/views/admin/page/list.html delete mode 100644 src/MyWebLog.Old/views/admin/post/edit.html delete mode 100644 src/MyWebLog.Old/views/admin/post/list.html delete mode 100644 src/MyWebLog.Old/views/admin/user/log-on.html delete mode 100644 src/MyWebLog.Old/views/themes/default/comment.html delete mode 100644 src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css delete mode 100644 src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css.map delete mode 100644 src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.min.css delete mode 100644 src/MyWebLog.Old/views/themes/default/footer.html delete mode 100644 src/MyWebLog.Old/views/themes/default/index-content.html delete mode 100644 src/MyWebLog.Old/views/themes/default/index.html delete mode 100644 src/MyWebLog.Old/views/themes/default/layout.html delete mode 100644 src/MyWebLog.Old/views/themes/default/page-content.html delete mode 100644 src/MyWebLog.Old/views/themes/default/page.html delete mode 100644 src/MyWebLog.Old/views/themes/default/single-content.html delete mode 100644 src/MyWebLog.Old/views/themes/default/single.html delete mode 100644 src/MyWebLog.Tests/MyWebLog.Tests.fs delete mode 100644 src/MyWebLog.Tests/MyWebLog.Tests.fsproj diff --git a/src/MyWebLog.App/App.fs b/src/MyWebLog.App/App.fs deleted file mode 100644 index 96a3a13..0000000 --- a/src/MyWebLog.App/App.fs +++ /dev/null @@ -1,155 +0,0 @@ -module MyWebLog.App - -open MyWebLog -open MyWebLog.Data -open MyWebLog.Data.RethinkDB -open MyWebLog.Entities -open MyWebLog.Logic.WebLog -open MyWebLog.Resources -open Nancy -open Nancy.Authentication.Forms -open Nancy.Bootstrapper -open Nancy.Conventions -open Nancy.Cryptography -open Nancy.Owin -open Nancy.Security -open Nancy.Session.Persistable -//open Nancy.Session.Relational -open Nancy.Session.RethinkDB -open Nancy.TinyIoc -open Nancy.ViewEngines.SuperSimpleViewEngine -open NodaTime -open RethinkDb.Driver.Net -open Suave -open Suave.Owin -open System -open System.IO -open System.Reflection -open System.Security.Claims -open System.Text.RegularExpressions - -/// Establish the configuration for this instance -let cfg = try AppConfig.FromJson (System.IO.File.ReadAllText "config.json") - with ex -> raise <| Exception (Strings.get "ErrBadAppConfig", ex) - -let data = lazy (RethinkMyWebLogData (cfg.DataConfig.Conn, cfg.DataConfig) :> IMyWebLogData) - -/// Support RESX lookup via the @Translate SSVE alias -type TranslateTokenViewEngineMatcher() = - static let regex = Regex ("@Translate\.(?[a-zA-Z0-9-_]+);?", RegexOptions.Compiled) - interface ISuperSimpleViewEngineMatcher with - member this.Invoke (content, model, host) = - let translate (m : Match) = Strings.get m.Groups.["TranslationKey"].Value - regex.Replace(content, translate) - - -/// Handle forms authentication -type MyWebLogUser (claims : Claim seq) = - inherit ClaimsPrincipal (ClaimsIdentity (claims, "forms")) - - new (user : User) = - // TODO: refactor the User.Claims property to produce this, and just pass it as the constructor - let claims = - seq { - yield Claim (ClaimTypes.Name, user.PreferredName) - for claim in user.Claims -> Claim (ClaimTypes.Role, claim) - } - MyWebLogUser claims - -type MyWebLogUserMapper (container : TinyIoCContainer) = - - interface IUserMapper with - member this.GetUserFromIdentifier (identifier, context) = - match context.Request.PersistableSession.GetOrDefault (Keys.User, User.Empty) with - | user when user.Id = string identifier -> upcast MyWebLogUser user - | _ -> null - - -/// Set up the application environment -type MyWebLogBootstrapper() = - inherit DefaultNancyBootstrapper() - - override this.ConfigureRequestContainer (container, context) = - base.ConfigureRequestContainer (container, context) - /// User mapper for forms authentication - container.Register() - |> ignore - - override this.ConfigureConventions (conventions) = - base.ConfigureConventions conventions - conventions.StaticContentsConventions.Add - (StaticContentConventionBuilder.AddDirectory ("admin/content", "views/admin/content")) - // Make theme content available at [theme-name]/ - Directory.EnumerateDirectories (Path.Combine [| "views"; "themes" |]) - |> Seq.map (fun themeDir -> themeDir, Path.Combine [| themeDir; "content" |]) - |> Seq.filter (fun (_, contentDir) -> Directory.Exists contentDir) - |> Seq.iter (fun (themeDir, contentDir) -> - conventions.StaticContentsConventions.Add - (StaticContentConventionBuilder.AddDirectory ((Path.GetFileName themeDir), contentDir))) - - override this.ConfigureApplicationContainer (container) = - base.ConfigureApplicationContainer container - container.Register cfg - |> ignore - data.Force().SetUp () - container.Register (data.Force ()) - |> ignore - // NodaTime - container.Register SystemClock.Instance - |> ignore - // I18N in SSVE - container.Register (fun _ _ -> - Seq.singleton (TranslateTokenViewEngineMatcher () :> ISuperSimpleViewEngineMatcher)) - |> ignore - - override this.ApplicationStartup (container, pipelines) = - base.ApplicationStartup (container, pipelines) - // Forms authentication configuration - let auth = - FormsAuthenticationConfiguration ( - CryptographyConfiguration = - CryptographyConfiguration ( - AesEncryptionProvider (PassphraseKeyGenerator (cfg.AuthEncryptionPassphrase, cfg.AuthSalt)), - DefaultHmacProvider (PassphraseKeyGenerator (cfg.AuthHmacPassphrase, cfg.AuthSalt))), - RedirectUrl = "~/user/log-on", - UserMapper = container.Resolve ()) - FormsAuthentication.Enable (pipelines, auth) - // CSRF - Csrf.Enable pipelines - // Sessions - let sessions = RethinkDBSessionConfiguration cfg.DataConfig.Conn - sessions.Database <- cfg.DataConfig.Database - //let sessions = RelationalSessionConfiguration(ConfigurationManager.ConnectionStrings.["SessionStore"].ConnectionString) - PersistableSessions.Enable (pipelines, sessions) - () - - override this.Configure (environment) = - base.Configure environment - environment.Tracing (true, true) - - -let version = - let v = typeof.GetTypeInfo().Assembly.GetName().Version - match v.Build with - | 0 -> match v.Minor with 0 -> string v.Major | _ -> sprintf "%d.%d" v.Major v.Minor - | _ -> sprintf "%d.%d.%d" v.Major v.Minor v.Build - |> sprintf "v%s" - -/// Set up the request environment -type RequestEnvironment() = - interface IRequestStartup with - member this.Initialize (pipelines, context) = - let establishEnv (ctx : NancyContext) = - ctx.Items.[Keys.RequestStart] <- DateTime.Now.Ticks - match tryFindWebLogByUrlBase (data.Force ()) ctx.Request.Url.HostName with - | Some webLog -> ctx.Items.[Keys.WebLog] <- webLog - | None -> // TODO: redirect to domain set up page - Exception (sprintf "%s %s" ctx.Request.Url.HostName (Strings.get "ErrNotConfigured")) - |> raise - ctx.Items.[Keys.Version] <- version - null - pipelines.BeforeRequest.AddItemToStartOfPipeline establishEnv - -let Run () = - OwinApp.ofMidFunc "/" (NancyMiddleware.UseNancy (NancyOptions (Bootstrapper = new MyWebLogBootstrapper ()))) - |> startWebServer defaultConfig diff --git a/src/MyWebLog.App/AppConfig.fs b/src/MyWebLog.App/AppConfig.fs deleted file mode 100644 index 915a18e..0000000 --- a/src/MyWebLog.App/AppConfig.fs +++ /dev/null @@ -1,33 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Data.RethinkDB -open Newtonsoft.Json -open System.Text - -/// Configuration for this myWebLog instance -type AppConfig = - { /// The text from which to derive salt to use for passwords - [] - PasswordSaltString : string - /// The text from which to derive salt to use for forms authentication - [] - AuthSaltString : string - /// The encryption passphrase to use for forms authentication - [] - AuthEncryptionPassphrase : string - /// The HMAC passphrase to use for forms authentication - [] - AuthHmacPassphrase : string - /// The data configuration - [] - DataConfig : DataConfig } - with - /// The salt to use for passwords - member this.PasswordSalt = Encoding.UTF8.GetBytes this.PasswordSaltString - /// The salt to use for forms authentication - member this.AuthSalt = Encoding.UTF8.GetBytes this.AuthSaltString - - /// Deserialize the configuration from the JSON file - static member FromJson json = - let cfg = JsonConvert.DeserializeObject json - { cfg with DataConfig = DataConfig.Connect cfg.DataConfig } \ No newline at end of file diff --git a/src/MyWebLog.App/AssemblyInfo.fs b/src/MyWebLog.App/AssemblyInfo.fs deleted file mode 100644 index 0fb972b..0000000 --- a/src/MyWebLog.App/AssemblyInfo.fs +++ /dev/null @@ -1,21 +0,0 @@ -namespace MyWebLog.AssemblyInfo - -open System.Reflection -open System.Runtime.CompilerServices -open System.Runtime.InteropServices - -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] - -do - () \ No newline at end of file diff --git a/src/MyWebLog.App/Data/Category.fs b/src/MyWebLog.App/Data/Category.fs deleted file mode 100644 index 94c13cc..0000000 --- a/src/MyWebLog.App/Data/Category.fs +++ /dev/null @@ -1,140 +0,0 @@ -module MyWebLog.Data.RethinkDB.Category - -open MyWebLog.Entities -open RethinkDb.Driver.Ast - -let private r = RethinkDb.Driver.RethinkDB.R - -/// Get all categories for a web log -let getAllCategories conn (webLogId : string) = - async { - return! r.Table(Table.Category) - .GetAll(webLogId).OptArg("index", "WebLogId") - .OrderBy("Name") - .RunResultAsync conn - } - |> Async.RunSynchronously - -/// Get a specific category by its Id -let tryFindCategory conn webLogId catId : Category option = - async { - let! c = - r.Table(Table.Category) - .Get(catId) - .RunResultAsync conn - return - match box c with - | null -> None - | catt -> - let cat : Category = unbox catt - match cat.WebLogId = webLogId with true -> Some cat | _ -> None - } - |> Async.RunSynchronously - -/// Add a category -let addCategory conn (cat : Category) = - async { - do! r.Table(Table.Category) - .Insert(cat) - .RunResultAsync conn - } - |> Async.RunSynchronously - -type CategoryUpdateRecord = - { Name : string - Slug : string - Description : string option - ParentId : string option - } -/// Update a category -let updateCategory conn (cat : Category) = - match tryFindCategory conn cat.WebLogId cat.Id with - | Some _ -> - async { - do! r.Table(Table.Category) - .Get(cat.Id) - .Update( - { CategoryUpdateRecord.Name = cat.Name - Slug = cat.Slug - Description = cat.Description - ParentId = cat.ParentId - }) - .RunResultAsync conn - } - |> Async.RunSynchronously - | _ -> () - -/// Update a category's children -let updateChildren conn webLogId parentId (children : string list) = - match tryFindCategory conn webLogId parentId with - | Some _ -> - async { - do! r.Table(Table.Category) - .Get(parentId) - .Update(dict [ "Children", children ]) - .RunResultAsync conn - } - |> Async.RunSynchronously - | _ -> () - -/// Delete a category -let deleteCategory conn (cat : Category) = - async { - // Remove the category from its parent - match cat.ParentId with - | Some parentId -> - match tryFindCategory conn cat.WebLogId parentId with - | Some parent -> parent.Children - |> List.filter (fun childId -> childId <> cat.Id) - |> updateChildren conn cat.WebLogId parentId - | _ -> () - | _ -> () - // Move this category's children to its parent - cat.Children - |> List.map (fun childId -> - match tryFindCategory conn cat.WebLogId childId with - | Some _ -> - async { - do! r.Table(Table.Category) - .Get(childId) - .Update(dict [ "ParentId", cat.ParentId ]) - .RunResultAsync conn - } - |> Some - | _ -> None) - |> List.filter Option.isSome - |> List.map Option.get - |> List.iter Async.RunSynchronously - // Remove the category from posts where it is assigned - let! posts = - r.Table(Table.Post) - .GetAll(cat.WebLogId).OptArg("index", "WebLogId") - .Filter(ReqlFunction1 (fun p -> upcast p.["CategoryIds"].Contains cat.Id)) - .RunResultAsync conn - |> Async.AwaitTask - posts - |> List.map (fun post -> - async { - do! r.Table(Table.Post) - .Get(post.Id) - .Update(dict [ "CategoryIds", post.CategoryIds |> List.filter (fun c -> c <> cat.Id) ]) - .RunResultAsync conn - }) - |> List.iter Async.RunSynchronously - // Now, delete the category - do! r.Table(Table.Category) - .Get(cat.Id) - .Delete() - .RunResultAsync conn - } - |> Async.RunSynchronously - -/// Get a category by its slug -let tryFindCategoryBySlug conn (webLogId : string) (slug : string) = - async { - let! cat = r.Table(Table.Category) - .GetAll(r.Array (webLogId, slug)).OptArg("index", "Slug") - .RunResultAsync conn - return cat |> List.tryHead - } - |> Async.RunSynchronously diff --git a/src/MyWebLog.App/Data/DataConfig.fs b/src/MyWebLog.App/Data/DataConfig.fs deleted file mode 100644 index 0d993c2..0000000 --- a/src/MyWebLog.App/Data/DataConfig.fs +++ /dev/null @@ -1,43 +0,0 @@ -namespace MyWebLog.Data.RethinkDB - -open RethinkDb.Driver -open RethinkDb.Driver.Net -open Newtonsoft.Json - -/// Data configuration -type DataConfig = - { /// The hostname for the RethinkDB server - [] - Hostname : string - /// The port for the RethinkDB server - [] - Port : int - /// The authorization key to use when connecting to the server - [] - AuthKey : string - /// How long an attempt to connect to the server should wait before giving up - [] - Timeout : int - /// The name of the default database to use on the connection - [] - Database : string - /// A connection to the RethinkDB server using the configuration in this object - [] - Conn : IConnection } -with - /// Use RethinkDB defaults for non-provided options, and connect to the server - static member Connect config = - let host cfg = match cfg.Hostname with null -> { cfg with Hostname = RethinkDBConstants.DefaultHostname } | _ -> cfg - let port cfg = match cfg.Port with 0 -> { cfg with Port = RethinkDBConstants.DefaultPort } | _ -> cfg - let auth cfg = match cfg.AuthKey with null -> { cfg with AuthKey = RethinkDBConstants.DefaultAuthkey } | _ -> cfg - let timeout cfg = match cfg.Timeout with 0 -> { cfg with Timeout = RethinkDBConstants.DefaultTimeout } | _ -> cfg - let db cfg = match cfg.Database with null -> { cfg with Database = RethinkDBConstants.DefaultDbName } | _ -> cfg - let connect cfg = - { cfg with Conn = RethinkDB.R.Connection() - .Hostname(cfg.Hostname) - .Port(cfg.Port) - .AuthKey(cfg.AuthKey) - .Db(cfg.Database) - .Timeout(cfg.Timeout) - .Connect () } - (host >> port >> auth >> timeout >> db >> connect) config diff --git a/src/MyWebLog.App/Data/Extensions.fs b/src/MyWebLog.App/Data/Extensions.fs deleted file mode 100644 index 82c7645..0000000 --- a/src/MyWebLog.App/Data/Extensions.fs +++ /dev/null @@ -1,16 +0,0 @@ -[] -module MyWebLog.Data.RethinkDB.Extensions - -open System.Threading.Tasks - -// H/T: Suave -type AsyncBuilder with - /// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on - /// a standard .NET task - member x.Bind(t : Task<'T>, f:'T -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f) - - /// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on - /// a standard .NET task which does not commpute a value - member x.Bind(t : Task, f : unit -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f) - - member x.ReturnFrom(t : Task<'T>) = Async.AwaitTask t diff --git a/src/MyWebLog.App/Data/Page.fs b/src/MyWebLog.App/Data/Page.fs deleted file mode 100644 index 34db086..0000000 --- a/src/MyWebLog.App/Data/Page.fs +++ /dev/null @@ -1,98 +0,0 @@ -module MyWebLog.Data.RethinkDB.Page - -open MyWebLog.Entities -open RethinkDb.Driver.Ast - -let private r = RethinkDb.Driver.RethinkDB.R - -/// Try to find a page by its Id, optionally including revisions -let tryFindPageById conn webLogId (pageId : string) includeRevs = - async { - let q = - r.Table(Table.Page) - .Get pageId - let! thePage = - match includeRevs with - | true -> q.RunResultAsync conn - | _ -> q.Without("Revisions").RunResultAsync conn - return - match box thePage with - | null -> None - | page -> - let pg : Page = unbox page - match pg.WebLogId = webLogId with true -> Some pg | _ -> None - } - |> Async.RunSynchronously - -/// Find a page by its permalink -let tryFindPageByPermalink conn (webLogId : string) (permalink : string) = - async { - let! pg = - r.Table(Table.Page) - .GetAll(r.Array (webLogId, permalink)).OptArg("index", "Permalink") - .Without("Revisions") - .RunResultAsync conn - return List.tryHead pg - } - |> Async.RunSynchronously - -/// Get a list of all pages (excludes page text and revisions) -let findAllPages conn (webLogId : string) = - async { - return! - r.Table(Table.Page) - .GetAll(webLogId).OptArg("index", "WebLogId") - .OrderBy("Title") - .Without("Text", "Revisions") - .RunResultAsync conn - } - |> Async.RunSynchronously - -/// Add a page -let addPage conn (page : Page) = - async { - do! r.Table(Table.Page) - .Insert(page) - .RunResultAsync conn - } - |> (Async.RunSynchronously >> ignore) - -type PageUpdateRecord = - { Title : string - Permalink : string - PublishedOn : int64 - UpdatedOn : int64 - ShowInPageList : bool - Text : string - Revisions : Revision list } -/// Update a page -let updatePage conn (page : Page) = - match tryFindPageById conn page.WebLogId page.Id false with - | Some _ -> - async { - do! r.Table(Table.Page) - .Get(page.Id) - .Update({ PageUpdateRecord.Title = page.Title - Permalink = page.Permalink - PublishedOn = page.PublishedOn - UpdatedOn = page.UpdatedOn - ShowInPageList = page.ShowInPageList - Text = page.Text - Revisions = page.Revisions }) - .RunResultAsync conn - } - |> (Async.RunSynchronously >> ignore) - | _ -> () - -/// Delete a page -let deletePage conn webLogId pageId = - match tryFindPageById conn webLogId pageId false with - | Some _ -> - async { - do! r.Table(Table.Page) - .Get(pageId) - .Delete() - .RunResultAsync conn - } - |> (Async.RunSynchronously >> ignore) - | _ -> () diff --git a/src/MyWebLog.App/Data/Post.fs b/src/MyWebLog.App/Data/Post.fs deleted file mode 100644 index 4d4bb14..0000000 --- a/src/MyWebLog.App/Data/Post.fs +++ /dev/null @@ -1,225 +0,0 @@ -module MyWebLog.Data.RethinkDB.Post - -open MyWebLog.Entities -open RethinkDb.Driver.Ast - -let private r = RethinkDb.Driver.RethinkDB.R - -/// Shorthand to select all published posts for a web log -let private publishedPosts (webLogId : string) = - r.Table(Table.Post) - .GetAll(r.Array (webLogId, PostStatus.Published)).OptArg("index", "WebLogAndStatus") - .Without("Revisions") - // This allows us to count comments without retrieving them all - .Merge(ReqlFunction1 (fun p -> - upcast r.HashMap( - "Comments", r.Table(Table.Comment) - .GetAll(p.["id"]).OptArg("index", "PostId") - .Pluck("id") - .CoerceTo("array")))) - - -/// Shorthand to sort posts by published date, slice for the given page, and return a list -let private toPostList conn pageNbr nbrPerPage (filter : ReqlExpr) = - async { - return! - filter - .OrderBy(r.Desc "PublishedOn") - .Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage) - .RunResultAsync conn - } - |> Async.RunSynchronously - -/// Shorthand to get a newer or older post -let private adjacentPost conn (post : Post) (theFilter : ReqlExpr -> obj) (sort : obj) = - async { - let! post = - (publishedPosts post.WebLogId) - .Filter(theFilter) - .OrderBy(sort) - .Limit(1) - .RunResultAsync conn - return List.tryHead post - } - |> Async.RunSynchronously - -/// Find a newer post -let private newerPost conn post theFilter = adjacentPost conn post theFilter <| r.Asc "PublishedOn" - -/// Find an older post -let private olderPost conn post theFilter = adjacentPost conn post theFilter <| r.Desc "PublishedOn" - -/// Get a page of published posts -let findPageOfPublishedPosts conn webLogId pageNbr nbrPerPage = - publishedPosts webLogId - |> toPostList conn pageNbr nbrPerPage - -/// Get a page of published posts assigned to a given category -let findPageOfCategorizedPosts conn webLogId (categoryId : string) pageNbr nbrPerPage = - (publishedPosts webLogId) - .Filter(ReqlFunction1 (fun p -> upcast p.["CategoryIds"].Contains categoryId)) - |> toPostList conn pageNbr nbrPerPage - -/// Get a page of published posts tagged with a given tag -let findPageOfTaggedPosts conn webLogId (tag : string) pageNbr nbrPerPage = - (publishedPosts webLogId) - .Filter(ReqlFunction1 (fun p -> upcast p.["Tags"].Contains tag)) - |> toPostList conn pageNbr nbrPerPage - -/// Try to get the next newest post from the given post -let tryFindNewerPost conn post = newerPost conn post (fun p -> upcast p.["PublishedOn"].Gt post.PublishedOn) - -/// Try to get the next newest post assigned to the given category -let tryFindNewerCategorizedPost conn (categoryId : string) post = - newerPost conn post (fun p -> upcast p.["PublishedOn"].Gt(post.PublishedOn) - .And(p.["CategoryIds"].Contains categoryId)) - -/// Try to get the next newest tagged post from the given tagged post -let tryFindNewerTaggedPost conn (tag : string) post = - newerPost conn post (fun p -> upcast p.["PublishedOn"].Gt(post.PublishedOn).And(p.["Tags"].Contains tag)) - -/// Try to get the next oldest post from the given post -let tryFindOlderPost conn post = olderPost conn post (fun p -> upcast p.["PublishedOn"].Lt post.PublishedOn) - -/// Try to get the next oldest post assigned to the given category -let tryFindOlderCategorizedPost conn (categoryId : string) post = - olderPost conn post (fun p -> upcast p.["PublishedOn"].Lt(post.PublishedOn) - .And(p.["CategoryIds"].Contains categoryId)) - -/// Try to get the next oldest tagged post from the given tagged post -let tryFindOlderTaggedPost conn (tag : string) post = - olderPost conn post (fun p -> upcast p.["PublishedOn"].Lt(post.PublishedOn).And(p.["Tags"].Contains tag)) - -/// Get a page of all posts in all statuses -let findPageOfAllPosts conn (webLogId : string) pageNbr nbrPerPage = - // FIXME: sort unpublished posts by their last updated date - async { - // .orderBy(r.desc(r.branch(r.row("Status").eq("Published"), r.row("PublishedOn"), r.row("UpdatedOn")))) - return! - r.Table(Table.Post) - .GetAll(webLogId).OptArg("index", "WebLogId") - .OrderBy(r.Desc (ReqlFunction1 (fun p -> - upcast r.Branch (p.["Status"].Eq("Published"), p.["PublishedOn"], p.["UpdatedOn"])))) - .Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage) - .RunResultAsync conn - } - |> Async.RunSynchronously - -/// Try to find a post by its Id and web log Id -let tryFindPost conn webLogId postId : Post option = - async { - let! p = - r.Table(Table.Post) - .Get(postId) - .RunAtomAsync conn - return - match box p with - | null -> None - | pst -> - let post : Post = unbox pst - match post.WebLogId = webLogId with true -> Some post | _ -> None - } - |> Async.RunSynchronously - -/// Try to find a post by its permalink -let tryFindPostByPermalink conn webLogId permalink = - async { - let! post = - r.Table(Table.Post) - .GetAll(r.Array (webLogId, permalink)).OptArg("index", "Permalink") - .Filter(ReqlFunction1 (fun p -> upcast p.["Status"].Eq PostStatus.Published)) - .Without("Revisions") - .Merge(ReqlFunction1 (fun p -> - upcast r.HashMap( - "Categories", r.Table(Table.Category) - .GetAll(r.Args p.["CategoryIds"]) - .Without("Children") - .OrderBy("Name") - .CoerceTo("array")).With( - "Comments", r.Table(Table.Comment) - .GetAll(p.["id"]).OptArg("index", "PostId") - .OrderBy("PostedOn") - .CoerceTo("array")))) - .RunResultAsync conn - return List.tryHead post - } - |> Async.RunSynchronously - -/// Try to find a post by its prior permalink -let tryFindPostByPriorPermalink conn (webLogId : string) (permalink : string) = - async { - let! post = - r.Table(Table.Post) - .GetAll(webLogId).OptArg("index", "WebLogId") - .Filter(ReqlFunction1 (fun p -> - upcast p.["PriorPermalinks"].Contains(permalink).And(p.["Status"].Eq PostStatus.Published))) - .Without("Revisions") - .RunResultAsync conn - return List.tryHead post - } - |> Async.RunSynchronously - -/// Get a set of posts for RSS -let findFeedPosts conn webLogId nbr : (Post * User option) list = - let tryFindUser userId = - async { - let! u = - r.Table(Table.User) - .Get(userId) - .RunAtomAsync conn - return match box u with null -> None | user -> Some <| unbox user - } - |> Async.RunSynchronously - (publishedPosts webLogId) - .Merge(ReqlFunction1 (fun post -> - upcast r.HashMap( - "Categories", r.Table(Table.Category) - .GetAll(r.Args post.["CategoryIds"]) - .OrderBy("Name") - .Pluck("id", "Name") - .CoerceTo("array")))) - |> toPostList conn 1 nbr - |> List.map (fun post -> post, tryFindUser post.AuthorId) - -/// Add a post -let addPost conn post = - async { - do! r.Table(Table.Post) - .Insert(post) - .RunResultAsync conn - } - |> (Async.RunSynchronously >> ignore) - -/// Update a post -let updatePost conn (post : Post) = - async { - do! r.Table(Table.Post) - .Get(post.Id) - .Replace( { post with Categories = [] - Comments = [] } ) - .RunResultAsync conn - } - |> (Async.RunSynchronously >> ignore) - -/// Save a post -let savePost conn (post : Post) = - match post.Id with - | "new" -> - let newPost = { post with Id = string <| System.Guid.NewGuid() } - async { - do! r.Table(Table.Post) - .Insert(newPost) - .RunResultAsync conn - } - |> Async.RunSynchronously - newPost.Id - | _ -> - async { - do! r.Table(Table.Post) - .Get(post.Id) - .Replace( { post with Categories = [] - Comments = [] } ) - .RunResultAsync conn - } - |> Async.RunSynchronously - post.Id diff --git a/src/MyWebLog.App/Data/RethinkMyWebLogData.fs b/src/MyWebLog.App/Data/RethinkMyWebLogData.fs deleted file mode 100644 index a7cf789..0000000 --- a/src/MyWebLog.App/Data/RethinkMyWebLogData.fs +++ /dev/null @@ -1,48 +0,0 @@ -namespace MyWebLog.Data.RethinkDB - -open MyWebLog.Data -open RethinkDb.Driver.Net - -/// RethinkDB implementation of myWebLog data persistence -type RethinkMyWebLogData(conn : IConnection, cfg : DataConfig) = - interface IMyWebLogData with - member __.SetUp = fun () -> SetUp.startUpCheck cfg - - member __.AllCategories = Category.getAllCategories conn - member __.CategoryById = Category.tryFindCategory conn - member __.CategoryBySlug = Category.tryFindCategoryBySlug conn - member __.AddCategory = Category.addCategory conn - member __.UpdateCategory = Category.updateCategory conn - member __.UpdateChildren = Category.updateChildren conn - member __.DeleteCategory = Category.deleteCategory conn - - member __.PageById = Page.tryFindPageById conn - member __.PageByPermalink = Page.tryFindPageByPermalink conn - member __.AllPages = Page.findAllPages conn - member __.AddPage = Page.addPage conn - member __.UpdatePage = Page.updatePage conn - member __.DeletePage = Page.deletePage conn - - member __.PageOfPublishedPosts = Post.findPageOfPublishedPosts conn - member __.PageOfCategorizedPosts = Post.findPageOfCategorizedPosts conn - member __.PageOfTaggedPosts = Post.findPageOfTaggedPosts conn - member __.NewerPost = Post.tryFindNewerPost conn - member __.NewerCategorizedPost = Post.tryFindNewerCategorizedPost conn - member __.NewerTaggedPost = Post.tryFindNewerTaggedPost conn - member __.OlderPost = Post.tryFindOlderPost conn - member __.OlderCategorizedPost = Post.tryFindOlderCategorizedPost conn - member __.OlderTaggedPost = Post.tryFindOlderTaggedPost conn - member __.PageOfAllPosts = Post.findPageOfAllPosts conn - member __.PostById = Post.tryFindPost conn - member __.PostByPermalink = Post.tryFindPostByPermalink conn - member __.PostByPriorPermalink = Post.tryFindPostByPriorPermalink conn - member __.FeedPosts = Post.findFeedPosts conn - member __.AddPost = Post.addPost conn - member __.UpdatePost = Post.updatePost conn - - member __.LogOn = User.tryUserLogOn conn - member __.SetUserPassword = User.setUserPassword conn - - member __.WebLogByUrlBase = WebLog.tryFindWebLogByUrlBase conn - member __.DashboardCounts = WebLog.findDashboardCounts conn - \ No newline at end of file diff --git a/src/MyWebLog.App/Data/SetUp.fs b/src/MyWebLog.App/Data/SetUp.fs deleted file mode 100644 index bd0341a..0000000 --- a/src/MyWebLog.App/Data/SetUp.fs +++ /dev/null @@ -1,100 +0,0 @@ -module MyWebLog.Data.RethinkDB.SetUp - -open RethinkDb.Driver.Ast -open System - -let private r = RethinkDb.Driver.RethinkDB.R -let private logStep step = Console.Out.WriteLine (sprintf "[myWebLog] %s" step) -let private logStepStart text = Console.Out.Write (sprintf "[myWebLog] %s..." text) -let private logStepDone () = Console.Out.WriteLine (" done.") - -/// Ensure the myWebLog database exists -let private checkDatabase (cfg : DataConfig) = - async { - logStep "|> Checking database" - let! dbs = r.DbList().RunResultAsync cfg.Conn - match List.contains cfg.Database dbs with - | true -> () - | _ -> logStepStart (sprintf " %s database not found - creating" cfg.Database) - do! r.DbCreate(cfg.Database).RunResultAsync cfg.Conn - logStepDone () - } - - -/// Ensure all required tables exist -let private checkTables cfg = - async { - logStep "|> Checking tables" - let! tables = r.Db(cfg.Database).TableList().RunResultAsync cfg.Conn - [ Table.Category; Table.Comment; Table.Page; Table.Post; Table.User; Table.WebLog ] - |> List.filter (fun tbl -> not (List.contains tbl tables)) - |> List.iter (fun tbl -> logStepStart (sprintf " Creating table %s" tbl) - async { do! (r.TableCreate tbl).RunResultAsync cfg.Conn } |> Async.RunSynchronously - logStepDone ()) - } - -/// Shorthand to get the table -let private tbl cfg table = r.Db(cfg.Database).Table table - -/// Create the given index -let private createIndex cfg table (index : string * (ReqlExpr -> obj) option) = - async { - let idxName, idxFunc = index - logStepStart (sprintf """ Creating index "%s" on table %s""" idxName table) - do! (match idxFunc with - | Some f -> (tbl cfg table).IndexCreate(idxName, f) - | None -> (tbl cfg table).IndexCreate(idxName)) - .RunResultAsync cfg.Conn - logStepDone () - } - -/// Ensure that the given indexes exist, and create them if required -let private ensureIndexes cfg (indexes : (string * (string * (ReqlExpr -> obj) option) list) list) = - let ensureForTable (tblName, idxs) = - async { - let! idx = (tbl cfg tblName).IndexList().RunResultAsync cfg.Conn - idxs - |> List.filter (fun (idxName, _) -> not (List.contains idxName idx)) - |> List.map (fun index -> createIndex cfg tblName index) - |> List.iter Async.RunSynchronously - } - |> Async.RunSynchronously - indexes - |> List.iter ensureForTable - -/// Create an index on web log Id and the given field -let private webLogField (name : string) : (ReqlExpr -> obj) option = - Some <| fun row -> upcast r.Array(row.["WebLogId"], row.[name]) - -/// Ensure all the required indexes exist -let private checkIndexes cfg = - logStep "|> Checking indexes" - [ Table.Category, [ "WebLogId", None - "Slug", webLogField "Slug" - ] - Table.Comment, [ "PostId", None - ] - Table.Page, [ "WebLogId", None - "Permalink", webLogField "Permalink" - ] - Table.Post, [ "WebLogId", None - "WebLogAndStatus", webLogField "Status" - "Permalink", webLogField "Permalink" - ] - Table.User, [ "UserName", None - ] - Table.WebLog, [ "UrlBase", None - ] - ] - |> ensureIndexes cfg - -/// Start up checks to ensure the database, tables, and indexes exist -let startUpCheck cfg = - async { - logStep "Database Start Up Checks Starting" - do! checkDatabase cfg - do! checkTables cfg - checkIndexes cfg - logStep "Database Start Up Checks Complete" - } - |> Async.RunSynchronously diff --git a/src/MyWebLog.App/Data/Table.fs b/src/MyWebLog.App/Data/Table.fs deleted file mode 100644 index 8a84652..0000000 --- a/src/MyWebLog.App/Data/Table.fs +++ /dev/null @@ -1,21 +0,0 @@ -/// Constants for tables used in myWebLog -[] -module MyWebLog.Data.RethinkDB.Table - -/// The Category table -let Category = "Category" - -/// The Comment table -let Comment = "Comment" - -/// The Page table -let Page = "Page" - -/// The Post table -let Post = "Post" - -/// The WebLog table -let WebLog = "WebLog" - -/// The User table -let User = "User" \ No newline at end of file diff --git a/src/MyWebLog.App/Data/User.fs b/src/MyWebLog.App/Data/User.fs deleted file mode 100644 index 0e4cecf..0000000 --- a/src/MyWebLog.App/Data/User.fs +++ /dev/null @@ -1,31 +0,0 @@ -module MyWebLog.Data.RethinkDB.User - -open MyWebLog.Entities -open RethinkDb.Driver.Ast - -let private r = RethinkDb.Driver.RethinkDB.R - -/// Log on a user -// NOTE: The significant length of a RethinkDB index is 238 - [PK size]; as we're storing 1,024 characters of password, -// including it in an index does not get any performance gain, and would unnecessarily bloat the index. See -// http://rethinkdb.com/docs/secondary-indexes/java/ for more information. -let tryUserLogOn conn (email : string) (passwordHash : string) = - async { - let! user = - r.Table(Table.User) - .GetAll(email).OptArg("index", "UserName") - .Filter(ReqlFunction1 (fun u -> upcast u.["PasswordHash"].Eq passwordHash)) - .RunResultAsync conn - return user |> List.tryHead - } - |> Async.RunSynchronously - -/// Set a user's password -let setUserPassword conn (email : string) (passwordHash : string) = - async { - do! r.Table(Table.User) - .GetAll(email).OptArg("index", "UserName") - .Update(dict [ "PasswordHash", passwordHash ]) - .RunResultAsync conn - } - |> Async.RunSynchronously \ No newline at end of file diff --git a/src/MyWebLog.App/Data/WebLog.fs b/src/MyWebLog.App/Data/WebLog.fs deleted file mode 100644 index 6d236f8..0000000 --- a/src/MyWebLog.App/Data/WebLog.fs +++ /dev/null @@ -1,39 +0,0 @@ -module MyWebLog.Data.RethinkDB.WebLog - -open MyWebLog.Entities -open RethinkDb.Driver.Ast - -let private r = RethinkDb.Driver.RethinkDB.R - -/// Detemine the web log by the URL base -let tryFindWebLogByUrlBase conn (urlBase : string) = - async { - let! cursor = - r.Table(Table.WebLog) - .GetAll(urlBase).OptArg("index", "UrlBase") - .Merge(ReqlFunction1 (fun w -> - upcast r.HashMap( - "PageList", r.Table(Table.Page) - .GetAll(w.G("id")).OptArg("index", "WebLogId") - .Filter(ReqlFunction1 (fun pg -> upcast pg.["ShowInPageList"].Eq true)) - .OrderBy("Title") - .Pluck("Title", "Permalink") - .CoerceTo("array")))) - .RunCursorAsync conn - return cursor |> Seq.tryHead - } - |> Async.RunSynchronously - -/// Get counts for the admin dashboard -let findDashboardCounts conn (webLogId : string) = - async { - return! - r.Expr( - r.HashMap( - "Pages", r.Table(Table.Page ).GetAll(webLogId).OptArg("index", "WebLogId").Count()).With( - "Posts", r.Table(Table.Post ).GetAll(webLogId).OptArg("index", "WebLogId").Count()).With( - "Categories", r.Table(Table.Category).GetAll(webLogId).OptArg("index", "WebLogId").Count())) - .RunResultAsync conn - } - |> Async.RunSynchronously - \ No newline at end of file diff --git a/src/MyWebLog.App/Entities/Entities.fs b/src/MyWebLog.App/Entities/Entities.fs deleted file mode 100644 index b35a111..0000000 --- a/src/MyWebLog.App/Entities/Entities.fs +++ /dev/null @@ -1,301 +0,0 @@ -namespace MyWebLog.Entities - -open Newtonsoft.Json - -// --- Constants --- - -/// Constants to use for revision source language -[] -module RevisionSource = - [] - let Markdown = "markdown" - [] - let HTML = "html" - -/// Constants to use for authorization levels -[] -module AuthorizationLevel = - [] - let Administrator = "Administrator" - [] - let User = "User" - -/// Constants to use for post statuses -[] -module PostStatus = - [] - let Draft = "Draft" - [] - let Published = "Published" - -/// Constants to use for comment statuses -[] -module CommentStatus = - [] - let Approved = "Approved" - [] - let Pending = "Pending" - [] - let Spam = "Spam" - -// --- Entities --- - -/// A revision of a post or page -type Revision = - { /// The instant which this revision was saved - AsOf : int64 - /// The source language - SourceType : string - /// The text - Text : string } -with - /// An empty revision - static member Empty = - { AsOf = int64 0 - SourceType = RevisionSource.HTML - Text = "" } - -/// A page with static content -type Page = - { /// The Id - [] - Id : string - /// The Id of the web log to which this page belongs - WebLogId : string - /// The Id of the author of this page - AuthorId : string - /// The title of the page - Title : string - /// The link at which this page is displayed - Permalink : string - /// The instant this page was published - PublishedOn : int64 - /// The instant this page was last updated - UpdatedOn : int64 - /// Whether this page shows as part of the web log's navigation - ShowInPageList : bool - /// The current text of the page - Text : string - /// Revisions of this page - Revisions : Revision list } -with - static member Empty = - { Id = "" - WebLogId = "" - AuthorId = "" - Title = "" - Permalink = "" - PublishedOn = int64 0 - UpdatedOn = int64 0 - ShowInPageList = false - Text = "" - Revisions = [] - } - - -/// An entry in the list of pages displayed as part of the web log (derived via query) -type PageListEntry = - { Permalink : string - Title : string } - -/// A web log -type WebLog = - { /// The Id - [] - Id : string - /// The name - Name : string - /// The subtitle - Subtitle : string option - /// The default page ("posts" or a page Id) - DefaultPage : string - /// The path of the theme (within /views/themes) - ThemePath : string - /// The URL base - UrlBase : string - /// The time zone in which dates/times should be displayed - TimeZone : string - /// A list of pages to be rendered as part of the site navigation (not stored) - PageList : PageListEntry list } -with - /// An empty web log - static member Empty = - { Id = "" - Name = "" - Subtitle = None - DefaultPage = "" - ThemePath = "default" - UrlBase = "" - TimeZone = "America/New_York" - PageList = [] } - - -/// An authorization between a user and a web log -type Authorization = - { /// The Id of the web log to which this authorization grants access - WebLogId : string - /// The level of access granted by this authorization - Level : string } - - -/// A user of myWebLog -type User = - { /// The Id - [] - Id : string - /// The user name (e-mail address) - UserName : string - /// The first name - FirstName : string - /// The last name - LastName : string - /// The user's preferred name - PreferredName : string - /// The hash of the user's password - PasswordHash : string - /// The URL of the user's personal site - Url : string option - /// The user's authorizations - Authorizations : Authorization list } -with - /// An empty user - static member Empty = - { Id = "" - UserName = "" - FirstName = "" - LastName = "" - PreferredName = "" - PasswordHash = "" - Url = None - Authorizations = [] } - - /// Claims for this user - [] - member this.Claims = this.Authorizations - |> List.map (fun auth -> sprintf "%s|%s" auth.WebLogId auth.Level) - - -/// A category to which posts may be assigned -type Category = - { /// The Id - [] - Id : string - /// The Id of the web log to which this category belongs - WebLogId : string - /// The displayed name - Name : string - /// The slug (used in category URLs) - Slug : string - /// A longer description of the category - Description : string option - /// The parent Id of this category (if a subcategory) - ParentId : string option - /// The categories for which this category is the parent - Children : string list } -with - /// An empty category - static member Empty = - { Id = "new" - WebLogId = "" - Name = "" - Slug = "" - Description = None - ParentId = None - Children = [] } - - -/// A comment (applies to a post) -type Comment = - { /// The Id - [] - Id : string - /// The Id of the post to which this comment applies - PostId : string - /// The Id of the comment to which this comment is a reply - InReplyToId : string option - /// The name of the commentor - Name : string - /// The e-mail address of the commentor - Email : string - /// The URL of the commentor's personal website - Url : string option - /// The status of the comment - Status : string - /// The instant the comment was posted - PostedOn : int64 - /// The text of the comment - Text : string } -with - static member Empty = - { Id = "" - PostId = "" - InReplyToId = None - Name = "" - Email = "" - Url = None - Status = CommentStatus.Pending - PostedOn = int64 0 - Text = "" } - - -/// A post -type Post = - { /// The Id - [] - Id : string - /// The Id of the web log to which this post belongs - WebLogId : string - /// The Id of the author of this post - AuthorId : string - /// The status - Status : string - /// The title - Title : string - /// The link at which the post resides - Permalink : string - /// The instant on which the post was originally published - PublishedOn : int64 - /// The instant on which the post was last updated - UpdatedOn : int64 - /// The text of the post - Text : string - /// The Ids of the categories to which this is assigned - CategoryIds : string list - /// The tags for the post - Tags : string list - /// The permalinks at which this post may have once resided - PriorPermalinks : string list - /// Revisions of this post - Revisions : Revision list - /// The categories to which this is assigned (not stored in database) - Categories : Category list - /// The comments (not stored in database) - Comments : Comment list } -with - static member Empty = - { Id = "new" - WebLogId = "" - AuthorId = "" - Status = PostStatus.Draft - Title = "" - Permalink = "" - PublishedOn = int64 0 - UpdatedOn = int64 0 - Text = "" - CategoryIds = [] - Tags = [] - PriorPermalinks = [] - Revisions = [] - Categories = [] - Comments = [] } - -// --- UI Support --- - -/// Counts of items displayed on the admin dashboard -type DashboardCounts = - { /// The number of pages for the web log - Pages : int - /// The number of pages for the web log - Posts : int - /// The number of categories for the web log - Categories : int } diff --git a/src/MyWebLog.App/Entities/IMyWebLogData.fs b/src/MyWebLog.App/Entities/IMyWebLogData.fs deleted file mode 100644 index 2972f79..0000000 --- a/src/MyWebLog.App/Entities/IMyWebLogData.fs +++ /dev/null @@ -1,117 +0,0 @@ -namespace MyWebLog.Data - -open MyWebLog.Entities - -/// Interface required to provide data to myWebLog's logic layer -type IMyWebLogData = - /// Function to set up the data store - abstract SetUp : (unit -> unit) - - // --- Category --- - - /// Get all categories for a web log - abstract AllCategories : (string -> Category list) - - /// Try to find a category by its Id and web log Id (web log, category Ids) - abstract CategoryById : (string -> string -> Category option) - - /// Try to find a category by its slug (web log Id, slug) - abstract CategoryBySlug : (string -> string -> Category option) - - /// Add a category - abstract AddCategory : (Category -> unit) - - /// Update a category - abstract UpdateCategory : (Category -> unit) - - /// Update a category's children - abstract UpdateChildren : (string -> string -> string list -> unit) - - /// Delete a Category - abstract DeleteCategory : (Category -> unit) - - // --- Page --- - - /// Try to find a page by its Id and web log Id (web log, page Ids), choosing whether to include revisions - abstract PageById : (string -> string -> bool -> Page option) - - /// Try to find a page by its permalink and web log Id (web log Id, permalink) - abstract PageByPermalink : (string -> string -> Page option) - - /// Get all pages for a web log - abstract AllPages : (string -> Page list) - - /// Add a page - abstract AddPage : (Page -> unit) - - /// Update a page - abstract UpdatePage : (Page -> unit) - - /// Delete a page by its Id and web log Id (web log, page Ids) - abstract DeletePage : (string -> string -> unit) - - // --- Post --- - - /// Find a page of published posts for the given web log (web log Id, page #, # per page) - abstract PageOfPublishedPosts : (string -> int -> int -> Post list) - - /// Find a page of published posts within a given category (web log Id, cat Id, page #, # per page) - abstract PageOfCategorizedPosts : (string -> string -> int -> int -> Post list) - - /// Find a page of published posts tagged with a given tag (web log Id, tag, page #, # per page) - abstract PageOfTaggedPosts : (string -> string -> int -> int -> Post list) - - /// Try to find the next newer published post for the given post - abstract NewerPost : (Post -> Post option) - - /// Try to find the next newer published post within a given category - abstract NewerCategorizedPost : (string -> Post -> Post option) - - /// Try to find the next newer published post tagged with a given tag - abstract NewerTaggedPost : (string -> Post -> Post option) - - /// Try to find the next older published post for the given post - abstract OlderPost : (Post -> Post option) - - /// Try to find the next older published post within a given category - abstract OlderCategorizedPost : (string -> Post -> Post option) - - /// Try to find the next older published post tagged with a given tag - abstract OlderTaggedPost : (string -> Post -> Post option) - - /// Find a page of all posts for the given web log (web log Id, page #, # per page) - abstract PageOfAllPosts : (string -> int -> int -> Post list) - - /// Try to find a post by its Id and web log Id (web log, post Ids) - abstract PostById : (string -> string -> Post option) - - /// Try to find a post by its permalink (web log Id, permalink) - abstract PostByPermalink : (string -> string -> Post option) - - /// Try to find a post by a prior permalink (web log Id, permalink) - abstract PostByPriorPermalink : (string -> string -> Post option) - - /// Get posts for the RSS feed for the given web log and number of posts - abstract FeedPosts : (string -> int -> (Post * User option) list) - - /// Add a post - abstract AddPost : (Post -> unit) - - /// Update a post - abstract UpdatePost : (Post -> unit) - - // --- User --- - - /// Attempt to log on a user - abstract LogOn : (string -> string -> User option) - - /// Set a user's password (e-mail, password hash) - abstract SetUserPassword : (string -> string -> unit) - - // --- WebLog --- - - /// Get a web log by its URL base - abstract WebLogByUrlBase : (string -> WebLog option) - - /// Get dashboard counts for a web log - abstract DashboardCounts : (string -> DashboardCounts) diff --git a/src/MyWebLog.App/Keys.fs b/src/MyWebLog.App/Keys.fs deleted file mode 100644 index 741b6b4..0000000 --- a/src/MyWebLog.App/Keys.fs +++ /dev/null @@ -1,17 +0,0 @@ -[] -module MyWebLog.Keys - -/// Messages stored in the session -let Messages = "messages" - -/// The request start time (stored in the context for each request) -let RequestStart = "request-start" - -/// The current user -let User = "user" - -/// The version of myWebLog -let Version = "version" - -/// The web log -let WebLog = "web-log" \ No newline at end of file diff --git a/src/MyWebLog.App/Logic/Category.fs b/src/MyWebLog.App/Logic/Category.fs deleted file mode 100644 index e91e771..0000000 --- a/src/MyWebLog.App/Logic/Category.fs +++ /dev/null @@ -1,56 +0,0 @@ -module MyWebLog.Logic.Category - -open MyWebLog.Data -open MyWebLog.Entities - -/// Sort categories by their name, with their children sorted below them, including an indent level -let sortCategories categories = - let rec getChildren (cat : Category) indent = - seq { - yield cat, indent - for child in categories |> List.filter (fun c -> c.ParentId = Some cat.Id) do - yield! getChildren child (indent + 1) - } - categories - |> List.filter (fun c -> c.ParentId.IsNone) - |> List.map (fun c -> getChildren c 0) - |> Seq.collect id - |> Seq.toList - -/// Find all categories for a given web log -let findAllCategories (data : IMyWebLogData) webLogId = - data.AllCategories webLogId - |> sortCategories - -/// Try to find a category for a given web log Id and category Id -let tryFindCategory (data : IMyWebLogData) webLogId catId = data.CategoryById webLogId catId - -/// Try to find a category by its slug for a given web log -let tryFindCategoryBySlug (data : IMyWebLogData) webLogId slug = data.CategoryBySlug webLogId slug - -/// Save a category -let saveCategory (data : IMyWebLogData) (cat : Category) = - match cat.Id with - | "new" -> let newCat = { cat with Id = string <| System.Guid.NewGuid() } - data.AddCategory newCat - newCat.Id - | _ -> data.UpdateCategory cat - cat.Id - -/// Remove a category from its parent -let removeCategoryFromParent (data : IMyWebLogData) webLogId parentId catId = - match tryFindCategory data webLogId parentId with - | Some parent -> parent.Children - |> List.filter (fun childId -> childId <> catId) - |> data.UpdateChildren webLogId parentId - | None -> () - -/// Add a category to a given parent -let addCategoryToParent (data : IMyWebLogData) webLogId parentId catId = - match tryFindCategory data webLogId parentId with - | Some parent -> catId :: parent.Children - |> data.UpdateChildren webLogId parentId - | None -> () - -/// Delete a category -let deleteCategory (data : IMyWebLogData) cat = data.DeleteCategory cat diff --git a/src/MyWebLog.App/Logic/Page.fs b/src/MyWebLog.App/Logic/Page.fs deleted file mode 100644 index 81fb0f4..0000000 --- a/src/MyWebLog.App/Logic/Page.fs +++ /dev/null @@ -1,29 +0,0 @@ -/// Logic for manipulating entities -module MyWebLog.Logic.Page - -open MyWebLog.Data -open MyWebLog.Entities - -/// Find a page by its Id and web log Id -let tryFindPage (data : IMyWebLogData) webLogId pageId = data.PageById webLogId pageId true - -/// Find a page by its Id and web log Id, without the revision list -let tryFindPageWithoutRevisions (data : IMyWebLogData) webLogId pageId = data.PageById webLogId pageId false - -/// Find a page by its permalink -let tryFindPageByPermalink (data : IMyWebLogData) webLogId permalink = data.PageByPermalink webLogId permalink - -/// Find a list of all pages (excludes text and revisions) -let findAllPages (data : IMyWebLogData) webLogId = data.AllPages webLogId - -/// Save a page -let savePage (data : IMyWebLogData) (page : Page) = - match page.Id with - | "new" -> let newPg = { page with Id = string <| System.Guid.NewGuid () } - data.AddPage newPg - newPg.Id - | _ -> data.UpdatePage page - page.Id - -/// Delete a page -let deletePage (data : IMyWebLogData) webLogId pageId = data.DeletePage webLogId pageId diff --git a/src/MyWebLog.App/Logic/Post.fs b/src/MyWebLog.App/Logic/Post.fs deleted file mode 100644 index 1918cbf..0000000 --- a/src/MyWebLog.App/Logic/Post.fs +++ /dev/null @@ -1,60 +0,0 @@ -/// Logic for manipulating entities -module MyWebLog.Logic.Post - -open MyWebLog.Data -open MyWebLog.Entities - -/// Find a page of published posts -let findPageOfPublishedPosts (data : IMyWebLogData) webLogId pageNbr nbrPerPage = - data.PageOfPublishedPosts webLogId pageNbr nbrPerPage - -/// Find a pages of published posts in a given category -let findPageOfCategorizedPosts (data : IMyWebLogData) webLogId catId pageNbr nbrPerPage = - data.PageOfCategorizedPosts webLogId catId pageNbr nbrPerPage - -/// Find a page of published posts tagged with a given tag -let findPageOfTaggedPosts (data : IMyWebLogData) webLogId tag pageNbr nbrPerPage = - data.PageOfTaggedPosts webLogId tag pageNbr nbrPerPage - -/// Find the next newer published post for the given post -let tryFindNewerPost (data : IMyWebLogData) post = data.NewerPost post - -/// Find the next newer published post in a given category for the given post -let tryFindNewerCategorizedPost (data : IMyWebLogData) catId post = data.NewerCategorizedPost catId post - -/// Find the next newer published post tagged with a given tag for the given post -let tryFindNewerTaggedPost (data : IMyWebLogData) tag post = data.NewerTaggedPost tag post - -/// Find the next older published post for the given post -let tryFindOlderPost (data : IMyWebLogData) post = data.OlderPost post - -/// Find the next older published post in a given category for the given post -let tryFindOlderCategorizedPost (data : IMyWebLogData) catId post = data.OlderCategorizedPost catId post - -/// Find the next older published post tagged with a given tag for the given post -let tryFindOlderTaggedPost (data : IMyWebLogData) tag post = data.OlderTaggedPost tag post - -/// Find a page of all posts for a web log -let findPageOfAllPosts (data : IMyWebLogData) webLogId pageNbr nbrPerPage = - data.PageOfAllPosts webLogId pageNbr nbrPerPage - -/// Try to find a post by its Id -let tryFindPost (data : IMyWebLogData) webLogId postId = data.PostById webLogId postId - -/// Try to find a post by its permalink -let tryFindPostByPermalink (data : IMyWebLogData) webLogId permalink = data.PostByPermalink webLogId permalink - -/// Try to find a post by its prior permalink -let tryFindPostByPriorPermalink (data : IMyWebLogData) webLogId permalink = data.PostByPriorPermalink webLogId permalink - -/// Find posts for the RSS feed -let findFeedPosts (data : IMyWebLogData) webLogId nbrOfPosts = data.FeedPosts webLogId nbrOfPosts - -/// Save a post -let savePost (data : IMyWebLogData) (post : Post) = - match post.Id with - | "new" -> let newPost = { post with Id = string <| System.Guid.NewGuid() } - data.AddPost newPost - newPost.Id - | _ -> data.UpdatePost post - post.Id diff --git a/src/MyWebLog.App/Logic/User.fs b/src/MyWebLog.App/Logic/User.fs deleted file mode 100644 index 4c6c1dc..0000000 --- a/src/MyWebLog.App/Logic/User.fs +++ /dev/null @@ -1,9 +0,0 @@ -/// Logic for manipulating entities -module MyWebLog.Logic.User - -open MyWebLog.Data - -/// Try to log on a user -let tryUserLogOn (data : IMyWebLogData) email passwordHash = data.LogOn email passwordHash - -let setUserPassword (data : IMyWebLogData) = data.SetUserPassword \ No newline at end of file diff --git a/src/MyWebLog.App/Logic/WebLog.fs b/src/MyWebLog.App/Logic/WebLog.fs deleted file mode 100644 index a1dfc70..0000000 --- a/src/MyWebLog.App/Logic/WebLog.fs +++ /dev/null @@ -1,11 +0,0 @@ -/// Logic for manipulating entities -module MyWebLog.Logic.WebLog - -open MyWebLog.Data -open MyWebLog.Entities - -/// Find a web log by its URL base -let tryFindWebLogByUrlBase (data : IMyWebLogData) urlBase = data.WebLogByUrlBase urlBase - -/// Find the counts for the admin dashboard -let findDashboardCounts (data : IMyWebLogData) webLogId = data.DashboardCounts webLogId diff --git a/src/MyWebLog.App/Modules/AdminModule.fs b/src/MyWebLog.App/Modules/AdminModule.fs deleted file mode 100644 index de094f0..0000000 --- a/src/MyWebLog.App/Modules/AdminModule.fs +++ /dev/null @@ -1,22 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Data -open MyWebLog.Entities -open MyWebLog.Logic.WebLog -open MyWebLog.Resources -open Nancy -open RethinkDb.Driver.Net - -/// Handle /admin routes -type AdminModule (data : IMyWebLogData) as this = - inherit NancyModule ("/admin") - - do - this.Get ("/", fun _ -> this.Dashboard ()) - - /// Admin dashboard - member this.Dashboard () : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let model = DashboardModel (this.Context, this.WebLog, findDashboardCounts data this.WebLog.Id) - model.PageTitle <- Strings.get "Dashboard" - upcast this.View.["admin/dashboard", model] diff --git a/src/MyWebLog.App/Modules/CategoryModule.fs b/src/MyWebLog.App/Modules/CategoryModule.fs deleted file mode 100644 index 1d752eb..0000000 --- a/src/MyWebLog.App/Modules/CategoryModule.fs +++ /dev/null @@ -1,97 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Data -open MyWebLog.Logic.Category -open MyWebLog.Entities -open MyWebLog.Resources -open Nancy -open Nancy.ModelBinding -open Nancy.Security -open RethinkDb.Driver.Net - -/// Handle /category and /categories URLs -type CategoryModule (data : IMyWebLogData) as this = - inherit NancyModule () - - do - this.Get ("/categories", fun _ -> this.CategoryList ()) - this.Get ("/category/{id}/edit", fun p -> this.EditCategory (downcast p)) - this.Post ("/category/{id}/edit", fun p -> this.SaveCategory (downcast p)) - this.Post ("/category/{id}/delete", fun p -> this.DeleteCategory (downcast p)) - - /// Display a list of categories - member this.CategoryList () : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let model = - CategoryListModel ( - this.Context, this.WebLog, findAllCategories data this.WebLog.Id - |> List.map (fun cat -> IndentedCategory.Create cat (fun _ -> false))) - model.PageTitle <- Strings.get "Categories" - upcast this.View.["admin/category/list", model] - - /// Edit a category - member this.EditCategory (parameters : DynamicDictionary) : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let catId = parameters.["id"].ToString () - match catId with "new" -> Some Category.Empty | _ -> tryFindCategory data this.WebLog.Id catId - |> function - | Some cat -> - let model = CategoryEditModel (this.Context, this.WebLog, cat) - model.Categories <- findAllCategories data this.WebLog.Id - |> List.map (fun c -> - IndentedCategory.Create c (fun catId -> catId = defaultArg cat.ParentId "")) - model.PageTitle <- Strings.get <| match catId with "new" -> "AddNewCategory" | _ -> "EditCategory" - upcast this.View.["admin/category/edit", model] - | _ -> this.NotFound () - - /// Save a category - member this.SaveCategory (parameters : DynamicDictionary) : obj = - this.ValidateCsrfToken () - this.RequiresAccessLevel AuthorizationLevel.Administrator - let catId = parameters.["id"].ToString () - let form = this.Bind () - match catId with - | "new" -> Some { Category.Empty with WebLogId = this.WebLog.Id } - | _ -> tryFindCategory data this.WebLog.Id catId - |> function - | Some old -> - let cat = - { old with - Name = form.Name - Slug = form.Slug - Description = match form.Description with "" -> None | d -> Some d - ParentId = match form.ParentId with "" -> None | p -> Some p - } - let newCatId = saveCategory data cat - match old.ParentId = cat.ParentId with - | true -> () - | _ -> - match old.ParentId with - | Some parentId -> removeCategoryFromParent data this.WebLog.Id parentId newCatId - | _ -> () - match cat.ParentId with - | Some parentId -> addCategoryToParent data this.WebLog.Id parentId newCatId - | _ -> () - let model = MyWebLogModel (this.Context, this.WebLog) - model.AddMessage - { UserMessage.Empty with - Message = System.String.Format - (Strings.get "MsgCategoryEditSuccess", - Strings.get (match catId with "new" -> "Added" | _ -> "Updated")) - } - this.Redirect (sprintf "/category/%s/edit" newCatId) model - | _ -> this.NotFound () - - /// Delete a category - member this.DeleteCategory (parameters : DynamicDictionary) : obj = - this.ValidateCsrfToken () - this.RequiresAccessLevel AuthorizationLevel.Administrator - let catId = parameters.["id"].ToString () - match tryFindCategory data this.WebLog.Id catId with - | Some cat -> - deleteCategory data cat - let model = MyWebLogModel (this.Context, this.WebLog) - model.AddMessage - { UserMessage.Empty with Message = System.String.Format(Strings.get "MsgCategoryDeleted", cat.Name) } - this.Redirect "/categories" model - | _ -> this.NotFound () diff --git a/src/MyWebLog.App/Modules/ModuleExtensions.fs b/src/MyWebLog.App/Modules/ModuleExtensions.fs deleted file mode 100644 index dd3d069..0000000 --- a/src/MyWebLog.App/Modules/ModuleExtensions.fs +++ /dev/null @@ -1,36 +0,0 @@ -[] -module MyWebLog.ModuleExtensions - -open MyWebLog.Entities -open Nancy -open Nancy.Security -open System -open System.Security.Claims - -/// Parent class for all myWebLog Nancy modules -type NancyModule with - - /// Strongly-typed access to the web log for the current request - member this.WebLog = this.Context.Items.[Keys.WebLog] :?> WebLog - - /// Display a view using the theme specified for the web log - member this.ThemedView view (model : MyWebLogModel) : obj = - upcast this.View.[(sprintf "themes/%s/%s" this.WebLog.ThemePath view), model] - - /// Return a 404 - member this.NotFound () : obj = upcast HttpStatusCode.NotFound - - /// Redirect a request, storing messages in the session if they exist - member this.Redirect url (model : MyWebLogModel) : obj = - match List.length model.Messages with - | 0 -> () - | _ -> this.Session.[Keys.Messages] <- model.Messages - // Temp (307) redirects don't reset the HTTP method; this allows POST-process-REDIRECT workflow - upcast this.Response.AsRedirect(url).WithStatusCode HttpStatusCode.MovedPermanently - - /// Require a specific level of access for the current web log - member this.RequiresAccessLevel level = - let findClaim = Predicate (fun claim -> - claim.Type = ClaimTypes.Role && claim.Value = sprintf "%s|%s" this.WebLog.Id level) - this.RequiresAuthentication () - this.RequiresClaims [| findClaim |] diff --git a/src/MyWebLog.App/Modules/PageModule.fs b/src/MyWebLog.App/Modules/PageModule.fs deleted file mode 100644 index a928dac..0000000 --- a/src/MyWebLog.App/Modules/PageModule.fs +++ /dev/null @@ -1,97 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Data -open MyWebLog.Entities -open MyWebLog.Logic.Page -open MyWebLog.Resources -open Nancy -open Nancy.ModelBinding -open Nancy.Security -open NodaTime -open RethinkDb.Driver.Net - -/// Handle /pages and /page URLs -type PageModule (data : IMyWebLogData, clock : IClock) as this = - inherit NancyModule () - - do - this.Get ("/pages", fun _ -> this.PageList ()) - this.Get ("/page/{id}/edit", fun p -> this.EditPage (downcast p)) - this.Post ("/page/{id}/edit", fun p -> this.SavePage (downcast p)) - this.Delete ("/page/{id}/delete", fun p -> this.DeletePage (downcast p)) - - /// List all pages - member this.PageList () : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let model = - PagesModel (this.Context, this.WebLog, findAllPages data this.WebLog.Id - |> List.map (fun p -> PageForDisplay (this.WebLog, p))) - model.PageTitle <- Strings.get "Pages" - upcast this.View.["admin/page/list", model] - - /// Edit a page - member this.EditPage (parameters : DynamicDictionary) : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let pageId = parameters.["id"].ToString () - match pageId with "new" -> Some Page.Empty | _ -> tryFindPage data this.WebLog.Id pageId - |> function - | Some page -> - let rev = match page.Revisions - |> List.sortByDescending (fun r -> r.AsOf) - |> List.tryHead with - | Some r -> r - | _ -> Revision.Empty - let model = EditPageModel (this.Context, this.WebLog, page, rev) - model.PageTitle <- Strings.get <| match pageId with "new" -> "AddNewPage" | _ -> "EditPage" - upcast this.View.["admin/page/edit", model] - | _ -> this.NotFound () - - /// Save a page - member this.SavePage (parameters : DynamicDictionary) : obj = - this.ValidateCsrfToken () - this.RequiresAccessLevel AuthorizationLevel.Administrator - let pageId = parameters.["id"].ToString () - let form = this.Bind () - let now = clock.GetCurrentInstant().ToUnixTimeTicks () - match pageId with "new" -> Some Page.Empty | _ -> tryFindPage data this.WebLog.Id pageId - |> function - | Some p -> - let page = match pageId with "new" -> { p with WebLogId = this.WebLog.Id } | _ -> p - let pId = - { p with - Title = form.Title - Permalink = form.Permalink - PublishedOn = match pageId with "new" -> now | _ -> page.PublishedOn - UpdatedOn = now - ShowInPageList = form.ShowInPageList - Text = match form.Source with - | RevisionSource.Markdown -> (* Markdown.TransformHtml *) form.Text - | _ -> form.Text - Revisions = { AsOf = now - SourceType = form.Source - Text = form.Text - } :: page.Revisions - } - |> savePage data - let model = MyWebLogModel (this.Context, this.WebLog) - model.AddMessage - { UserMessage.Empty with - Message = System.String.Format - (Strings.get "MsgPageEditSuccess", - Strings.get (match pageId with "new" -> "Added" | _ -> "Updated")) - } - this.Redirect (sprintf "/page/%s/edit" pId) model - | _ -> this.NotFound () - - /// Delete a page - member this.DeletePage (parameters : DynamicDictionary) : obj = - this.ValidateCsrfToken () - this.RequiresAccessLevel AuthorizationLevel.Administrator - let pageId = parameters.["id"].ToString () - match tryFindPageWithoutRevisions data this.WebLog.Id pageId with - | Some page -> - deletePage data page.WebLogId page.Id - let model = MyWebLogModel (this.Context, this.WebLog) - model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgPageDeleted" } - this.Redirect "/pages" model - | _ -> this.NotFound () diff --git a/src/MyWebLog.App/Modules/PostModule.fs b/src/MyWebLog.App/Modules/PostModule.fs deleted file mode 100644 index ec29d85..0000000 --- a/src/MyWebLog.App/Modules/PostModule.fs +++ /dev/null @@ -1,317 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Data -open MyWebLog.Entities -open MyWebLog.Logic.Category -open MyWebLog.Logic.Page -open MyWebLog.Logic.Post -open MyWebLog.Resources -open Nancy -open Nancy.ModelBinding -open Nancy.Security -open Nancy.Session.Persistable -open NodaTime -open RethinkDb.Driver.Net -open System -open System.Xml.Linq - -type NewsItem = - { Title : string - Link : string - ReleaseDate : DateTime - Description : string - } - -/// Routes dealing with posts (including the home page, /tag, /category, RSS, and catch-all routes) -type PostModule (data : IMyWebLogData, clock : IClock) as this = - inherit NancyModule () - - /// Get the page number from the dictionary - let getPage (parameters : DynamicDictionary) = - match parameters.ContainsKey "page" with - | true -> match System.Int32.TryParse (parameters.["page"].ToString ()) with true, pg -> pg | _ -> 1 - | _ -> 1 - - /// Convert a list of posts to a list of posts for display - let forDisplay posts = posts |> List.map (fun post -> PostForDisplay (this.WebLog, post)) - - /// Generate an RSS/Atom feed of the latest posts - let generateFeed format : obj = - let myChannelFeed channelTitle channelLink channelDescription (items : NewsItem list) = - let xn = XName.Get - let elem name (valu : string) = XElement (xn name, valu) - let elems = - items - |> List.sortByDescending (fun i -> i.ReleaseDate) - |> List.map (fun i -> - XElement ( - xn "item", - elem "title" (System.Net.WebUtility.HtmlEncode i.Title), - elem "link" i.Link, - elem "guid" i.Link, - elem "pubDate" (i.ReleaseDate.ToString "r"), - elem "description" (System.Net.WebUtility.HtmlEncode i.Description) - )) - XDocument ( - XDeclaration ("1.0", "utf-8", "yes"), - XElement ( - xn "rss", - XAttribute (xn "version", "2.0"), - elem "title" channelTitle, - elem "link" channelLink, - elem "description" (defaultArg channelDescription ""), - elem "language" "en-us", - XElement (xn "channel", elems)) - |> box) - let schemeAndUrl = sprintf "%s://%s" this.Request.Url.Scheme this.WebLog.UrlBase - let feed = - findFeedPosts data this.WebLog.Id 10 - |> List.map (fun (post, _) -> - { Title = post.Title - Link = sprintf "%s/%s" schemeAndUrl post.Permalink - ReleaseDate = Instant.FromUnixTimeTicks(post.PublishedOn).ToDateTimeOffset().DateTime - Description = post.Text - }) - |> myChannelFeed this.WebLog.Name schemeAndUrl this.WebLog.Subtitle - let stream = new IO.MemoryStream () - Xml.XmlWriter.Create stream |> feed.Save - //|> match format with "atom" -> feed.SaveAsAtom10 | _ -> feed.SaveAsRss20 - stream.Position <- 0L - upcast this.Response.FromStream (stream, sprintf "application/%s+xml" format) - // TODO: how to return this? - - (* - let feed = - SyndicationFeed( - this.WebLog.Name, defaultArg this.WebLog.Subtitle null, - Uri(sprintf "%s://%s" this.Request.Url.Scheme this.WebLog.UrlBase), null, - (match posts |> List.tryHead with - | Some (post, _) -> Instant(post.UpdatedOn).ToDateTimeOffset () - | _ -> System.DateTimeOffset(System.DateTime.MinValue)), - posts - |> List.map (fun (post, user) -> - let item = - SyndicationItem( - BaseUri = Uri(sprintf "%s://%s/%s" this.Request.Url.Scheme this.WebLog.UrlBase post.Permalink), - PublishDate = Instant(post.PublishedOn).ToDateTimeOffset (), - LastUpdatedTime = Instant(post.UpdatedOn).ToDateTimeOffset (), - Title = TextSyndicationContent(post.Title), - Content = TextSyndicationContent(post.Text, TextSyndicationContentKind.Html)) - user - |> Option.iter (fun u -> item.Authors.Add - (SyndicationPerson(u.UserName, u.PreferredName, defaultArg u.Url null))) - post.Categories - |> List.iter (fun c -> item.Categories.Add(SyndicationCategory(c.Name))) - item)) - let stream = new IO.MemoryStream() - Xml.XmlWriter.Create(stream) - |> match format with "atom" -> feed.SaveAsAtom10 | _ -> feed.SaveAsRss20 - stream.Position <- int64 0 - upcast this.Response.FromStream(stream, sprintf "application/%s+xml" format) *) - - do - this.Get ("/", fun _ -> this.HomePage ()) - this.Get ("/{permalink*}", fun p -> this.CatchAll (downcast p)) - this.Get ("/posts/page/{page:int}", fun p -> this.PublishedPostsPage (getPage <| downcast p)) - this.Get ("/category/{slug}", fun p -> this.CategorizedPosts (downcast p)) - this.Get ("/category/{slug}/page/{page:int}", fun p -> this.CategorizedPosts (downcast p)) - this.Get ("/tag/{tag}", fun p -> this.TaggedPosts (downcast p)) - this.Get ("/tag/{tag}/page/{page:int}", fun p -> this.TaggedPosts (downcast p)) - this.Get ("/feed", fun _ -> this.Feed ()) - this.Get ("/posts/list", fun _ -> this.PostList 1) - this.Get ("/posts/list/page/{page:int}", fun p -> this.PostList (getPage <| downcast p)) - this.Get ("/post/{postId}/edit", fun p -> this.EditPost (downcast p)) - this.Post ("/post/{postId}/edit", fun p -> this.SavePost (downcast p)) - - // ---- Display posts to users ---- - - /// Display a page of published posts - member this.PublishedPostsPage pageNbr : obj = - let model = PostsModel (this.Context, this.WebLog) - model.PageNbr <- pageNbr - model.Posts <- findPageOfPublishedPosts data this.WebLog.Id pageNbr 10 |> forDisplay - model.HasNewer <- match pageNbr with - | 1 -> false - | _ -> match List.isEmpty model.Posts with - | true -> false - | _ -> Option.isSome <| tryFindNewerPost data (List.last model.Posts).Post - model.HasOlder <- match List.isEmpty model.Posts with - | true -> false - | _ -> Option.isSome <| tryFindOlderPost data (List.head model.Posts).Post - model.UrlPrefix <- "/posts" - model.PageTitle <- match pageNbr with 1 -> "" | _ -> sprintf "%s%i" (Strings.get "PageHash") pageNbr - this.ThemedView "index" model - - /// Display either the newest posts or the configured home page - member this.HomePage () : obj = - match this.WebLog.DefaultPage with - | "posts" -> this.PublishedPostsPage 1 - | pageId -> - match tryFindPageWithoutRevisions data this.WebLog.Id pageId with - | Some page -> - let model = PageModel(this.Context, this.WebLog, page) - model.PageTitle <- page.Title - this.ThemedView "page" model - | _ -> this.NotFound () - - /// Derive a post or page from the URL, or redirect from a prior URL to the current one - member this.CatchAll (parameters : DynamicDictionary) : obj = - let url = parameters.["permalink"].ToString () - match tryFindPostByPermalink data this.WebLog.Id url with - | Some post -> // Hopefully the most common result; the permalink is a permalink! - let model = PostModel(this.Context, this.WebLog, post) - model.NewerPost <- tryFindNewerPost data post - model.OlderPost <- tryFindOlderPost data post - model.PageTitle <- post.Title - this.ThemedView "single" model - | _ -> // Maybe it's a page permalink instead... - match tryFindPageByPermalink data this.WebLog.Id url with - | Some page -> // ...and it is! - let model = PageModel (this.Context, this.WebLog, page) - model.PageTitle <- page.Title - this.ThemedView "page" model - | _ -> // Maybe it's an old permalink for a post - match tryFindPostByPriorPermalink data this.WebLog.Id url with - | Some post -> // Redirect them to the proper permalink - upcast this.Response.AsRedirect(sprintf "/%s" post.Permalink) - .WithStatusCode HttpStatusCode.MovedPermanently - | _ -> this.NotFound () - - /// Display categorized posts - member this.CategorizedPosts (parameters : DynamicDictionary) : obj = - let slug = parameters.["slug"].ToString () - match tryFindCategoryBySlug data this.WebLog.Id slug with - | Some cat -> - let pageNbr = getPage parameters - let model = PostsModel (this.Context, this.WebLog) - model.PageNbr <- pageNbr - model.Posts <- findPageOfCategorizedPosts data this.WebLog.Id cat.Id pageNbr 10 |> forDisplay - model.HasNewer <- match List.isEmpty model.Posts with - | true -> false - | _ -> Option.isSome <| tryFindNewerCategorizedPost data cat.Id - (List.head model.Posts).Post - model.HasOlder <- match List.isEmpty model.Posts with - | true -> false - | _ -> Option.isSome <| tryFindOlderCategorizedPost data cat.Id - (List.last model.Posts).Post - model.UrlPrefix <- sprintf "/category/%s" slug - model.PageTitle <- sprintf "\"%s\" Category%s" cat.Name - (match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n) - model.Subtitle <- Some <| match cat.Description with - | Some desc -> desc - | _ -> sprintf "Posts in the \"%s\" category" cat.Name - this.ThemedView "index" model - | _ -> this.NotFound () - - /// Display tagged posts - member this.TaggedPosts (parameters : DynamicDictionary) : obj = - let tag = parameters.["tag"].ToString () - let pageNbr = getPage parameters - let model = PostsModel (this.Context, this.WebLog) - model.PageNbr <- pageNbr - model.Posts <- findPageOfTaggedPosts data this.WebLog.Id tag pageNbr 10 |> forDisplay - model.HasNewer <- match List.isEmpty model.Posts with - | true -> false - | _ -> Option.isSome <| tryFindNewerTaggedPost data tag (List.head model.Posts).Post - model.HasOlder <- match List.isEmpty model.Posts with - | true -> false - | _ -> Option.isSome <| tryFindOlderTaggedPost data tag (List.last model.Posts).Post - model.UrlPrefix <- sprintf "/tag/%s" tag - model.PageTitle <- sprintf "\"%s\" Tag%s" tag (match pageNbr with 1 -> "" | n -> sprintf " | Page %i" n) - model.Subtitle <- Some <| sprintf "Posts tagged \"%s\"" tag - this.ThemedView "index" model - - /// Generate an RSS feed - member this.Feed () : obj = - let query = this.Request.Query :?> DynamicDictionary - match query.ContainsKey "format" with - | true -> - match query.["format"].ToString () with - | x when x = "atom" || x = "rss" -> generateFeed x - | x when x = "rss2" -> generateFeed "rss" - | _ -> this.Redirect "/feed" (MyWebLogModel (this.Context, this.WebLog)) - | _ -> generateFeed "rss" - - // ---- Administer posts ---- - - /// Display a page of posts in the admin area - member this.PostList pageNbr : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let model = PostsModel (this.Context, this.WebLog) - model.PageNbr <- pageNbr - model.Posts <- findPageOfAllPosts data this.WebLog.Id pageNbr 25 |> forDisplay - model.HasNewer <- pageNbr > 1 - model.HasOlder <- List.length model.Posts > 24 - model.UrlPrefix <- "/posts/list" - model.PageTitle <- Strings.get "Posts" - upcast this.View.["admin/post/list", model] - - /// Edit a post - member this.EditPost (parameters : DynamicDictionary) : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - let postId = parameters.["postId"].ToString () - match postId with "new" -> Some Post.Empty | _ -> tryFindPost data this.WebLog.Id postId - |> function - | Some post -> - let rev = - match post.Revisions - |> List.sortByDescending (fun r -> r.AsOf) - |> List.tryHead with - | Some r -> r - | None -> Revision.Empty - let model = EditPostModel (this.Context, this.WebLog, post, rev) - model.Categories <- findAllCategories data this.WebLog.Id - |> List.map (fun cat -> - DisplayCategory.Create cat (post.CategoryIds |> List.contains (fst cat).Id)) - model.PageTitle <- Strings.get <| match post.Id with "new" -> "AddNewPost" | _ -> "EditPost" - upcast this.View.["admin/post/edit", model] - | _ -> this.NotFound () - - /// Save a post - member this.SavePost (parameters : DynamicDictionary) : obj = - this.RequiresAccessLevel AuthorizationLevel.Administrator - this.ValidateCsrfToken () - let postId = parameters.["postId"].ToString () - let form = this.Bind () - let now = clock.GetCurrentInstant().ToUnixTimeTicks () - match postId with "new" -> Some Post.Empty | _ -> tryFindPost data this.WebLog.Id postId - |> function - | Some p -> - let justPublished = p.PublishedOn = 0L && form.PublishNow - let post = - match postId with - | "new" -> - { p with - WebLogId = this.WebLog.Id - AuthorId = this.Request.PersistableSession.GetOrDefault(Keys.User, User.Empty).Id - } - | _ -> p - let pId = - { post with - Status = match form.PublishNow with true -> PostStatus.Published | _ -> PostStatus.Draft - Title = form.Title - Permalink = form.Permalink - PublishedOn = match justPublished with true -> now | _ -> post.PublishedOn - UpdatedOn = now - Text = match form.Source with - | RevisionSource.Markdown -> (* Markdown.TransformHtml *) form.Text - | _ -> form.Text - CategoryIds = Array.toList form.Categories - Tags = form.Tags.Split ',' - |> Seq.map (fun t -> t.Trim().ToLowerInvariant ()) - |> Seq.sort - |> Seq.toList - Revisions = { AsOf = now - SourceType = form.Source - Text = form.Text } :: post.Revisions } - |> savePost data - let model = MyWebLogModel(this.Context, this.WebLog) - model.AddMessage - { UserMessage.Empty with - Message = System.String.Format - (Strings.get "MsgPostEditSuccess", - Strings.get (match postId with "new" -> "Added" | _ -> "Updated"), - (match justPublished with true -> Strings.get "AndPublished" | _ -> "")) - } - this.Redirect (sprintf "/post/%s/edit" pId) model - | _ -> this.NotFound () diff --git a/src/MyWebLog.App/Modules/UserModule.fs b/src/MyWebLog.App/Modules/UserModule.fs deleted file mode 100644 index eca46af..0000000 --- a/src/MyWebLog.App/Modules/UserModule.fs +++ /dev/null @@ -1,64 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Data -open MyWebLog.Entities -open MyWebLog.Logic.User -open MyWebLog.Resources -open Nancy -open Nancy.Authentication.Forms -open Nancy.Cryptography -open Nancy.ModelBinding -open Nancy.Security -open Nancy.Session.Persistable -open RethinkDb.Driver.Net -open System.Text - -/// Handle /user URLs -type UserModule (data : IMyWebLogData, cfg : AppConfig) as this = - inherit NancyModule ("/user") - - /// Hash the user's password - let pbkdf2 (pw : string) = - PassphraseKeyGenerator(pw, cfg.PasswordSalt, 4096).GetBytes 512 - |> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) "" - - do - this.Get ("/log-on", fun _ -> this.ShowLogOn ()) - this.Post ("/log-on", fun p -> this.DoLogOn (downcast p)) - this.Get ("/log-off", fun _ -> this.LogOff ()) - - /// Show the log on page - member this.ShowLogOn () : obj = - let model = LogOnModel (this.Context, this.WebLog) - let query = this.Request.Query :?> DynamicDictionary - model.Form.ReturnUrl <- match query.ContainsKey "returnUrl" with true -> query.["returnUrl"].ToString () | _ -> "" - model.PageTitle <- Strings.get "LogOn" - upcast this.View.["admin/user/log-on", model] - - /// Process a user log on - member this.DoLogOn (parameters : DynamicDictionary) : obj = - this.ValidateCsrfToken () - let form = this.Bind () - let model = MyWebLogModel(this.Context, this.WebLog) - match tryUserLogOn data form.Email (pbkdf2 form.Password) with - | Some user -> - this.Session.[Keys.User] <- user - model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgLogOnSuccess" } - this.Redirect "" model |> ignore // Save the messages in the session before the Nancy redirect - // TODO: investigate if addMessage should update the session when it's called - upcast this.LoginAndRedirect (System.Guid.Parse user.Id, - fallbackRedirectUrl = defaultArg (Option.ofObj form.ReturnUrl) "/") - | _ -> - { UserMessage.Empty with - Level = Level.Error - Message = Strings.get "ErrBadLogOnAttempt" } - |> model.AddMessage - this.Redirect (sprintf "/user/log-on?returnUrl=%s" form.ReturnUrl) model - - /// Log a user off - member this.LogOff () : obj = - this.Session.DeleteAll () - let model = MyWebLogModel (this.Context, this.WebLog) - model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgLogOffSuccess" } - this.Redirect "" model |> ignore - upcast this.LogoutAndRedirect "/" diff --git a/src/MyWebLog.App/MyWebLog.App.xproj b/src/MyWebLog.App/MyWebLog.App.xproj deleted file mode 100644 index eb039d1..0000000 --- a/src/MyWebLog.App/MyWebLog.App.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - 9cea3a8b-e8aa-44e6-9f5f-2095ceed54eb - Nancy.Session.Persistable - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/MyWebLog.App/Strings.fs b/src/MyWebLog.App/Strings.fs deleted file mode 100644 index f207444..0000000 --- a/src/MyWebLog.App/Strings.fs +++ /dev/null @@ -1,42 +0,0 @@ -module MyWebLog.Resources.Strings - -open MyWebLog -open Newtonsoft.Json -open System.Collections.Generic -open System.Reflection - -/// The locales we'll try to load -let private supportedLocales = [ "en-US" ] - -/// The fallback locale, if a key is not found in a non-default locale -let private fallbackLocale = "en-US" - -/// Get an embedded JSON file as a string -let private getEmbedded locale = - use rdr = - new System.IO.StreamReader - (typeof.GetTypeInfo().Assembly.GetManifestResourceStream(sprintf "MyWebLog.App.%s.json" locale)) - rdr.ReadToEnd() - -/// The dictionary of localized strings -let private strings = - supportedLocales - |> List.map (fun loc -> loc, JsonConvert.DeserializeObject>(getEmbedded loc)) - |> dict - -/// Get a key from the resources file for the given locale -let getForLocale locale key = - let getString thisLocale = - match strings.ContainsKey thisLocale with - | true -> match strings.[thisLocale].ContainsKey key with - | true -> Some strings.[thisLocale].[key] - | _ -> None - | _ -> None - match getString locale with - | Some xlat -> Some xlat - | _ when locale <> fallbackLocale -> getString fallbackLocale - | _ -> None - |> function Some xlat -> xlat | _ -> sprintf "%s.%s" locale key - -/// Translate the key for the current locale -let get key = getForLocale System.Globalization.CultureInfo.CurrentCulture.Name key diff --git a/src/MyWebLog.App/ViewModels.fs b/src/MyWebLog.App/ViewModels.fs deleted file mode 100644 index 7a0babe..0000000 --- a/src/MyWebLog.App/ViewModels.fs +++ /dev/null @@ -1,504 +0,0 @@ -namespace MyWebLog - -open MyWebLog.Entities -open MyWebLog.Logic.WebLog -open MyWebLog.Resources -open Nancy -open Nancy.Session.Persistable -open Newtonsoft.Json -open NodaTime -open NodaTime.Text -open System -open System.Net - -/// Levels for a user message -[] -module Level = - /// An informational message - let Info = "Info" - /// A message regarding a non-fatal but non-optimal condition - let Warning = "WARNING" - /// A message regarding a failure of the expected result - let Error = "ERROR" - - -/// A message for the user -type UserMessage = - { /// The level of the message (use Level module constants) - Level : string - /// The text of the message - Message : string - /// Further details regarding the message - Details : string option } -with - /// An empty message - static member Empty = - { Level = Level.Info - Message = "" - Details = None } - - /// Display version - [] - member this.ToDisplay = - let classAndLabel = - dict [ - Level.Error, ("danger", Strings.get "Error") - Level.Warning, ("warning", Strings.get "Warning") - Level.Info, ("info", "") - ] - seq { - yield "
    " - match snd classAndLabel.[this.Level] with - | "" -> () - | lbl -> yield lbl.ToUpper () - yield " » " - yield this.Message - yield "" - match this.Details with - | Some d -> yield "
    " - yield d - | None -> () - yield "
    " - } - |> Seq.reduce (+) - - -/// Helpers to format local date/time using NodaTime -module FormatDateTime = - - /// Convert ticks to a zoned date/time - let zonedTime timeZone ticks = Instant.FromUnixTimeTicks(ticks).InZone(DateTimeZoneProviders.Tzdb.[timeZone]) - - /// Display a long date - let longDate timeZone ticks = - zonedTime timeZone ticks - |> ZonedDateTimePattern.CreateWithCurrentCulture("MMMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format - - /// Display a short date - let shortDate timeZone ticks = - zonedTime timeZone ticks - |> ZonedDateTimePattern.CreateWithCurrentCulture("MMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format - - /// Display the time - let time timeZone ticks = - (zonedTime timeZone ticks - |> ZonedDateTimePattern.CreateWithCurrentCulture("h':'mmtt", DateTimeZoneProviders.Tzdb).Format).ToLower () - - -/// Parent view model for all myWebLog views -type MyWebLogModel (ctx : NancyContext, webLog : WebLog) as this = - - /// Get the messages from the session - let getMessages () = - let msg = ctx.Request.PersistableSession.GetOrDefault (Keys.Messages, []) - match List.length msg with - | 0 -> () - | _ -> ctx.Request.Session.Delete Keys.Messages - msg - - /// Generate a footer logo with the given scheme - let footerLogo scheme = - seq { - yield sprintf "\"myWebLog\"" - } - |> Seq.reduce (+) - - /// The web log for this request - member this.WebLog = webLog - /// The subtitle for the webLog (SSVE can't do IsSome that deep) - member this.WebLogSubtitle = defaultArg this.WebLog.Subtitle "" - /// User messages - member val Messages = getMessages () with get, set - /// The currently logged in user - member this.User = ctx.Request.PersistableSession.GetOrDefault (Keys.User, User.Empty) - /// The title of the page - member val PageTitle = "" with get, set - /// The name and version of the application - member this.Generator = sprintf "myWebLog %s" (ctx.Items.[Keys.Version].ToString ()) - /// The request start time - member this.RequestStart = ctx.Items.[Keys.RequestStart] :?> int64 - /// Is a user authenticated for this request? - member this.IsAuthenticated = "" <> this.User.Id - /// Add a message to the output - member this.AddMessage message = this.Messages <- message :: this.Messages - - /// Display a long date - member this.DisplayLongDate ticks = FormatDateTime.longDate this.WebLog.TimeZone ticks - /// Display a short date - member this.DisplayShortDate ticks = FormatDateTime.shortDate this.WebLog.TimeZone ticks - /// Display the time - member this.DisplayTime ticks = FormatDateTime.time this.WebLog.TimeZone ticks - /// The page title with the web log name appended - member this.DisplayPageTitle = - match this.PageTitle with - | "" -> - match this.WebLog.Subtitle with - | Some st -> sprintf "%s | %s" this.WebLog.Name st - | None -> this.WebLog.Name - | pt -> sprintf "%s | %s" pt this.WebLog.Name - - /// An image with the version and load time in the tool tip (using light text) - member this.FooterLogoLight = footerLogo "light" - - /// An image with the version and load time in the tool tip (using dark text) - member this.FooterLogoDark = footerLogo "dark" - - -// ---- Admin models ---- - -/// Admin Dashboard view model -type DashboardModel (ctx, webLog, counts : DashboardCounts) = - inherit MyWebLogModel (ctx, webLog) - /// The number of posts for the current web log - member val Posts = counts.Posts with get, set - /// The number of pages for the current web log - member val Pages = counts.Pages with get, set - /// The number of categories for the current web log - member val Categories = counts.Categories with get, set - - -// ---- Category models ---- - -type IndentedCategory = - { Category : Category - Indent : int - Selected : bool } -with - /// Create an indented category - static member Create cat isSelected = - { Category = fst cat - Indent = snd cat - Selected = isSelected (fst cat).Id } - /// Display name for a category on the list page, complete with indents - member this.ListName = sprintf "%s%s" (String.replicate this.Indent " »   ") this.Category.Name - /// Display for this category as an option within a select box - member this.Option = - seq { - yield sprintf "" - } - |> String.concat "" - /// Does the category have a description? - member this.HasDescription = this.Category.Description.IsSome - - -/// Model for the list of categories -type CategoryListModel (ctx, webLog, categories) = - inherit MyWebLogModel (ctx, webLog) - /// The categories - member this.Categories : IndentedCategory list = categories - - -/// Form for editing a category -type CategoryForm (category : Category) = - new() = CategoryForm (Category.Empty) - /// The name of the category - member val Name = category.Name with get, set - /// The slug of the category (used in category URLs) - member val Slug = category.Slug with get, set - /// The description of the category - member val Description = defaultArg category.Description "" with get, set - /// The parent category for this one - member val ParentId = defaultArg category.ParentId "" with get, set - -/// Model for editing a category -type CategoryEditModel (ctx, webLog, category) = - inherit MyWebLogModel (ctx, webLog) - /// The form with the category information - member val Form = CategoryForm (category) with get, set - /// The category being edited - member val Category = category - /// The categories - member val Categories : IndentedCategory list = [] with get, set - - -// ---- Page models ---- - -/// Model for page display -type PageModel (ctx, webLog, page) = - inherit MyWebLogModel (ctx, webLog) - /// The page to be displayed - member this.Page : Page = page - - -/// Wrapper for a page with additional properties -type PageForDisplay (webLog, page) = - /// The page - member this.Page : Page = page - /// The time zone of the web log - member this.TimeZone = webLog.TimeZone - /// The date the page was last updated - member this.UpdatedDate = FormatDateTime.longDate this.TimeZone page.UpdatedOn - /// The time the page was last updated - member this.UpdatedTime = FormatDateTime.time this.TimeZone page.UpdatedOn - - -/// Model for page list display -type PagesModel (ctx, webLog, pages) = - inherit MyWebLogModel (ctx, webLog) - /// The pages - member this.Pages : PageForDisplay list = pages - - -/// Form used to edit a page -type EditPageForm() = - /// The title of the page - member val Title = "" with get, set - /// The link for the page - member val Permalink = "" with get, set - /// The source type of the revision - member val Source = "" with get, set - /// The text of the revision - member val Text = "" with get, set - /// Whether to show the page in the web log's page list - member val ShowInPageList = false with get, set - - /// Fill the form with applicable values from a page - member this.ForPage (page : Page) = - this.Title <- page.Title - this.Permalink <- page.Permalink - this.ShowInPageList <- page.ShowInPageList - this - - /// Fill the form with applicable values from a revision - member this.ForRevision rev = - this.Source <- rev.SourceType - this.Text <- rev.Text - this - - -/// Model for the edit page page -type EditPageModel (ctx, webLog, page, revision) = - inherit MyWebLogModel (ctx, webLog) - /// The page edit form - member val Form = EditPageForm().ForPage(page).ForRevision(revision) - /// The page itself - member this.Page = page - /// The page's published date - member this.PublishedDate = this.DisplayLongDate page.PublishedOn - /// The page's published time - member this.PublishedTime = this.DisplayTime page.PublishedOn - /// The page's last updated date - member this.LastUpdatedDate = this.DisplayLongDate page.UpdatedOn - /// The page's last updated time - member this.LastUpdatedTime = this.DisplayTime page.UpdatedOn - /// Is this a new page? - member this.IsNew = "new" = page.Id - /// Generate a checked attribute if this page shows in the page list - member this.PageListChecked = match page.ShowInPageList with true -> "checked=\"checked\"" | _ -> "" - - -// ---- Post models ---- - -/// Formatter for comment information -type CommentForDisplay (comment : Comment, tz) = - /// The comment on which this model is based - member this.Comment = comment - /// The commentor (linked with a URL if there is one) - member this.Commentor = - match comment.Url with Some url -> sprintf "%s" url comment.Name | _ -> comment.Name - /// The date/time this comment was posted - member this.CommentedOn = - sprintf "%s / %s" (FormatDateTime.longDate tz comment.PostedOn) (FormatDateTime.time tz comment.PostedOn) - -/// Model for single post display -type PostModel (ctx, webLog, post) = - inherit MyWebLogModel (ctx, webLog) - /// The post being displayed - member this.Post : Post = post - /// The next newer post - member val NewerPost : Post option = None with get, set - /// The next older post - member val OlderPost : Post option = None with get, set - /// The date the post was published - member this.PublishedDate = this.DisplayLongDate this.Post.PublishedOn - /// The time the post was published - member this.PublishedTime = this.DisplayTime this.Post.PublishedOn - /// The number of comments - member this.CommentCount = - match post.Comments |> List.length with - | 0 -> Strings.get "NoComments" - | 1 -> Strings.get "OneComment" - | x -> String.Format (Strings.get "XComments", x) - /// The comments for display - member this.Comments = post.Comments - |> List.filter (fun c -> c.Status = CommentStatus.Approved) - |> List.map (fun c -> CommentForDisplay (c, webLog.TimeZone)) - - /// Does the post have tags? - member this.HasTags = not <| List.isEmpty post.Tags - /// Get the tags sorted - member this.Tags = post.Tags - |> List.sort - |> List.map (fun tag -> tag, tag.Replace(' ', '+')) - /// Does this post have a newer post? - member this.HasNewer = this.NewerPost.IsSome - /// Does this post have an older post? - member this.HasOlder = this.OlderPost.IsSome - - -/// Wrapper for a post with additional properties -type PostForDisplay (webLog : WebLog, post : Post) = - /// Turn tags into a pipe-delimited string of tags - let pipedTags tags = tags |> List.reduce (fun acc x -> sprintf "%s | %s" acc x) - /// The actual post - member this.Post = post - /// The time zone for the web log to which this post belongs - member this.TimeZone = webLog.TimeZone - /// The date the post was published - member this.PublishedDate = - match this.Post.Status with - | PostStatus.Published -> FormatDateTime.longDate this.TimeZone this.Post.PublishedOn - | _ -> FormatDateTime.longDate this.TimeZone this.Post.UpdatedOn - /// The time the post was published - member this.PublishedTime = - match this.Post.Status with - | PostStatus.Published -> FormatDateTime.time this.TimeZone this.Post.PublishedOn - | _ -> FormatDateTime.time this.TimeZone this.Post.UpdatedOn - /// The number of comments - member this.CommentCount = - match post.Comments |> List.length with - | 0 -> Strings.get "NoComments" - | 1 -> Strings.get "OneComment" - | x -> String.Format (Strings.get "XComments", x) - /// Tags - member this.Tags = - match List.length this.Post.Tags with - | 0 -> "" - | 1 | 2 | 3 | 4 | 5 -> this.Post.Tags |> pipedTags - | count -> sprintf "%s %s" (this.Post.Tags |> List.take 3 |> pipedTags) - (System.String.Format(Strings.get "andXMore", count - 3)) - - -/// Model for all page-of-posts pages -type PostsModel (ctx, webLog) = - inherit MyWebLogModel (ctx, webLog) - /// The subtitle for the page - member val Subtitle : string option = None with get, set - /// The posts to display - member val Posts : PostForDisplay list = [] with get, set - /// The page number of the post list - member val PageNbr = 0 with get, set - /// Whether there is a newer page of posts for the list - member val HasNewer = false with get, set - /// Whether there is an older page of posts for the list - member val HasOlder = true with get, set - /// The prefix for the next/prior links - member val UrlPrefix = "" with get, set - - /// The link for the next newer page of posts - member this.NewerLink = - match this.UrlPrefix = "/posts" && this.PageNbr = 2 && this.WebLog.DefaultPage = "posts" with - | true -> "/" - | _ -> sprintf "%s/page/%i" this.UrlPrefix (this.PageNbr - 1) - - /// The link for the prior (older) page of posts - member this.OlderLink = sprintf "%s/page/%i" this.UrlPrefix (this.PageNbr + 1) - - -/// Form for editing a post -type EditPostForm () = - /// The title of the post - member val Title = "" with get, set - /// The permalink for the post - member val Permalink = "" with get, set - /// The source type for this revision - member val Source = "" with get, set - /// The text - member val Text = "" with get, set - /// Tags for the post - member val Tags = "" with get, set - /// The selected category Ids for the post - member val Categories : string[] = [||] with get, set - /// Whether the post should be published - member val PublishNow = false with get, set - - /// Fill the form with applicable values from a post - member this.ForPost (post : Post) = - this.Title <- post.Title - this.Permalink <- post.Permalink - this.Tags <- match List.isEmpty post.Tags with - | true -> "" - | _ -> List.reduce (fun acc x -> sprintf "%s, %s" acc x) post.Tags - this.Categories <- List.toArray post.CategoryIds - this.PublishNow <- post.Status = PostStatus.Published || "new" = post.Id - this - - /// Fill the form with applicable values from a revision - member this.ForRevision rev = - this.Source <- rev.SourceType - this.Text <- rev.Text - this - -/// Category information for display -type DisplayCategory = { - Id : string - Indent : string - Name : string - Description : string - IsChecked : bool - } -with - /// Create a display category - static member Create (cat : Category, indent) isChecked = - { Id = cat.Id - Indent = String.replicate indent "     " - Name = WebUtility.HtmlEncode cat.Name - IsChecked = isChecked - Description = WebUtility.HtmlEncode (match cat.Description with Some d -> d | _ -> cat.Name) - } - /// The "checked" attribute for this category - member this.CheckedAttr - with get() = match this.IsChecked with true -> "checked=\"checked\"" | _ -> "" - -/// View model for the edit post page -type EditPostModel (ctx, webLog, post, revision) = - inherit MyWebLogModel (ctx, webLog) - - /// The form - member val Form = EditPostForm().ForPost(post).ForRevision(revision) with get, set - /// The post being edited - member val Post = post with get, set - /// The categories to which the post may be assigned - member val Categories : DisplayCategory list = [] with get, set - /// Whether the post is currently published - member this.IsPublished = PostStatus.Published = this.Post.Status - /// The published date - member this.PublishedDate = this.DisplayLongDate this.Post.PublishedOn - /// The published time - member this.PublishedTime = this.DisplayTime this.Post.PublishedOn - /// The "checked" attribute for the Publish Now box - member this.PublishNowCheckedAttr = match this.Form.PublishNow with true -> "checked=\"checked\"" | _ -> "" - - -// ---- User models ---- - -/// Form for the log on page -type LogOnForm () = - /// The URL to which the user will be directed upon successful log on - member val ReturnUrl = "" with get, set - /// The e-mail address - member val Email = "" with get, set - /// The user's passwor - member val Password = "" with get, set - - -/// Model to support the user log on page -type LogOnModel (ctx, webLog) = - inherit MyWebLogModel (ctx, webLog) - /// The log on form - member val Form = LogOnForm () with get, set diff --git a/src/MyWebLog.App/en-US.json b/src/MyWebLog.App/en-US.json deleted file mode 100644 index be2715a..0000000 --- a/src/MyWebLog.App/en-US.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "Action": "Action", - "Added": "Added", - "AddNew": "Add New", - "AddNewCategory": "Add New Category", - "AddNewPage": "Add New Page", - "AddNewPost": "Add New Post", - "Admin": "Admin", - "AndPublished": " and Published", - "andXMore": "and {0} more...", - "at": "at", - "BackToCategoryList": "Back to Category List", - "BackToPageList": "Back to Page List", - "BackToPostList": "Back to Post List", - "Categories": "Categories", - "Category": "Category", - "CategoryDeleteWarning": "Are you sure you wish to delete the category", - "Close": "Close", - "Comments": "Comments", - "Dashboard": "Dashboard", - "Date": "Date", - "Delete": "Delete", - "Description": "Description", - "Edit": "Edit", - "EditCategory": "Edit Category", - "EditPage": "Edit Page", - "EditPost": "Edit Post", - "EmailAddress": "E-mail Address", - "ErrBadAppConfig": "Could not convert config.json to myWebLog configuration", - "ErrBadLogOnAttempt": "Invalid e-mail address or password", - "ErrDataConfig": "Could not convert data-config.json to RethinkDB connection", - "ErrNotConfigured": "is not properly configured for myWebLog", - "Error": "Error", - "LastUpdated": "Last Updated", - "LastUpdatedDate": "Last Updated Date", - "ListAll": "List All", - "LoadedIn": "Loaded in", - "LogOff": "Log Off", - "LogOn": "Log On", - "MsgCategoryDeleted": "Deleted category {0} successfully", - "MsgCategoryEditSuccess": "{0} category successfully", - "MsgLogOffSuccess": "Log off successful | Have a nice day!", - "MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!", - "MsgPageDeleted": "Deleted page successfully", - "MsgPageEditSuccess": "{0} page successfully", - "MsgPostEditSuccess": "{0}{1} post successfully", - "Name": "Name", - "NewerPosts": "Newer Posts", - "NextPost": "Next Post", - "NoComments": "No Comments", - "NoParent": "No Parent", - "OlderPosts": "Older Posts", - "OneComment": "1 Comment", - "PageDeleteWarning": "Are you sure you wish to delete the page", - "PageDetails": "Page Details", - "PageHash": "Page #", - "Pages": "Pages", - "ParentCategory": "Parent Category", - "Password": "Password", - "Permalink": "Permalink", - "PermanentLinkTo": "Permanent Link to", - "PostDetails": "Post Details", - "Posts": "Posts", - "PostsTagged": "Posts Tagged", - "PostStatus": "Post Status", - "PoweredBy": "Powered by", - "PreviousPost": "Previous Post", - "PublishedDate": "Published Date", - "PublishThisPost": "Publish This Post", - "Save": "Save", - "Seconds": "Seconds", - "ShowInPageList": "Show in Page List", - "Slug": "Slug", - "startingWith": "starting with", - "Status": "Status", - "Tags": "Tags", - "Time": "Time", - "Title": "Title", - "Updated": "Updated", - "View": "View", - "Warning": "Warning", - "XComments": "{0} Comments" -} diff --git a/src/MyWebLog.App/project.json b/src/MyWebLog.App/project.json deleted file mode 100644 index 3ed4776..0000000 --- a/src/MyWebLog.App/project.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "buildOptions": { - "compilerName": "fsc", - "compile": { - "includeFiles": [ - "AssemblyInfo.fs", - "Entities/Entities.fs", - "Entities/IMyWebLogData.fs", - "Data/Extensions.fs", - "Data/Table.fs", - "Data/DataConfig.fs", - "Data/Category.fs", - "Data/Page.fs", - "Data/Post.fs", - "Data/User.fs", - "Data/WebLog.fs", - "Data/SetUp.fs", - "Data/RethinkMyWebLogData.fs", - "Logic/Category.fs", - "Logic/Page.fs", - "Logic/Post.fs", - "Logic/User.fs", - "Logic/WebLog.fs", - "Keys.fs", - "AppConfig.fs", - "Strings.fs", - "ViewModels.fs", - "Modules/ModuleExtensions.fs", - "Modules/AdminModule.fs", - "Modules/CategoryModule.fs", - "Modules/PageModule.fs", - "Modules/PostModule.fs", - "Modules/UserModule.fs", - "App.fs" - ] - }, - "embed": { - "include": [ "en-US.json" ] - } - }, - "dependencies": { - "Nancy": "2.0.0-barneyrubble", - "Nancy.Authentication.Forms": "2.0.0-barneyrubble", - "Nancy.Session.Persistable": "0.9.1-pre", - "Nancy.Session.RethinkDB": "0.9.1-pre", - "Newtonsoft.Json": "9.0.1", - "NodaTime": "2.0.0-alpha20160729", - "RethinkDb.Driver": "2.3.15", - "Suave": "2.0.0-rc2" - }, - "frameworks": { - "netstandard1.6": { - "imports": "dnxcore50", - "dependencies": { - "Microsoft.FSharp.Core.netcore": "1.0.0-alpha-161111", - "NETStandard.Library": "1.6.0" - } - } - }, - "tools": { - "dotnet-compile-fsc": "1.0.0-preview2-*" - }, - "version": "0.9.2" -} diff --git a/src/MyWebLog.Old/MyWebLog.xproj b/src/MyWebLog.Old/MyWebLog.xproj deleted file mode 100644 index 4fca1d9..0000000 --- a/src/MyWebLog.Old/MyWebLog.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB - MyWebLog - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/MyWebLog.Old/Program.cs b/src/MyWebLog.Old/Program.cs deleted file mode 100644 index 01ea1cf..0000000 --- a/src/MyWebLog.Old/Program.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MyWebLog -{ - class Program - { - static void Main(string[] args) - { - App.Run(); - } - } -} diff --git a/src/MyWebLog.Old/Properties/AssemblyInfo.cs b/src/MyWebLog.Old/Properties/AssemblyInfo.cs deleted file mode 100644 index f1792ff..0000000 --- a/src/MyWebLog.Old/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("MyWebLog")] -[assembly: AssemblyDescription("A lightweight blogging platform built on Nancy, and RethinkDB")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("MyWebLog")] -[assembly: AssemblyCopyright("Copyright © 2016")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] -[assembly: Guid("b9f6db52-65a1-4c2a-8c97-739e08a1d4fb")] -[assembly: AssemblyVersion("0.9.2.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/MyWebLog.Old/config.json b/src/MyWebLog.Old/config.json deleted file mode 100644 index 53897f0..0000000 --- a/src/MyWebLog.Old/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - // https://www.grc.com/passwords.htm is a great source of high-entropy passwords for these first 4 settings. - // Although what is there looks strong, keep in mind that it's what's in source control, so all instances of myWebLog - // could be using these values; that severly decreases their usefulness. :) - // - // WARNING: Changing this first one will render every single user's login inaccessible, including yours. Only do - // this if you are editing this file before setting up an instance, or if that is what you intend to do. - "password-salt": "3RVkw1jESpLFHr8F3WTThSbFnO3tFrMIckQsKzc9dymzEEXUoUS7nurF4rGpJ8Z", - // Changing any of these next 3 will render all current logins invalid, and the user will be force to reauthenticate. - "auth-salt": "2TweL5wcyGWg5CmMqZSZMonbe9xqQ2Q4vDNeysFRaUgVs4BpFZL85Iew79tn2IJ", - "encryption-passphrase": "jZjY6XyqUZypBcT0NaDXjEKc8xUjB4eb4V9EDHDedadRLuRUeRvIQx67yhx6bQP", - "hmac-passphrase": "42dzKb93X8YUkK8ms8JldjtkEvCKnPQGWCkO2yFaZ7lkNwECGCX00xzrx5ZSElO", - "data": { - "database": "myWebLog", - "hostname": "localhost" - } -} diff --git a/src/MyWebLog.Old/content/logo-dark.png b/src/MyWebLog.Old/content/logo-dark.png deleted file mode 100644 index 19bdcca2af3095d7f18345a28fbf9c6da0363daa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3362 zcmV+-4c+pIP)WFU8GbZ8()Nlj2>E@cM*01SpnL_t(|+U=ZskQCJw z#(zC5Ao3EC2oXWEf}*0J5)f1nK`94}j&Xi*#JudGcqa6%S+KT&-RP!O=+F+ip2H>e(2QR}+oFd72i zs+)9OzW2^kV49E*e<~SAs|Fbs5 zlDRJY=7E3{pwAWZ_d8&@lv@B7W=ZA+mO>V_i4(+MjxV9zGPG|7?3K2QfIC#*do%QH zo$|jSiV9{9}K5=-W~bTu*&Qv1s<1MdPm z<$HIaO+XhTlO)#$xE**4*aqwaz6O@y>eMX`VxFe}(||QV1+WYF7p}&B!V$Rc?PO(J z0&I~LZzr%8cm(Km5M%xXm?MUw64(wb1;UE2NA(KRRZVao&^%4sFQwfU1vXX__Wc%-Vk;C2kPQ~?W2eksJ`=j#viWQY>7kRh+E)=K5{MNZKtLg^KR8{3(&)(O)KD^DG$*hk~zXv zL?|Xo8Bs>(5Sa(7NpAFM^N!T-2U@1otx)~X_vl-y>TmL>Z=!DAHsFUD&bK(jN}_LC zS%&f2h}#lx-?NU$+)AwkRb-Yjdq_KjD}%Q78M9K|mvhsMcdYp10p}5>4-sY(Wi?T@ zGD7^Fp&TQ}jrzn0)A^8T7^X8378B(g!nE^kS3naY+)soGA}nMmO;v4US^nb_?UY8s zTav*#-0l%5m#O-W87%jh0JH9)+CQ3s%o75PH$}C-ua3xkvl`=xfPH4GKEL#+AFJwT z1Q_pHzj=fdp_&n8Dh^9=7%9G2Tk<%aDA&@G&*k_&43&u`GnC{zI)s@-gfEEjmq39; zm?$CM$DuE9JjQ2xI6^n7S&hR)9Gc-Ui01TGE%u7k=BH{5R`c~zBJ{CA9=>+ms;mVz z`$XY!!aNgT?rT*!lQ#ZrfbsPCpI=9--({*iD`20$tMWx2_5Dw8MWX-$MBgm@yM zJ{Ue=W{P$@l!k(a7 z9qeLF%CUdQMTtN0jT4XL0c-mOu-fCS;>v@2=E<6JkS**shd`1hY3eEhjgf zFaF>ia>ABN<|5H1-GHhTM=XWm6C56+n7^j)BxJrKfSZ6w zKQURc;+Fhk4y1X$E)lmEb^>js?SnE-Z6N3_Z3k7}UPol!A#-dc+^NdKnxot}QXagP z1m6*D9_&K3pg2j(Gz_P(29d!e`FM?Rm`bsb%P1ElCh3)<=t7KzIE)8YQp7+UvEFW< zH;A#42xBp<6e3RtE~khQl(L6*T&^Z!sNiv*InDR*o8$iSzLX#_72B0B*b(SL@Ga~O z%0Sd+0Cv2x`swgvRbE*~WY%-+9`jVlcQH9pkExhP9j`Nmo#P-Ebd0PVDExTKEAyyzh2 zI82qj42cfQWnTCjv|WE47eh()Q1>DX<|t=7~VjK$r}rgXYkRq1sxaAvU(A=H*lVmS7*>?OO$Idnqaxn zL2*w8>-wt49PlUfIAQ%xyT_jKeCbxt^K++F&3=cviVUcT>n+0V4ZvNKOXYMDS$#*}(NF$NB|W zzMDuJom=o9xe1$vWUYX+B=~Jp<37|>0lvgd!Wo9UO+Hs1oaR7Zk~8Jz0z(5R3zb<| z?;#$F#w#iA*K*vv@G{^yncJmmUej>XUj1+(uvp2j6`~}4B>%6c1o0u~kC0E4o`kuDFp~*$4gEZq-~}XaQaqByf4wfH?=FJ99~gg-W|N#XmhSUn z8sf#bY+DFf(v4HBq%$&%na#(~k1q@`#|mJ4!0R?h6rpsROhbgw@nfHP>CCwc!;J}l z(O_^`hQScxE8Lx~w&XL9#Wj+3=99b&n2jH)B|3mtvsk+KNHF$iV5C^j4kABQ%8K!^ zNRx9(-q!X*Ic^$gM8-Tr2(?uL&mswK4;S_VQIR0S5-7J9H)w~r4GV?v+p@Ukoq=CV z3~;It_H!{z(`9u%PWAcL+s(*DL^%(`avZTZF@8&oXDN}~hywD6u?jeoU9_as-=DHL zYS2|le6MMrJ$GN53NsnQiUg5i6~#-%I7FC= z!^D*1lRbU75*AT+i8U6vXyy)JY3{li{b)cwvn2;(KJYv7mEWW&(8htq(T%ad!&2T1 zN)Ba^ zBTMFyoJfd!sV2%y${58Z=sZ~y_&1X+ERL|6k;fk~tYaoG0MLLbsC96J{-PcW8%j|^lu~Ios|1*VUfdRc7UsiF^k1i z6JZoi;x`$dqcG(s2P`ZClIrkd6kTY*TB_-dVLN$z1T?}>iJ?EmNk1NLVUfd@xq%4e zA=;A1T;gvSINV{0Y*D`~^H7eV1@GfWFU8GbZ8()Nlj2>E@cM*01t~vL_t(|+U=ZsbXC=z z$3Oern-~HFP@qszX~72yB0h==Dk_4nSuQ%l@?FWjI`uJjX)EoFikVWzQk>3=Gu75Y zfGJ`PKI&t@I>^I*|&)&b^ zIeYK#`R(8S?ccc&AV7cs0RjXF5a8)^**BEZNPS zk=0tyuw=IcFGxV<1A!N;|6kmxjyuj}>HjOGPH1m$KgO@DQtBeBxGb=V0htekWwyzD zYAhD(-wE-{>_Smd(PTeDpM3I32{7JzhJefg_8&DhHAN!Ql3qTw*4OxvOC%D~pkiI&VRkK6}^OAz?s-QPM_S(CKLr_Xz*B~$PHZLO}Z4*R&@&n?*(4krIL@R_^+ zrp6}Jq>73P<-_N_)RWx}yzX5q8$EjTSw3z#(Yp8Yz?=j)V2H(H{Y6BltxhtTJSAWI z#>U28B9ibd)GDp@y_UtAUEp3v*!OC0Z~s{t}pzfXrRyYAB`h64DOIX|0#7Tet2FW6Tck z(wKNWJ|K^KGSMpLjbg;(@c~-vdqiZNh-?#)q=2|K`UX*8pd6S5=nmiDZ?XK%y;MXN^UH3{6*_B6Mlk2+Y|1I2%ii!%QwZ2b8+Drk6 z$m0zS4W)&2HZ?W%7Ln%*=vrQ1U*F@9kIINhokg@Oa*%$EH4v2+`EIbTFV0~VziqXN z*VWY>mg6~oX0;O$c}YYnwAOcu$S*`>qy6HgQmL_pi08WQBu`gTM4r)F-=np@TSOL$ z$WA*{m`o-|b~TwrJ{z}Uw7?>V>5&1k@L_}+Sv!(yJ9Qv=e z+I)~qCdcHkYIj-fYSYVS4&t?l$W{^AoJb^&&$iTy$eULCSG$tTTI(NKdYWC=y(C+^ zR4O%2L_W9L&dD=(Kea=psZ{EW%r>p9t^Gvgj~Oq~ev&!DMmqtY^u^=x0V1;9dj7FpL1vFoJ3Sq4DwWC`%1otFXNZXFwcj0!#g53Kvn@R@$z*bL zE`1FR4W%No$$vk}8ApT#M0uYmTe+S+{?1fNyz9pLCzNyCelxM0vJ1^*wf7RaKQEB8^r^?wEX* zTWftwp)RLVsWYrec_bg1wbpm}SeYs;eGha6nYGqGuyp*^=Zd-5>c_2Fblzs^eALG? zT$QUIjzy?mM45x)6%;oDBY{DLIi4t2(TBf!*OM69(i?mnY~N8)&I}@aN`!y&(N{$| zQ5>pKoCh3^p(MwOa0;6DQQVE97mCUB=InI!(4j*Uz>0J^91dTc5mvj{3ac&4GDyp; zsKq{c8dIiB8Kaas!js)pQd079mPp4XX~M+g@!uEf_>LVrHd)2IvGGJA@q~{_w)=Ei zSFCYinzJIe%!boRd*(24q!-=p0hdC&YL~$3_F(~I#UCj|hScAiFfj!X&!&@w1-`MR`iYR9$ ziZ@UU%Xs>oenWIhsmpRqJOIx;^URyZm?p1ymK{2C9LGd9 zOZJF7e(uiF($dcgP4cc?yK1fC;4bp!PWGNjH#9U<`w>W`Qg2(u;aQrTk~RX(&CTlz z>3i_O2OIN~S@Am*LxA65I4WbpE&~jP8O&n}3;7sjSz`y6sr00XWhl-7k{xwn`eeOp zlxg&2Gm3LEY989BbR*xOjCDBt3y`@RZl;xu85Zn+jWNb6PDn=_RlWf?OEf=z|&q<+&Hg3R$E*9rySxT zuT9(-b6+P(65C&v4Ie(dr@bfaA~F|stjGTSY@2-9Ir{WM+Gq3#xgrz_U20j@WqJIuKbDr3isxHT1{gee z@HtAUv>*TSNhh6@%H;+FgMgB!o_gvb&(a?V_&TL6EiFCTDsJy0GB{ne~9X|c^ z)7?S>0|ySYPbQnP1!I6dp3y3ZdQAFmk294b9>8OsF` zwgNDn5eP4$xC7Y0d?ukh>%B*2CBglr3{vhq5Oqnv}KyJVe_uQns z2!%r5v5FhIh|C>*96fsU7(W8V#l`mK_Ps1kwt6~yBoc|?h4ht`m7P$S%+r~KVwy#; zXa_;R7=uVyrA4$dv!g~?Z(o?>iEsxF8-a5%ET@h0yxMV=j$;T@i{igA)FDKe&p$Gs zkF0xMV?EnzEG4UjoIQK?+s2rBZ-7Q7lgW`vsZn0_+W!6f7g~asTM>~9fR5dEGMUVm z^7)EItnVGb2*P3G$B#ePN4x8+)n`o?k$F{T`{+2%H0!yt8ItwZc>leRm-foSWOnxE z>0-E@`OMFwbA88B?{EuCkq$}2YgW6(DE}<1x=aG?u@+CrU9G!$a)kbSkL? z%s!D1un!h~Q(%Lz%o?EEeOlJEWnXR)oJyrmFVOx}*kgo@F@LSCt$i*_ z=zp_3M;U_)jCKSQK)O)0m=6C$(x_!SZiV)wU42-)>q~F zSYY+))gjk)CyU6c9GlTEBW8vOI1G3d&4n!7H{{jXm639Og5n1#-bHgQ z3uxRglMvwv%LV!=^SD1>^KIL<_3Yojf0I(`Fl!>~9LM>V&k0+sl)5&x`}-NNPAN6b7_$v{(iroR zQmQv_o>J<3OXf+(ai-=Yv-h_RpI?{maM0i; zjwOoQaeN+O7*WPj&YhGqlXAYB%~FfV@(j!Na3{h^*JQ~0`aUrIB8kXiJ8P^!TH~{Y ztY~v{bD4;&ETE^drlzJS-?N!7foyub)PZ@0`Y0kXt@Ry##?4!$cI43&cU||&s;Vl- zx^|F#91-PbmW!c@vXuz+M0uMiTfOVgW+CLQ_F%ZKd%5qRZ^q|QBJ$*y@Gnmhk^c~p z4I6w}Z$4kK*ETJJ3KeCYb6&AE8j_Y^!MJeGu-3&#Qg2`=VE}$CPrT)v>%-%O)!UWwt zH-P2bjza}5XA)*Cco}qt9i$k^|4<)H`=JRd{=Jhu31~IOm=>i}FW@-##H{;T?H_R* z=hiR!r`-V&hN0Mma2|hVJ;T_D;slS(zh*wS1XF%UTjo}!RGAOb$BZ%eheDx$&sN_Z zHh>Dn5)5}!$?HV8%_4FN!z00TAKGYcZa%VKzkXLLr6vKRfgwOoU^jcxM&C8Ytct~A zi~9EM+mcJqS5M}0ZU&Az8yX>RP00Ef~a0COcKv=|TG!dqw_WULT z@HF!>esUl{0H37#+haN-2(byx*%-DHrUod+(1zgxD);_)cz^)i+cJl|&&nMJ5#~wK zKk%ZsHz0C=gUd2crGzr7QJiF5eS~>_K2{76AeW8&DzFKc;Vh<@KEMbRkK=F)Zy~{y l1PBlyK!5-N0tD!0_<#2 - - - - - @Model.PageTitle | @Translate.Admin | @Model.WebLog.Name - - - - - - -
    - -
    -
    - @Each.Messages - @Current.ToDisplay - @EndEach - @Section['Content']; -
    -
    -
    -
    -
    @Model.FooterLogoLight  
    -
    -
    -
    - - - - @Section['Scripts']; - - \ No newline at end of file diff --git a/src/MyWebLog.Old/views/admin/category/edit.html b/src/MyWebLog.Old/views/admin/category/edit.html deleted file mode 100644 index ca39310..0000000 --- a/src/MyWebLog.Old/views/admin/category/edit.html +++ /dev/null @@ -1,55 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - - @AntiForgeryToken -
    -
    - - @Translate.BackToCategoryList - -
    - - -
    -
    -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    -

    - -

    -
    -
    - -@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyWebLog.Old/views/admin/category/list.html b/src/MyWebLog.Old/views/admin/category/list.html deleted file mode 100644 index 14d7faa..0000000 --- a/src/MyWebLog.Old/views/admin/category/list.html +++ /dev/null @@ -1,51 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - -
    - - - - - - - @Each.Categories - - - - - - @EndEach -
    @Translate.Action@Translate.Category@Translate.Description
    - @Translate.Edit   - - @Translate.Delete - - @Current.ListName - @If.HasDescription - @Current.Category.Description.Value - @EndIf - @IfNot.HasDescription -   - @EndIf -
    -
    -
    - @AntiForgeryToken -
    -@EndSection - -@Section['Scripts'] - -@EndSection \ No newline at end of file diff --git a/src/MyWebLog.Old/views/admin/content/admin.css b/src/MyWebLog.Old/views/admin/content/admin.css deleted file mode 100644 index 7a8dc5c..0000000 --- a/src/MyWebLog.Old/views/admin/content/admin.css +++ /dev/null @@ -1,5 +0,0 @@ -footer { - background-color: #808080; - border-top: solid 1px black; - color: white; -} \ No newline at end of file diff --git a/src/MyWebLog.Old/views/admin/content/tinymce-init.js b/src/MyWebLog.Old/views/admin/content/tinymce-init.js deleted file mode 100644 index 48c9339..0000000 --- a/src/MyWebLog.Old/views/admin/content/tinymce-init.js +++ /dev/null @@ -1,10 +0,0 @@ -tinymce.init({ - menubar: false, - plugins: [ - "advlist autolink link image lists charmap print preview hr anchor pagebreak spellchecker", - "searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking", - "save table contextmenu directionality emoticons template paste textcolor" - ], - selector: "textarea", - toolbar: "styleselect | forecolor backcolor | bullist numlist | link unlink anchor | paste pastetext | spellchecker | visualblocks visualchars | code fullscreen" -}) \ No newline at end of file diff --git a/src/MyWebLog.Old/views/admin/dashboard.html b/src/MyWebLog.Old/views/admin/dashboard.html deleted file mode 100644 index c4e75a5..0000000 --- a/src/MyWebLog.Old/views/admin/dashboard.html +++ /dev/null @@ -1,31 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    -
    -

    @Translate.Posts  @Model.Posts

    -

    - @Translate.ListAll -     - @Translate.AddNew -

    -
    -
    -

    @Translate.Pages  @Model.Pages

    -

    - @Translate.ListAll -     - @Translate.AddNew -

    -
    -
    -

    @Translate.Categories  @Model.Categories

    -

    - @Translate.ListAll -     - @Translate.AddNew -

    -
    -
    -
    -@EndSection diff --git a/src/MyWebLog.Old/views/admin/page/edit.html b/src/MyWebLog.Old/views/admin/page/edit.html deleted file mode 100644 index facb824..0000000 --- a/src/MyWebLog.Old/views/admin/page/edit.html +++ /dev/null @@ -1,61 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken -
    -
    - - @Translate.BackToPageList - -
    - - -
    -
    - - -

    @Translate.startingWith //@Model.WebLog.UrlBase/

    -
    - - -
    - -
    -
    -
    -
    -
    @Translate.PageDetails
    -
    - @IfNot.isNew -
    - -

    @Model.PublishedDate
    @Model.PublishedTime

    -
    -
    - -

    @Model.LastUpdatedDate
    @Model.LastUpdatedTime

    -
    - @EndIf -
    - -   -
    -
    -
    -
    -

    -
    -
    -
    -
    -@EndSection - -@Section['Scripts'] - - -@EndSection diff --git a/src/MyWebLog.Old/views/admin/page/list.html b/src/MyWebLog.Old/views/admin/page/list.html deleted file mode 100644 index 7e5f759..0000000 --- a/src/MyWebLog.Old/views/admin/page/list.html +++ /dev/null @@ -1,42 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - -
    - - - - - - @Each.Pages - - - - - @EndEach -
    @Translate.Title@Translate.LastUpdated
    - @Current.Page.Title
    - @Translate.View   - @Translate.Edit   - @Translate.Delete -
    @Current.UpdatedDate
    @Translate.at @Current.UpdatedTime
    -
    -
    - @AntiForgeryToken -
    -@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyWebLog.Old/views/admin/post/edit.html b/src/MyWebLog.Old/views/admin/post/edit.html deleted file mode 100644 index 39963c7..0000000 --- a/src/MyWebLog.Old/views/admin/post/edit.html +++ /dev/null @@ -1,90 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken -
    -
    - - @Translate.BackToPostList - -
    - - -
    -
    - - -

    @Translate.startingWith //@Model.WebLog.UrlBase/

    -
    - - -
    - -
    -
    - - -
    -
    -
    -
    -
    -

    @Translate.PostDetails

    -
    -
    -
    - -

    @Model.Post.Status

    -
    - @If.IsPublished -
    - -

    @Model.PublishedDate
    @Model.PublishedTime

    -
    - @EndIf -
    -
    -
    -
    -

    @Translate.Categories

    -
    -
    - @Each.Categories - @Current.Indent - -   - -
    - @EndEach -
    -
    -
    - @If.IsPublished - - @EndIf - @IfNot.IsPublished -
    - -   -
    - @EndIf -

    - -

    -
    -
    -
    -
    -@EndSection - -@Section['Scripts'] - - -@EndSection diff --git a/src/MyWebLog.Old/views/admin/post/list.html b/src/MyWebLog.Old/views/admin/post/list.html deleted file mode 100644 index ee9cf74..0000000 --- a/src/MyWebLog.Old/views/admin/post/list.html +++ /dev/null @@ -1,49 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] - -
    - - - - - - - - @Each.Posts - - - - - - - @EndEach -
    @Translate.Date@Translate.Title@Translate.Status@Translate.Tags
    - @Current.PublishedDate
    - @Translate.at @Current.PublishedTime -
    - @Current.Post.Title
    - @Translate.View  |  - @Translate.Edit  |  - @Translate.Delete -
    @Current.Post.Status@Current.Tags
    -
    -
    -
    - @If.HasNewer -

    «  @Translate.NewerPosts

    - @EndIf -
    -
    - @If.HasOlder -

    @Translate.OlderPosts  »

    - @EndIf -
    -
    -@EndSection diff --git a/src/MyWebLog.Old/views/admin/user/log-on.html b/src/MyWebLog.Old/views/admin/user/log-on.html deleted file mode 100644 index 2115f3c..0000000 --- a/src/MyWebLog.Old/views/admin/user/log-on.html +++ /dev/null @@ -1,41 +0,0 @@ -@Master['admin/admin-layout'] - -@Section['Content'] -
    - @AntiForgeryToken - -
    -
    -
    - - -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -

    -
    - -

    -
    -
    -
    -@EndSection - -@Section['Scripts'] - -@EndSection diff --git a/src/MyWebLog.Old/views/themes/default/comment.html b/src/MyWebLog.Old/views/themes/default/comment.html deleted file mode 100644 index 2c7b34b..0000000 --- a/src/MyWebLog.Old/views/themes/default/comment.html +++ /dev/null @@ -1,4 +0,0 @@ -

    - @Model.Commentor    @Model.CommentedOn -

    -@Model.Comment.Text \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css b/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css deleted file mode 100644 index b0fdfcb..0000000 --- a/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css +++ /dev/null @@ -1,476 +0,0 @@ -/*! - * Bootstrap v3.3.4 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -.btn-default, -.btn-primary, -.btn-success, -.btn-info, -.btn-warning, -.btn-danger { - text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); -} -.btn-default:active, -.btn-primary:active, -.btn-success:active, -.btn-info:active, -.btn-warning:active, -.btn-danger:active, -.btn-default.active, -.btn-primary.active, -.btn-success.active, -.btn-info.active, -.btn-warning.active, -.btn-danger.active { - -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); - box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); -} -.btn-default .badge, -.btn-primary .badge, -.btn-success .badge, -.btn-info .badge, -.btn-warning .badge, -.btn-danger .badge { - text-shadow: none; -} -.btn:active, -.btn.active { - background-image: none; -} -.btn-default { - text-shadow: 0 1px 0 #fff; - background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); - background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0)); - background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-color: #dbdbdb; - border-color: #ccc; -} -.btn-default:hover, -.btn-default:focus { - background-color: #e0e0e0; - background-position: 0 -15px; -} -.btn-default:active, -.btn-default.active { - background-color: #e0e0e0; - border-color: #dbdbdb; -} -.btn-default.disabled, -.btn-default:disabled, -.btn-default[disabled] { - background-color: #e0e0e0; - background-image: none; -} -.btn-primary { - background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%); - background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88)); - background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-color: #245580; -} -.btn-primary:hover, -.btn-primary:focus { - background-color: #265a88; - background-position: 0 -15px; -} -.btn-primary:active, -.btn-primary.active { - background-color: #265a88; - border-color: #245580; -} -.btn-primary.disabled, -.btn-primary:disabled, -.btn-primary[disabled] { - background-color: #265a88; - background-image: none; -} -.btn-success { - background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); - background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641)); - background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-color: #3e8f3e; -} -.btn-success:hover, -.btn-success:focus { - background-color: #419641; - background-position: 0 -15px; -} -.btn-success:active, -.btn-success.active { - background-color: #419641; - border-color: #3e8f3e; -} -.btn-success.disabled, -.btn-success:disabled, -.btn-success[disabled] { - background-color: #419641; - background-image: none; -} -.btn-info { - background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); - background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2)); - background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-color: #28a4c9; -} -.btn-info:hover, -.btn-info:focus { - background-color: #2aabd2; - background-position: 0 -15px; -} -.btn-info:active, -.btn-info.active { - background-color: #2aabd2; - border-color: #28a4c9; -} -.btn-info.disabled, -.btn-info:disabled, -.btn-info[disabled] { - background-color: #2aabd2; - background-image: none; -} -.btn-warning { - background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); - background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316)); - background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-color: #e38d13; -} -.btn-warning:hover, -.btn-warning:focus { - background-color: #eb9316; - background-position: 0 -15px; -} -.btn-warning:active, -.btn-warning.active { - background-color: #eb9316; - border-color: #e38d13; -} -.btn-warning.disabled, -.btn-warning:disabled, -.btn-warning[disabled] { - background-color: #eb9316; - background-image: none; -} -.btn-danger { - background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); - background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a)); - background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-color: #b92c28; -} -.btn-danger:hover, -.btn-danger:focus { - background-color: #c12e2a; - background-position: 0 -15px; -} -.btn-danger:active, -.btn-danger.active { - background-color: #c12e2a; - border-color: #b92c28; -} -.btn-danger.disabled, -.btn-danger:disabled, -.btn-danger[disabled] { - background-color: #c12e2a; - background-image: none; -} -.thumbnail, -.img-thumbnail { - -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); - box-shadow: 0 1px 2px rgba(0, 0, 0, .075); -} -.dropdown-menu > li > a:hover, -.dropdown-menu > li > a:focus { - background-color: #e8e8e8; - background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); - background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); - background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); - background-repeat: repeat-x; -} -.dropdown-menu > .active > a, -.dropdown-menu > .active > a:hover, -.dropdown-menu > .active > a:focus { - background-color: #2e6da4; - background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); - background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); - background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); - background-repeat: repeat-x; -} -.navbar-default { - background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); - background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8)); - background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; - border-radius: 4px; - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); -} -.navbar-default .navbar-nav > .open > a, -.navbar-default .navbar-nav > .active > a { - background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); - background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2)); - background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); - background-repeat: repeat-x; - -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); - box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); -} -.navbar-brand, -.navbar-nav > li > a { - text-shadow: 0 1px 0 rgba(255, 255, 255, .25); -} -.navbar-inverse { - background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); - background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222)); - background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); - background-repeat: repeat-x; -} -.navbar-inverse .navbar-nav > .open > a, -.navbar-inverse .navbar-nav > .active > a { - background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%); - background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f)); - background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); - background-repeat: repeat-x; - -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); - box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); -} -.navbar-inverse .navbar-brand, -.navbar-inverse .navbar-nav > li > a { - text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); -} -.navbar-static-top, -.navbar-fixed-top, -.navbar-fixed-bottom { - border-radius: 0; -} -@media (max-width: 767px) { - .navbar .navbar-nav .open .dropdown-menu > .active > a, - .navbar .navbar-nav .open .dropdown-menu > .active > a:hover, - .navbar .navbar-nav .open .dropdown-menu > .active > a:focus { - color: #fff; - background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); - background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); - background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); - background-repeat: repeat-x; - } -} -.alert { - text-shadow: 0 1px 0 rgba(255, 255, 255, .2); - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); -} -.alert-success { - background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); - background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc)); - background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); - background-repeat: repeat-x; - border-color: #b2dba1; -} -.alert-info { - background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); - background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0)); - background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); - background-repeat: repeat-x; - border-color: #9acfea; -} -.alert-warning { - background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); - background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0)); - background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); - background-repeat: repeat-x; - border-color: #f5e79e; -} -.alert-danger { - background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); - background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3)); - background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); - background-repeat: repeat-x; - border-color: #dca7a7; -} -.progress { - background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); - background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5)); - background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); - background-repeat: repeat-x; -} -.progress-bar { - background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%); - background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090)); - background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0); - background-repeat: repeat-x; -} -.progress-bar-success { - background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); - background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44)); - background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); - background-repeat: repeat-x; -} -.progress-bar-info { - background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); - background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5)); - background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); - background-repeat: repeat-x; -} -.progress-bar-warning { - background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); - background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f)); - background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); - background-repeat: repeat-x; -} -.progress-bar-danger { - background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); - background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c)); - background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); - background-repeat: repeat-x; -} -.progress-bar-striped { - background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); - background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); - background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); -} -.list-group { - border-radius: 4px; - -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); - box-shadow: 0 1px 2px rgba(0, 0, 0, .075); -} -.list-group-item.active, -.list-group-item.active:hover, -.list-group-item.active:focus { - text-shadow: 0 -1px 0 #286090; - background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%); - background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a)); - background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0); - background-repeat: repeat-x; - border-color: #2b669a; -} -.list-group-item.active .badge, -.list-group-item.active:hover .badge, -.list-group-item.active:focus .badge { - text-shadow: none; -} -.panel { - -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); - box-shadow: 0 1px 2px rgba(0, 0, 0, .05); -} -.panel-default > .panel-heading { - background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); - background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); - background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); - background-repeat: repeat-x; -} -.panel-primary > .panel-heading { - background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); - background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); - background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); - background-repeat: repeat-x; -} -.panel-success > .panel-heading { - background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); - background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6)); - background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); - background-repeat: repeat-x; -} -.panel-info > .panel-heading { - background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); - background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3)); - background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); - background-repeat: repeat-x; -} -.panel-warning > .panel-heading { - background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); - background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc)); - background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); - background-repeat: repeat-x; -} -.panel-danger > .panel-heading { - background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); - background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc)); - background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); - background-repeat: repeat-x; -} -.well { - background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); - background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5)); - background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); - background-repeat: repeat-x; - border-color: #dcdcdc; - -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); - box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); -} -/*# sourceMappingURL=bootstrap-theme.css.map */ diff --git a/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css.map b/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css.map deleted file mode 100644 index 5a12d63..0000000 --- a/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","bootstrap-theme.css","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":"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","file":"bootstrap-theme.css","sourcesContent":["\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &:disabled,\n &[disabled] {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They will be removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility){\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n",".btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.btn-default:active,\n.btn-primary:active,\n.btn-success:active,\n.btn-info:active,\n.btn-warning:active,\n.btn-danger:active,\n.btn-default.active,\n.btn-primary.active,\n.btn-success.active,\n.btn-info.active,\n.btn-warning.active,\n.btn-danger.active {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-default .badge,\n.btn-primary .badge,\n.btn-success .badge,\n.btn-info .badge,\n.btn-warning .badge,\n.btn-danger .badge {\n text-shadow: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n}\n.btn-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #dbdbdb;\n text-shadow: 0 1px 0 #fff;\n border-color: #ccc;\n}\n.btn-default:hover,\n.btn-default:focus {\n background-color: #e0e0e0;\n background-position: 0 -15px;\n}\n.btn-default:active,\n.btn-default.active {\n background-color: #e0e0e0;\n border-color: #dbdbdb;\n}\n.btn-default.disabled,\n.btn-default:disabled,\n.btn-default[disabled] {\n background-color: #e0e0e0;\n background-image: none;\n}\n.btn-primary {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #245580;\n}\n.btn-primary:hover,\n.btn-primary:focus {\n background-color: #265a88;\n background-position: 0 -15px;\n}\n.btn-primary:active,\n.btn-primary.active {\n background-color: #265a88;\n border-color: #245580;\n}\n.btn-primary.disabled,\n.btn-primary:disabled,\n.btn-primary[disabled] {\n background-color: #265a88;\n background-image: none;\n}\n.btn-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #3e8f3e;\n}\n.btn-success:hover,\n.btn-success:focus {\n background-color: #419641;\n background-position: 0 -15px;\n}\n.btn-success:active,\n.btn-success.active {\n background-color: #419641;\n border-color: #3e8f3e;\n}\n.btn-success.disabled,\n.btn-success:disabled,\n.btn-success[disabled] {\n background-color: #419641;\n background-image: none;\n}\n.btn-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #28a4c9;\n}\n.btn-info:hover,\n.btn-info:focus {\n background-color: #2aabd2;\n background-position: 0 -15px;\n}\n.btn-info:active,\n.btn-info.active {\n background-color: #2aabd2;\n border-color: #28a4c9;\n}\n.btn-info.disabled,\n.btn-info:disabled,\n.btn-info[disabled] {\n background-color: #2aabd2;\n background-image: none;\n}\n.btn-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #e38d13;\n}\n.btn-warning:hover,\n.btn-warning:focus {\n background-color: #eb9316;\n background-position: 0 -15px;\n}\n.btn-warning:active,\n.btn-warning.active {\n background-color: #eb9316;\n border-color: #e38d13;\n}\n.btn-warning.disabled,\n.btn-warning:disabled,\n.btn-warning[disabled] {\n background-color: #eb9316;\n background-image: none;\n}\n.btn-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #b92c28;\n}\n.btn-danger:hover,\n.btn-danger:focus {\n background-color: #c12e2a;\n background-position: 0 -15px;\n}\n.btn-danger:active,\n.btn-danger.active {\n background-color: #c12e2a;\n border-color: #b92c28;\n}\n.btn-danger.disabled,\n.btn-danger:disabled,\n.btn-danger[disabled] {\n background-color: #c12e2a;\n background-image: none;\n}\n.thumbnail,\n.img-thumbnail {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n background-color: #e8e8e8;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n background-color: #2e6da4;\n}\n.navbar-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);\n}\n.navbar-inverse {\n background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: -o-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n}\n.navbar-inverse .navbar-brand,\n.navbar-inverse .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n@media (max-width: 767px) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n }\n}\n.alert {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.alert-success {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);\n border-color: #b2dba1;\n}\n.alert-info {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);\n border-color: #9acfea;\n}\n.alert-warning {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);\n border-color: #f5e79e;\n}\n.alert-danger {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);\n border-color: #dca7a7;\n}\n.progress {\n background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);\n}\n.progress-bar {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);\n}\n.progress-bar-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);\n}\n.progress-bar-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);\n}\n.progress-bar-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);\n}\n.progress-bar-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);\n}\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.list-group {\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 #286090;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);\n border-color: #2b669a;\n}\n.list-group-item.active .badge,\n.list-group-item.active:hover .badge,\n.list-group-item.active:focus .badge {\n text-shadow: none;\n}\n.panel {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.panel-default > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n}\n.panel-primary > .panel-heading {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n}\n.panel-success > .panel-heading {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);\n}\n.panel-info > .panel-heading {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);\n}\n.panel-warning > .panel-heading {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);\n}\n.panel-danger > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);\n}\n.well {\n background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);\n border-color: #dcdcdc;\n -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n}\n/*# sourceMappingURL=bootstrap-theme.css.map */","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.min.css b/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.min.css deleted file mode 100644 index cefa3d1..0000000 --- a/src/MyWebLog.Old/views/themes/default/content/bootstrap-theme.min.css +++ /dev/null @@ -1,5 +0,0 @@ -/*! - * Bootstrap v3.3.4 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default:disabled,.btn-default[disabled]{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary:disabled,.btn-primary[disabled]{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success:disabled,.btn-success[disabled]{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info:disabled,.btn-info[disabled]{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning:disabled,.btn-warning[disabled]{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger:disabled,.btn-danger[disabled]{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/footer.html b/src/MyWebLog.Old/views/themes/default/footer.html deleted file mode 100644 index ec3f017..0000000 --- a/src/MyWebLog.Old/views/themes/default/footer.html +++ /dev/null @@ -1,10 +0,0 @@ -
    -
    -
    -
    -
    - @Model.FooterLogoDark -
    -
    -
    -
    \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/index-content.html b/src/MyWebLog.Old/views/themes/default/index-content.html deleted file mode 100644 index 18a7f6b..0000000 --- a/src/MyWebLog.Old/views/themes/default/index-content.html +++ /dev/null @@ -1,43 +0,0 @@ -@Each.Messages - @Current.ToDisplay -@EndEach -@If.SubTitle.IsSome -

    - @Model.SubTitle -

    -@EndIf -@Each.Posts -
    -
    -
    -

    - @Current.Post.Title -

    -

    - @Current.PublishedDate   - @Current.PublishedTime   - @Current.CommentCount -

    - @Current.Post.Text -
    -
    -
    -
    -@EndEach -
    -
    - @If.HasNewer -

    - @Translate.NewerPosts -

    - @EndIf -
    -
    - @If.HasOlder -

    - @Translate.OlderPosts -

    - @EndIf -
    -
    \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/index.html b/src/MyWebLog.Old/views/themes/default/index.html deleted file mode 100644 index 6ef4c3a..0000000 --- a/src/MyWebLog.Old/views/themes/default/index.html +++ /dev/null @@ -1,9 +0,0 @@ -@Master['themes/default/layout'] - -@Section['Content'] - @Partial['themes/default/index-content', Model] -@EndSection - -@Section['Footer'] - @Partial['themes/default/footer', Model] -@EndSection diff --git a/src/MyWebLog.Old/views/themes/default/layout.html b/src/MyWebLog.Old/views/themes/default/layout.html deleted file mode 100644 index ed29d2c..0000000 --- a/src/MyWebLog.Old/views/themes/default/layout.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - @Model.DisplayPageTitle - - - - - - @Section['Head']; - - -
    - -
    -
    - @Section['Content']; -
    - @Section['Footer']; - - - @Section['Scripts']; - - \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/page-content.html b/src/MyWebLog.Old/views/themes/default/page-content.html deleted file mode 100644 index 731e6b8..0000000 --- a/src/MyWebLog.Old/views/themes/default/page-content.html +++ /dev/null @@ -1,4 +0,0 @@ -
    -

    @Model.Page.Title

    - @Model.Page.Text -
    \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/page.html b/src/MyWebLog.Old/views/themes/default/page.html deleted file mode 100644 index d3d60c1..0000000 --- a/src/MyWebLog.Old/views/themes/default/page.html +++ /dev/null @@ -1,9 +0,0 @@ -@Master['themes/default/layout'] - -@Section['Content'] - @Partial['themes/default/page-content', Model] -@EndSection - -@Section['Footer'] - @Partial['themes/default/footer', Model] -@EndSection diff --git a/src/MyWebLog.Old/views/themes/default/single-content.html b/src/MyWebLog.Old/views/themes/default/single-content.html deleted file mode 100644 index c2e3f94..0000000 --- a/src/MyWebLog.Old/views/themes/default/single-content.html +++ /dev/null @@ -1,67 +0,0 @@ -
    -
    -

    @Model.Post.Title

    -
    -
    -
    -

    - @Model.PublishedDate   - @Model.PublishedTime   - @Model.CommentCount       - @Each.Post.Categories - - - @Current.Name -     - - @EndEach -

    -
    -
    -
    -
    @Model.Post.Text
    -
    - @If.HasTags -
    -
    - @Each.Tags - - - @Current.Item1 -     - - @EndEach -
    -
    - @EndIf -
    -
    -

    -
    -
    -
    - @Each.Comments - @Partial['themes/default/comment', @Current] - @EndEach -
    -
    -
    -

    -
    -
    -
    - @If.HasNewer - - «  @Model.NewerPost.Value.Title - - @EndIf -
    -
    - @If.HasOlder - - @Model.OlderPost.Value.Title  » - - @EndIf -
    -
    \ No newline at end of file diff --git a/src/MyWebLog.Old/views/themes/default/single.html b/src/MyWebLog.Old/views/themes/default/single.html deleted file mode 100644 index 894c3b7..0000000 --- a/src/MyWebLog.Old/views/themes/default/single.html +++ /dev/null @@ -1,9 +0,0 @@ -@Master['themes/default/layout'] - -@Section['Content'] - @Partial['themes/default/single-content', Model] -@EndSection - -@Section['Footer'] - @Partial['themes/default/footer', Model] -@EndSection diff --git a/src/MyWebLog.Tests/MyWebLog.Tests.fs b/src/MyWebLog.Tests/MyWebLog.Tests.fs deleted file mode 100644 index 9210d97..0000000 --- a/src/MyWebLog.Tests/MyWebLog.Tests.fs +++ /dev/null @@ -1,4 +0,0 @@ -namespace MyWebLog.Web - -type Web() = - member this.X = "F#" diff --git a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj b/src/MyWebLog.Tests/MyWebLog.Tests.fsproj deleted file mode 100644 index 74198ff..0000000 --- a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj +++ /dev/null @@ -1,70 +0,0 @@ - - - - - Debug - AnyCPU - 2.0 - 07e60874-6cf5-4d53-aee0-f17ef28228dd - Library - MyWebLog.Tests - MyWebLog.Tests - v4.5.2 - 4.4.0.0 - MyWebLog.Tests - - - true - full - false - false - bin\Debug\ - DEBUG;TRACE - 3 - bin\Debug\MyWebLog.Tests.xml - - - pdbonly - true - true - bin\Release\ - TRACE - 3 - bin\Release\MyWebLog.Tests.xml - - - - - True - - - - - - - - - - 11 - - - - - $(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\3.0\Framework\v4.0\Microsoft.FSharp.Targets - - - - - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets - - - - - - -- 2.45.1 From de92761b041c6987cc4314652fb8e6484cfc5be6 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 2 Jun 2022 12:12:45 -0400 Subject: [PATCH 076/102] Add devotional theme --- src/MyWebLog/appsettings.json | 2 +- src/MyWebLog/themes/awftw/index.liquid | 183 ++++++++++ src/MyWebLog/themes/awftw/layout.liquid | 47 +++ src/MyWebLog/themes/awftw/single-page.liquid | 6 + src/MyWebLog/themes/awftw/single-post.liquid | 147 ++++++++ .../wwwroot/themes/awftw/img/paper.png | Bin 0 -> 7225 bytes .../themes/awftw/img/podcast/awftw.jpg | Bin 0 -> 122342 bytes .../wwwroot/themes/awftw/img/ribbon.png | Bin 0 -> 5794 bytes src/MyWebLog/wwwroot/themes/awftw/script.js | 12 + src/MyWebLog/wwwroot/themes/awftw/style.css | 316 ++++++++++++++++++ 10 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 src/MyWebLog/themes/awftw/index.liquid create mode 100644 src/MyWebLog/themes/awftw/layout.liquid create mode 100644 src/MyWebLog/themes/awftw/single-page.liquid create mode 100644 src/MyWebLog/themes/awftw/single-post.liquid create mode 100644 src/MyWebLog/wwwroot/themes/awftw/img/paper.png create mode 100644 src/MyWebLog/wwwroot/themes/awftw/img/podcast/awftw.jpg create mode 100644 src/MyWebLog/wwwroot/themes/awftw/img/ribbon.png create mode 100644 src/MyWebLog/wwwroot/themes/awftw/script.js create mode 100644 src/MyWebLog/wwwroot/themes/awftw/style.css diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index e2577c7..a9922f3 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -3,7 +3,7 @@ "hostname": "data02.bitbadger.solutions", "database": "myWebLog_dev" }, - "Generator": "myWebLog 2.0-alpha27", + "Generator": "myWebLog 2.0-alpha28", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Debug" diff --git a/src/MyWebLog/themes/awftw/index.liquid b/src/MyWebLog/themes/awftw/index.liquid new file mode 100644 index 0000000..b239803 --- /dev/null +++ b/src/MyWebLog/themes/awftw/index.liquid @@ -0,0 +1,183 @@ +
    + {% if is_category or is_tag %} +

    {{ page_title }}

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

    + + {{ post.title }} + +

    +

    + {{ post.published_on | date: "dddd, MMMM d, yyyy" }}   + {% comment %} TODO: reading time? + #[i.fa.fa-clock-o(title='Clock' aria-hidden='true')] #[= readingTime(post.content, 'minutes', 175)] + {% endcomment %} +

    + {%- assign media = post.meta | value: "media" -%} + {%- unless media == "-- media not found --" %} + + {%- endunless %} + {{ post.text }} +
    + {% endfor %} + +
    + diff --git a/src/MyWebLog/themes/awftw/layout.liquid b/src/MyWebLog/themes/awftw/layout.liquid new file mode 100644 index 0000000..db5a672 --- /dev/null +++ b/src/MyWebLog/themes/awftw/layout.liquid @@ -0,0 +1,47 @@ + + + + + + + + {%- if is_home -%} + {{ web_log.name }}{% if web_log.subtitle %} | {{ web_log.subtitle.value }}{% endif %} + {%- else -%} + {{ page_title | strip_html }}{% if page_title and page_title != "" %} » {% endif %}{{ web_log.name }} + {%- endif -%} + + + + + + {% page_head -%} + + + + {% comment %}

    Loading...

    {% endcomment %} +
    {{ content }}
    +
    + + {% page_foot %} + + diff --git a/src/MyWebLog/themes/awftw/single-page.liquid b/src/MyWebLog/themes/awftw/single-page.liquid new file mode 100644 index 0000000..869038e --- /dev/null +++ b/src/MyWebLog/themes/awftw/single-page.liquid @@ -0,0 +1,6 @@ +
    +
    +

    {{ page.title }}

    + {{ page.text }} +
    +
    diff --git a/src/MyWebLog/themes/awftw/single-post.liquid b/src/MyWebLog/themes/awftw/single-post.liquid new file mode 100644 index 0000000..4fb41c2 --- /dev/null +++ b/src/MyWebLog/themes/awftw/single-post.liquid @@ -0,0 +1,147 @@ +{%- assign post = model.posts | first -%} +
    +
    +

    {{ post.title }}

    + {% assign media = post.meta | value: "media" %} + {%- unless media == "-- media not found --" %} + + {%- endunless %} + {{ post.text }} +
    + +
    + diff --git a/src/MyWebLog/wwwroot/themes/awftw/img/paper.png b/src/MyWebLog/wwwroot/themes/awftw/img/paper.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f7b8f00bc1186ca9242e54b2925ccd744d9a97 GIT binary patch literal 7225 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 Hu0mjfb7tLR literal 0 HcmV?d00001 diff --git a/src/MyWebLog/wwwroot/themes/awftw/img/podcast/awftw.jpg b/src/MyWebLog/wwwroot/themes/awftw/img/podcast/awftw.jpg new file mode 100644 index 0000000000000000000000000000000000000000..de7fb3f3e61dafdb3f887eb8713321360d2579ab GIT binary patch literal 122342 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%0A*_V^r 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 literal 0 HcmV?d00001 diff --git a/src/MyWebLog/wwwroot/themes/awftw/script.js b/src/MyWebLog/wwwroot/themes/awftw/script.js new file mode 100644 index 0000000..c7eb688 --- /dev/null +++ b/src/MyWebLog/wwwroot/themes/awftw/script.js @@ -0,0 +1,12 @@ +const awftw = { + counted: false, + countPlay: function (fileLink) { + if (!this.counted) { + const request = new XMLHttpRequest() + request.open('HEAD', 'https://pdcst.click/c/awftw/files.bitbadger.solutions/devotions/' + fileLink, true) + request.onload = function () { awftw.counted = true } + request.onerror = function () { } + request.send() + } + } +} diff --git a/src/MyWebLog/wwwroot/themes/awftw/style.css b/src/MyWebLog/wwwroot/themes/awftw/style.css new file mode 100644 index 0000000..168a3a5 --- /dev/null +++ b/src/MyWebLog/wwwroot/themes/awftw/style.css @@ -0,0 +1,316 @@ +@import url('https://fonts.googleapis.com/css?family=Quicksand|Federo|Istok+Web'); +:root { + --text-color: hsl(0, 0%, 10%); + --accent-color: green; + --link-color: green; + --superscript-color: #707070; + --bkg-color: lightgray; + --heading-bkg-color: darkgray; + --item-bkg-color: white; + --title-text-color: white; + --audio-bkg-color: hsla(0, 0%, 0%, .05); + --audio-text-color: hsla(0, 0%, 0%, .5); +} +@media (prefers-color-scheme: dark) { + :root { + --text-color: hsl(0, 0%, 80%); + --accent-color: hsl(120, 100%, 15%); + --link-color: hsl(120, 100%, 34%); + --superscript-color: hsl(0, 0%, 70%); + --bkg-color: hsl(0, 0%, 20%); + --heading-bkg-color: hsla(0, 0%, 100%, .2); + --item-bkg-color: hsla(0, 0%, 7%); + --audio-bkg-color: hsl(0, 0%, 10%); + --audio-text-color: hsl(0, 0%, 50%); + } + blockquote.bible { + background-color: var(--bkg-color); + background-image: linear-gradient(hsl(0, 0%, 85%), hsl(0, 0%, 85%)), url('../img/paper.png') repeat; + background-blend-mode: soft-light; + color: hsl(0, 0%, 95%); + } + .ref { + text-shadow: white 0 0 6px, white 0 0 6px, white 0 0 6px, white 0 0 6px; + } + .ref sup { + text-shadow: none; + } + .index-title { + text-shadow: white 0 0 6px, white 0 0 6px; + } +} +html { + background-color: var(--accent-color); +} +body { + font-family: "Istok Web",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; + font-size: 1.1rem; + margin: 0; + color: var(--text-color); + background-color: var(--bkg-color); +} +a { + text-decoration: none; +} +a:link, a:visited { + color: var(--link-color); +} +a:hover { + text-decoration: underline; +} +h1, h2, h3, h4, p, ul { + margin-top: 0; + margin-bottom: 1rem; +} +.site-header { + display: flex; + flex-flow: row wrap; + align-items: flex-end; + margin-bottom: 1rem; + background-image: -webkit-gradient(linear, left top, left bottom, from(var(--accent-color)), to(var(--bkg-color))); + background-image: -webkit-linear-gradient(top, var(--accent-color), var(--bkg-color)); + background-image: -moz-linear-gradient(top, var(--accent-color), var(--bkg-color)); + background-image: linear-gradient(to bottom, var(--accent-color), var(--bkg-color)); +} +.site-header a { + color: var(--title-text-color); +} +.site-header p { + padding-right: 2rem; +} +.site-title, +.index-title, +.item-heading, +.item-meta, +.post-meta, +.post-nav-title, +.page-footer a { + font-family: Federo,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} +.site-title { + font-size: 2rem; + font-weight: bold; + padding-left: 1rem; + padding-top: .3rem; +} +.site-title a { + color: var(--title-text-color); +} +.container { + display: flex; + flex-direction: row; + justify-content: space-around; + max-width: 1400px; + margin: auto; +} +@media all and (max-width:78rem) { + .container { + flex-direction: column; + align-items: center; + } +} +.index-title { + color: black; + text-align: center; +} +.content { + max-width: 60rem; + margin: 0; +} +.sidebar { + min-width: 10rem; + max-width: 18rem; + font-size: 1rem; + display: flex; + flex-direction: column; +} +@media all and (max-width:60rem) { + .content { + padding: 0 .4rem; + } +} +@media all and (max-width:78rem) { + .sidebar { + width: 100%; + max-width: 60rem; + padding: 0; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-around; + } + .sidebar .item { + max-width: 12rem; + } +} +.sidebar h4 { + font-size: 1.2rem; +} +.sidebar ul { + padding-left: 1rem; + margin-bottom: .5rem; +} +.sidebar ul li { + list-style-type: none; + margin-bottom: .5rem; +} +.sidebar ul li ul > li { + margin-top: .5rem; +} +.sidebar .item > ul { + padding-left: 0; +} +.post-sidebar { + margin-top: 4rem; +} +@media all and (max-width:78rem) { + .post-sidebar { + margin-top: 0; + } +} +hr.sidebar-sep { + margin: 0 -.4rem .5rem -.4rem; + height: 1px; + border: 0; + color: var(--accent-color); + background-color: var(--accent-color); +} +blockquote { + margin: 1rem 2rem 1rem 1rem; + border-left: solid 3px var(--accent-color); + padding-left: 1rem; +} +blockquote.bible { + padding: 11px; + margin-left: 2rem; + border: 0; + background: var(--bkg-color) url('img/paper.png') repeat; + font-family: Quicksand, serif; + border-top: solid 1px black; + display: flow-root; +} +blockquote.bible cite { + display: block; + padding-right: 11px; + text-align: right; + background: var(--item-bkg-color) url('img/ribbon.png') no-repeat bottom left; + color: var(--text-color); + height: 28px; + font-style: normal; + position: relative; + top: 5px; + margin: 0 -11px -11px -12px; +} +.ref { + color: red; +} +blockquote.bible sup { + color: var(--superscript-color); + padding-right: .35rem; +} +.lord, .sc { + font-variant: small-caps; +} +.u { + text-decoration: underline; +} +blockquote { + font-size: 1.2rem; +} +blockquote footer cite { + font-style: normal; +} +blockquote footer cite::before { + content: ", "; +} +cite { + font-size: 1rem; +} +nav { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + margin-bottom: 1rem; +} +.nav-next { + text-align: right; +} +.post-nav { + font-size: .8rem; + text-transform: uppercase; +} +footer.part-1 { + height: 2rem; + background-image: -webkit-gradient(linear, left top, left bottom, from(var(--bkg-color)), to(var(--accent-color))); + background-image: -webkit-linear-gradient(top, var(--bkg-color), var(--accent-color)); + background-image: -moz-linear-gradient(top, var(--bkg-color), var(--accent-color)); + background-image: linear-gradient(to bottom, var(--bkg-color), var(--accent-color)); +} +footer.page-footer { + padding: 0 .5rem .5rem 0; + text-align: right; + background-color: var(--accent-color); + color: var(--title-text-color); + font-size: 1rem; +} +footer.page-footer a:link, +footer.page-footer a:visited { + color: var(--title-text-color); +} +small.count { + padding-left: .35rem; +} +h1 { + font-size: 2rem; +} +.item { + border: solid 1px black; + background-color: var(--item-bkg-color); + padding: .4rem; + margin-bottom: 1.2rem; +} +.item-heading { + margin: -.4rem; + margin-bottom: .4rem; + border-bottom: solid 2px var(--link-color); + padding-bottom: .2rem; + text-align: center; + background-color: var(--heading-bkg-color); +} +.item-heading, +.item-heading a { + color: var(--title-text-color); +} +.item-meta { + margin: -.4rem; + margin-bottom: 1.2rem; + font-size: 1rem; + text-align: center; +} +.date-posted { + padding: 0 1rem; +} +.text-center { + text-align: center; +} +aside.podcast { + display: flex; + justify-content: space-around; + align-items: center; + background: var(--audio-bkg-color); + border: solid 1px var(--accent-color); + border-radius: .5rem; + color: var(--audio-text-color); + margin: 0 .5rem 1rem .5rem; +} +aside.podcast audio { + width: 100%; +} +aside.podcast p { + margin: 0 .5rem; + white-space: nowrap; +} +a.dl:link, +a.dl:visited { + color: var(--audio-text-color); +} -- 2.45.1 From 7a1d438d68c50b60e315d5da4d8c3a24ad81e57e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 2 Jun 2022 21:49:53 -0400 Subject: [PATCH 077/102] WIP on episode meta fields --- src/MyWebLog.Domain/ViewModels.fs | 4 +- src/MyWebLog/Handlers/Feed.fs | 18 +-- src/MyWebLog/Handlers/Post.fs | 7 +- src/MyWebLog/themes/admin/post-edit.liquid | 10 +- src/MyWebLog/themes/awftw/index.liquid | 4 +- src/MyWebLog/themes/awftw/single-post.liquid | 4 +- src/MyWebLog/wwwroot/themes/admin/admin.js | 125 ++++++++++++++++--- 7 files changed, 137 insertions(+), 35 deletions(-) diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index a495625..38d554c 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -610,7 +610,7 @@ type PostListItem = tags : string list /// Metadata for the post - meta : MetaItem list + metadata : MetaItem list } /// Create a post list item from a post @@ -627,7 +627,7 @@ type PostListItem = text = if extra = "" then post.text else post.text.Replace ("href=\"/", $"href=\"{extra}/") categoryIds = post.categoryIds |> List.map CategoryId.toString tags = post.tags - meta = post.metadata + metadata = post.metadata } diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 4e03d96..0313105 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -122,21 +122,21 @@ let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : Syndicat let meta name = post.metadata |> List.tryFind (fun it -> it.name = name) let value (item : MetaItem) = item.value let epMediaUrl = - match (meta >> Option.get >> value) "media" with + match (meta >> Option.get >> value) "episode_media_file" with | link when link.StartsWith "http" -> link | link -> WebLog.absoluteUrl webLog (Permalink link) let epMediaType = - match meta "mediaType", podcast.defaultMediaType with + match meta "episode_media_type", podcast.defaultMediaType with | Some epType, _ -> Some epType.value | None, Some defType -> Some defType | _ -> None let epImageUrl = - match defaultArg ((meta >> Option.map value) "image") (Permalink.toString podcast.imageUrl) with + match defaultArg ((meta >> Option.map value) "episode_image") (Permalink.toString podcast.imageUrl) with | link when link.StartsWith "http" -> link | link -> WebLog.absoluteUrl webLog (Permalink link) let epExplicit = try - (meta >> Option.map (value >> ExplicitRating.parse)) "explicit" + (meta >> Option.map (value >> ExplicitRating.parse)) "episode_explicit" |> Option.defaultValue podcast.explicit |> ExplicitRating.toString with :? ArgumentException -> ExplicitRating.toString podcast.explicit @@ -144,8 +144,8 @@ let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : Syndicat let xmlDoc = XmlDocument () let enclosure = xmlDoc.CreateElement "enclosure" enclosure.SetAttribute ("url", epMediaUrl) - meta "length" |> Option.iter (fun it -> enclosure.SetAttribute ("length", it.value)) - epMediaType |> Option.iter (fun typ -> enclosure.SetAttribute ("type", typ)) + meta "episode_media_length" |> Option.iter (fun it -> enclosure.SetAttribute ("length", it.value)) + epMediaType |> Option.iter (fun typ -> enclosure.SetAttribute ("type", typ)) item.ElementExtensions.Add enclosure item.ElementExtensions.Add ("creator", Namespace.dc, podcast.displayedAuthor) @@ -153,8 +153,10 @@ let private addEpisode webLog (feed : CustomFeed) (post : Post) (item : Syndicat item.ElementExtensions.Add ("summary", Namespace.iTunes, stripHtml post.text) item.ElementExtensions.Add ("image", Namespace.iTunes, epImageUrl) item.ElementExtensions.Add ("explicit", Namespace.iTunes, epExplicit) - meta "subtitle" |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it.value)) - meta "duration" |> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it.value)) + meta "episode_subtitle" + |> Option.iter (fun it -> item.ElementExtensions.Add ("subtitle", Namespace.iTunes, it.value)) + meta "episode_duration" + |> Option.iter (fun it -> item.ElementExtensions.Add ("duration", Namespace.iTunes, it.value)) if post.metadata |> List.exists (fun it -> it.name = "chapter") then try diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index faa7aff..99e19c6 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -231,11 +231,14 @@ let edit postId : HttpHandler = fun next ctx -> task { } match result with | Some (title, post) -> - let! cats = Data.Category.findAllForView webLog.id conn + let! cats = Data.Category.findAllForView webLog.id conn + let model = EditPostModel.fromPost webLog post return! Hash.FromAnonymousObject {| csrf = csrfToken ctx - model = EditPostModel.fromPost webLog post + model = model + metadata = Array.zip model.metaNames model.metaValues + |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) page_title = title categories = cats |} diff --git a/src/MyWebLog/themes/admin/post-edit.liquid b/src/MyWebLog/themes/admin/post-edit.liquid index 38a6787..ce61b78 100644 --- a/src/MyWebLog/themes/admin/post-edit.liquid +++ b/src/MyWebLog/themes/admin/post-edit.liquid @@ -55,8 +55,14 @@
    + {%- assign has_media = model.metaNames | where: "episode_media_file" | size -%} + {%- unless has_media == 0 %} + + {%- endunless %}
    - {%- for meta in model.metadata %} + {%- for meta in metadata %}
    diff --git a/src/MyWebLog/themes/awftw/index.liquid b/src/MyWebLog/themes/awftw/index.liquid index b239803..21e3118 100644 --- a/src/MyWebLog/themes/awftw/index.liquid +++ b/src/MyWebLog/themes/awftw/index.liquid @@ -33,8 +33,8 @@ #[i.fa.fa-clock-o(title='Clock' aria-hidden='true')] #[= readingTime(post.content, 'minutes', 175)] {% endcomment %}

    - {%- assign media = post.meta | value: "media" -%} - {%- unless media == "-- media not found --" %} + {%- assign media = post.metadata | value: "episode_media_file" -%} + {%- unless media == "-- episode_media_file not found --" %}