77
src/MyWebLog/Views/Admin.fs
Normal file
77
src/MyWebLog/Views/Admin.fs
Normal file
@@ -0,0 +1,77 @@
|
||||
module MyWebLog.Views.Admin
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// The main dashboard
|
||||
let dashboard (model: DashboardModel) app = [
|
||||
h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " • Dashboard" ]
|
||||
article [ _class "container" ] [
|
||||
div [ _class "row" ] [
|
||||
section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-primary" ] [ raw "Posts" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3" ] [
|
||||
raw "Published "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Posts) ]
|
||||
raw " Drafts "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Drafts) ]
|
||||
]
|
||||
if app.IsAuthor then
|
||||
a [ _href (relUrl app "admin/posts"); _class "btn btn-secondary me-2" ] [ raw "View All" ]
|
||||
a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary" ] [
|
||||
raw "Write a New Post"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
section [ _class "col-lg-5 col-xl-4 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-primary" ] [ raw "Pages" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3" ] [
|
||||
raw "All "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Pages) ]
|
||||
raw " Shown in Page List "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.ListedPages) ]
|
||||
]
|
||||
if app.IsAuthor then
|
||||
a [ _href (relUrl app "admin/pages"); _class "btn btn-secondary me-2" ] [ raw "View All" ]
|
||||
a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary" ] [
|
||||
raw "Create a New Page"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-secondary" ] [ raw "Categories" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3"] [
|
||||
raw "All "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Categories) ]
|
||||
raw " Top Level "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.TopLevelCategories) ]
|
||||
]
|
||||
if app.IsWebLogAdmin then
|
||||
a [ _href (relUrl app "admin/categories"); _class "btn btn-secondary me-2" ] [
|
||||
raw "View All"
|
||||
]
|
||||
a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-secondary" ] [
|
||||
raw "Add a New Category"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
if app.IsWebLogAdmin then
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-end" ] [
|
||||
a [ _href (relUrl app "admin/settings"); _class "btn btn-secondary" ] [ raw "Modify Settings" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
222
src/MyWebLog/Views/Helpers.fs
Normal file
222
src/MyWebLog/Views/Helpers.fs
Normal file
@@ -0,0 +1,222 @@
|
||||
[<AutoOpen>]
|
||||
module MyWebLog.Views.Helpers
|
||||
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
/// The rendering context for this application
|
||||
[<NoComparison; NoEquality>]
|
||||
type AppViewContext = {
|
||||
/// The web log for this request
|
||||
WebLog: WebLog
|
||||
|
||||
/// The ID of the current user
|
||||
UserId: WebLogUserId option
|
||||
|
||||
/// The title of the page being rendered
|
||||
PageTitle: string
|
||||
|
||||
/// The anti-Cross Site Request Forgery (CSRF) token set to use when rendering a form
|
||||
Csrf: AntiforgeryTokenSet option
|
||||
|
||||
/// The page list for the web log
|
||||
PageList: DisplayPage array
|
||||
|
||||
/// Categories and post counts for the web log
|
||||
Categories: DisplayCategory array
|
||||
|
||||
/// The URL of the page being rendered
|
||||
CurrentPage: string
|
||||
|
||||
/// User messages
|
||||
Messages: UserMessage array
|
||||
|
||||
/// The generator string for the rendered page
|
||||
Generator: string
|
||||
|
||||
/// A string to load the minified htmx script
|
||||
HtmxScript: string
|
||||
|
||||
/// Whether the current user is an author
|
||||
IsAuthor: bool
|
||||
|
||||
/// Whether the current user is an editor (implies author)
|
||||
IsEditor: bool
|
||||
|
||||
/// Whether the current user is a web log administrator (implies author and editor)
|
||||
IsWebLogAdmin: bool
|
||||
|
||||
/// Whether the current user is an installation administrator (implies all web log rights)
|
||||
IsAdministrator: bool
|
||||
} with
|
||||
|
||||
/// Whether there is a user logged on
|
||||
member this.IsLoggedOn = Option.isSome this.UserId
|
||||
|
||||
|
||||
/// Create a relative URL for the current web log
|
||||
let relUrl app =
|
||||
Permalink >> app.WebLog.RelativeUrl
|
||||
|
||||
/// Add a hidden input with the anti-Cross Site Request Forgery (CSRF) token
|
||||
let antiCsrf app =
|
||||
input [ _type "hidden"; _name app.Csrf.Value.FormFieldName; _value app.Csrf.Value.RequestToken ]
|
||||
|
||||
/// Shorthand for encoded text in a template
|
||||
let txt = encodedText
|
||||
|
||||
/// Shorthand for raw text in a template
|
||||
let raw = rawText
|
||||
|
||||
/// The pattern for a long date
|
||||
let longDatePattern =
|
||||
InstantPattern.CreateWithInvariantCulture "MMMM d, yyyy"
|
||||
|
||||
/// Create a long date
|
||||
let longDate =
|
||||
longDatePattern.Format >> txt
|
||||
|
||||
/// The pattern for a short time
|
||||
let shortTimePattern =
|
||||
InstantPattern.CreateWithInvariantCulture "h:mmtt"
|
||||
|
||||
/// Create a short time
|
||||
let shortTime instant =
|
||||
txt (shortTimePattern.Format(instant).ToLower())
|
||||
|
||||
/// Functions for generating content in varying layouts
|
||||
module Layout =
|
||||
|
||||
/// Generate the title tag for a page
|
||||
let private titleTag (app: AppViewContext) =
|
||||
title [] [ txt app.PageTitle; raw " « Admin « "; txt app.WebLog.Name ]
|
||||
|
||||
/// Create a navigation link
|
||||
let private navLink app name url =
|
||||
let extraPath = app.WebLog.ExtraPath
|
||||
let path = if extraPath = "" then "" else $"{extraPath[1..]}/"
|
||||
let active = if app.CurrentPage.StartsWith $"{path}{url}" then " active" else ""
|
||||
li [ _class "nav-item" ] [
|
||||
a [ _class $"nav-link{active}"; _href (relUrl app url) ] [ txt name ]
|
||||
]
|
||||
|
||||
/// Create a page view for the given content
|
||||
let private pageView (content: AppViewContext -> XmlNode list) app = [
|
||||
header [] [
|
||||
nav [ _class "navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2 position-fixed top-0 w-100" ] [
|
||||
div [ _class "container-fluid" ] [
|
||||
a [ _class "navbar-brand"; _href (relUrl app ""); _hxNoBoost ] [ txt app.WebLog.Name ]
|
||||
button [ _type "button"; _class "navbar-toggler"; _data "bs-toggle" "collapse"
|
||||
_data "bs-target" "#navbarText"; _ariaControls "navbarText"; _ariaExpanded "false"
|
||||
_ariaLabel "Toggle navigation" ] [
|
||||
span [ _class "navbar-toggler-icon" ] []
|
||||
]
|
||||
div [ _class "collapse navbar-collapse"; _id "navbarText" ] [
|
||||
if app.IsLoggedOn then
|
||||
ul [ _class "navbar-nav" ] [
|
||||
navLink app "Dashboard" "admin/dashboard"
|
||||
if app.IsAuthor then
|
||||
navLink app "Pages" "admin/pages"
|
||||
navLink app "Posts" "admin/posts"
|
||||
navLink app "Uploads" "admin/uploads"
|
||||
if app.IsWebLogAdmin then
|
||||
navLink app "Categories" "admin/categories"
|
||||
navLink app "Settings" "admin/settings"
|
||||
if app.IsAdministrator then navLink app "Admin" "admin/administration"
|
||||
]
|
||||
ul [ _class "navbar-nav flex-grow-1 justify-content-end" ] [
|
||||
if app.IsLoggedOn then navLink app "My Info" "admin/my-info"
|
||||
li [ _class "nav-item" ] [
|
||||
a [ _class "nav-link"
|
||||
_href "https://bitbadger.solutions/open-source/myweblog/#how-to-use-myweblog"
|
||||
_target "_blank" ] [
|
||||
raw "Docs"
|
||||
]
|
||||
]
|
||||
if app.IsLoggedOn then
|
||||
li [ _class "nav-item" ] [
|
||||
a [ _class "nav-link"; _href (relUrl app "user/log-off"); _hxNoBoost ] [
|
||||
raw "Log Off"
|
||||
]
|
||||
]
|
||||
else
|
||||
navLink app "Log On" "user/log-on"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _id "toastHost"; _class "position-fixed top-0 w-100"; _ariaLive "polite"; _ariaAtomic "true" ] [
|
||||
div [ _id "toasts"; _class "toast-container position-absolute p-3 mt-5 top-0 end-0" ] [
|
||||
for msg in app.Messages do
|
||||
let textColor = if msg.Level = "warning" then "" else " text-white"
|
||||
div [ _class "toast"; _roleAlert; _ariaLive "assertive"; _ariaAtomic "true"
|
||||
if msg.Level <> "success" then _data "bs-autohide" "false" ] [
|
||||
div [ _class $"toast-header bg-{msg.Level}{textColor}" ] [
|
||||
strong [ _class "me-auto text-uppercase" ] [
|
||||
raw (if msg.Level = "danger" then "error" else msg.Level)
|
||||
]
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "toast"
|
||||
_ariaLabel "Close" ] []
|
||||
]
|
||||
div [ _class $"toast-body bg-{msg.Level} bg-opacity-25" ] [
|
||||
txt msg.Message
|
||||
if Option.isSome msg.Detail then
|
||||
hr []
|
||||
txt msg.Detail.Value
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
main [ _class "mx-3 mt-3" ] [
|
||||
div [ _class "load-overlay p-5"; _id "loadOverlay" ] [ h1 [ _class "p-3" ] [ raw "Loading…" ] ]
|
||||
yield! content app
|
||||
]
|
||||
footer [ _class "position-fixed bottom-0 w-100" ] [
|
||||
div [ _class "text-end text-white me-2" ] [
|
||||
let version = app.Generator.Split ' '
|
||||
small [ _class "me-1 align-baseline"] [ raw $"v{version[1]}" ]
|
||||
img [ _src (relUrl app "themes/admin/logo-light.png"); _alt "myWebLog"; _width "120"; _height "34" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a page with a partial layout (htmx request)
|
||||
let partial content app =
|
||||
html [ _lang "en" ] [
|
||||
titleTag app
|
||||
yield! pageView content app
|
||||
]
|
||||
|
||||
/// Render a page with a full layout
|
||||
let full content app =
|
||||
html [ _lang "en" ] [
|
||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||
meta [ _name "generator"; _content app.Generator ]
|
||||
titleTag app
|
||||
link [ _rel "stylesheet"; _href "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
_integrity "sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
_crossorigin "anonymous" ]
|
||||
link [ _rel "stylesheet"; _href (relUrl app "themes/admin/admin.css") ]
|
||||
body [ _hxBoost; _hxIndicator "#loadOverlay" ] [
|
||||
yield! pageView content app
|
||||
script [ _src "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||
_crossorigin "anonymous" ] []
|
||||
Script.minified
|
||||
script [ _src (relUrl app "themes/admin/admin.js") ] []
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a bare layout
|
||||
let bare (content: AppViewContext -> XmlNode list) app =
|
||||
html [ _lang "en" ] [
|
||||
title [] []
|
||||
yield! content app
|
||||
]
|
||||
204
src/MyWebLog/Views/Post.fs
Normal file
204
src/MyWebLog/Views/Post.fs
Normal file
@@ -0,0 +1,204 @@
|
||||
module MyWebLog.Views.Post
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
open NodaTime.Text
|
||||
|
||||
/// The pattern for chapter start times
|
||||
let startTimePattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF"
|
||||
|
||||
/// The form to add or edit a chapter
|
||||
let chapterEdit (model: EditChapterModel) app = [
|
||||
let postUrl = relUrl app $"admin/post/{model.PostId}/chapter/{model.Index}"
|
||||
h3 [ _class "my-3" ] [ raw (if model.Index < 0 then "Add" else "Edit"); raw " Chapter" ]
|
||||
p [ _class "form-text" ] [
|
||||
raw "Times may be entered as seconds; minutes and seconds; or hours, minutes and seconds. Fractional seconds "
|
||||
raw "are supported to two decimal places."
|
||||
]
|
||||
form [ _method "post"; _action postUrl; _hxPost postUrl; _hxTarget "#chapter_list"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "PostId"; _value model.PostId ]
|
||||
input [ _type "hidden"; _name "Index"; _value (string model.Index) ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-6 col-lg-3 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "start_time"; _name "StartTime"; _class "form-control"; _required
|
||||
_autofocus; _placeholder "Start Time"
|
||||
if model.Index >= 0 then _value model.StartTime ]
|
||||
label [ _for "start_time" ] [ raw "Start Time" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-6 col-lg-3 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "end_time"; _name "EndTime"; _class "form-control"; _value model.EndTime
|
||||
_placeholder "End Time" ]
|
||||
label [ _for "end_time" ] [ raw "End Time" ]
|
||||
span [ _class "form-text" ] [ raw "Optional; ends when next starts" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "title"; _name "Title"; _class "form-control"; _value model.Title
|
||||
_placeholder "Title" ]
|
||||
label [ _for "title" ] [ raw "Chapter Title" ]
|
||||
span [ _class "form-text" ] [ raw "Optional" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 offset-xl-1 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "image_url"; _name "ImageUrl"; _class "form-control"
|
||||
_value model.ImageUrl; _placeholder "Image URL" ]
|
||||
label [ _for "image_url" ] [ raw "Image URL" ]
|
||||
span [ _class "form-text" ] [
|
||||
raw "Optional; a separate image to display while this chapter is playing"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 col-xl-4 mb-3 align-self-end d-flex flex-column" ] [
|
||||
div [ _class "form-check form-switch mb-3" ] [
|
||||
input [ _type "checkbox"; _id "is_hidden"; _name "IsHidden"; _class "form-check-input"
|
||||
_value "true"
|
||||
if model.IsHidden then _checked ]
|
||||
label [ _for "is_hidden" ] [ raw "Hidden Chapter" ]
|
||||
]
|
||||
span [ _class "form-text" ] [ raw "Not displayed, but may update image and location" ]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
let hasLoc = model.LocationName <> ""
|
||||
div [ _class "col-12 col-md-4 col-lg-3 offset-lg-1 mb-3 align-self-end" ] [
|
||||
div [ _class "form-check form-switch mb-3" ] [
|
||||
input [ _type "checkbox"; _id "has_location"; _class "form-check-input"; _value "true"
|
||||
if hasLoc then _checked
|
||||
_onclick "Admin.checkChapterLocation()" ]
|
||||
label [ _for "has_location" ] [ raw "Associate Location" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-8 col-lg-6 offset-lg-1 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "location_name"; _name "LocationName"; _class "form-control"
|
||||
_value model.LocationName; _placeholder "Location Name"; _required
|
||||
if not hasLoc then _disabled ]
|
||||
label [ _for "location_name" ] [ raw "Name" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-6 col-lg-4 offset-lg-2 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "location_geo"; _name "LocationGeo"; _class "form-control"
|
||||
_value model.LocationGeo; _placeholder "Location Geo URL"
|
||||
if not hasLoc then _disabled ]
|
||||
label [ _for "location_geo" ] [ raw "Geo URL" ]
|
||||
em [ _class "form-text" ] [
|
||||
raw "Optional; "
|
||||
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended"
|
||||
_target "_blank"; _rel "noopener" ] [
|
||||
raw "see spec"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-6 col-lg-4 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id "location_osm"; _name "LocationOsm"; _class "form-control"
|
||||
_value model.LocationOsm; _placeholder "Location OSM Query"
|
||||
if not hasLoc then _disabled ]
|
||||
label [ _for "location_osm" ] [ raw "OpenStreetMap ID" ]
|
||||
em [ _class "form-text" ] [
|
||||
raw "Optional; "
|
||||
a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _rel "noopener" ] [ raw "get ID" ]
|
||||
raw ", "
|
||||
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#osm-recommended"
|
||||
_target "_blank"; _rel "noopener" ] [
|
||||
raw "see spec"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col" ] [
|
||||
let cancelLink = relUrl app $"admin/post/{model.PostId}/chapters"
|
||||
if model.Index < 0 then
|
||||
div [ _class "form-check form-switch mb-3" ] [
|
||||
input [ _type "checkbox"; _id "add_another"; _name "AddAnother"; _class "form-check-input"
|
||||
_value "true"; _checked ]
|
||||
label [ _for "add_another" ] [ raw "Add Another New Chapter" ]
|
||||
]
|
||||
else
|
||||
input [ _type "hidden"; _name "AddAnother"; _value "false" ]
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save" ]
|
||||
raw " "
|
||||
a [ _href cancelLink; _hxGet cancelLink; _class "btn btn-secondary"; _hxTarget "body" ] [ raw "Cancel" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Display a list of chapters
|
||||
let chapterList withNew (model: ManageChaptersModel) app =
|
||||
form [ _method "post"; _id "chapter_list"; _class "container mb-3"; _hxTarget "this"; _hxSwap "outerHTML" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col" ] [ raw "Start" ]
|
||||
div [ _class "col" ] [ raw "Title" ]
|
||||
div [ _class "col" ] [ raw "Image?" ]
|
||||
div [ _class "col" ] [ raw "Location?" ]
|
||||
]
|
||||
yield! model.Chapters |> List.mapi (fun idx chapter ->
|
||||
div [ _class "row mwl-table-detail"; _id $"chapter{idx}" ] [
|
||||
div [ _class "col" ] [ txt (startTimePattern.Format chapter.StartTime) ]
|
||||
div [ _class "col" ] [
|
||||
txt (defaultArg chapter.Title ""); br []
|
||||
small [] [
|
||||
if withNew then
|
||||
raw " "
|
||||
else
|
||||
let chapterUrl = relUrl app $"admin/post/{model.Id}/chapter/{idx}"
|
||||
a [ _href chapterUrl; _hxGet chapterUrl; _hxTarget $"#chapter{idx}"
|
||||
_hxSwap $"innerHTML show:#chapter{idx}:top" ] [
|
||||
raw "Edit"
|
||||
]
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href chapterUrl; _hxDelete chapterUrl; _class "text-danger" ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col" ] [ raw (if Option.isSome chapter.ImageUrl then "Y" else "N") ]
|
||||
div [ _class "col" ] [ raw (if Option.isSome chapter.Location then "Y" else "N") ]
|
||||
])
|
||||
div [ _class "row pb-3"; _id "chapter-1" ] [
|
||||
let newLink = relUrl app $"admin/post/{model.Id}/chapter/-1"
|
||||
if withNew then
|
||||
span [ _hxGet newLink; _hxTarget "#chapter-1"; _hxTrigger "load"; _hxSwap "show:#chapter-1:top" ] []
|
||||
else
|
||||
div [ _class "row pb-3 mwl-table-detail" ] [
|
||||
div [ _class "col-12" ] [
|
||||
a [ _class "btn btn-primary"; _href newLink; _hxGet newLink; _hxTarget "#chapter-1"
|
||||
_hxSwap "show:#chapter-1:top" ] [
|
||||
raw "Add a New Chapter"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
/// Manage Chapters page
|
||||
let chapters withNew (model: ManageChaptersModel) app = [
|
||||
h2 [ _class "my-3" ] [ txt app.PageTitle ]
|
||||
article [] [
|
||||
p [ _style "line-height:1.2rem;" ] [
|
||||
strong [] [ txt model.Title ]; br []
|
||||
small [ _class "text-muted" ] [
|
||||
a [ _href (relUrl app $"admin/post/{model.Id}/edit") ] [
|
||||
raw "« Back to Edit Post"
|
||||
]
|
||||
]
|
||||
]
|
||||
yield! chapterList withNew model app
|
||||
]
|
||||
]
|
||||
91
src/MyWebLog/Views/User.fs
Normal file
91
src/MyWebLog/Views/User.fs
Normal file
@@ -0,0 +1,91 @@
|
||||
module MyWebLog.Views.User
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Page to display the log on page
|
||||
let logOn (model: LogOnModel) (app: AppViewContext) = [
|
||||
h2 [ _class "my-3" ] [ rawText "Log On to "; encodedText app.WebLog.Name ]
|
||||
article [ _class "py-3" ] [
|
||||
form [ _action (relUrl app "user/log-on"); _method "post"; _class "container"; _hxPushUrl "true" ] [
|
||||
antiCsrf app
|
||||
if Option.isSome model.ReturnTo then input [ _type "hidden"; _name "ReturnTo"; _value model.ReturnTo.Value ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-6 col-lg-4 offset-lg-2 pb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "email"; _id "email"; _name "EmailAddress"; _class "form-control"; _autofocus
|
||||
_required ]
|
||||
label [ _for "email" ] [ rawText "E-mail Address" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "password"; _id "password"; _name "Password"; _class "form-control"; _required ]
|
||||
label [ _for "password" ] [ rawText "Password" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ rawText "Log On" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The list of users for a web log (part of web log settings page)
|
||||
let userList (model: WebLogUser list) app =
|
||||
let badge = "ms-2 badge bg"
|
||||
div [ _id "userList" ] [
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row mwl-table-detail"; _id "user_new" ] []
|
||||
]
|
||||
form [ _method "post"; _class "container g-0"; _hxTarget "this"; _hxSwap "outerHTML show:window:top" ] [
|
||||
antiCsrf app
|
||||
for user in model do
|
||||
div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [
|
||||
div [ _class $"col-12 col-md-4 col-xl-3 no-wrap" ] [
|
||||
txt user.PreferredName; raw " "
|
||||
match user.AccessLevel with
|
||||
| Administrator -> span [ _class $"{badge}-success" ] [ raw "ADMINISTRATOR" ]
|
||||
| WebLogAdmin -> span [ _class $"{badge}-primary" ] [ raw "WEB LOG ADMIN" ]
|
||||
| Editor -> span [ _class $"{badge}-secondary" ] [ raw "EDITOR" ]
|
||||
| Author -> span [ _class $"{badge}-dark" ] [ raw "AUTHOR" ]
|
||||
br []
|
||||
if app.IsAdministrator || (app.IsWebLogAdmin && not (user.AccessLevel = Administrator)) then
|
||||
let urlBase = $"admin/settings/user/{user.Id}"
|
||||
small [] [
|
||||
a [ _href (relUrl app $"{urlBase}/edit"); _hxTarget $"#user_{user.Id}"
|
||||
_hxSwap $"innerHTML show:#user_{user.Id}:top" ] [
|
||||
raw "Edit"
|
||||
]
|
||||
if app.UserId.Value <> user.Id then
|
||||
let delLink = relUrl app $"{urlBase}/delete"
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href delLink; _hxPost delLink; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the user “{user.PreferredName}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)" ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-4" ] [
|
||||
txt $"{user.FirstName} {user.LastName}"; br []
|
||||
small [ _class "text-muted" ] [
|
||||
txt user.Email
|
||||
if Option.isSome user.Url then
|
||||
br []; txt user.Url.Value
|
||||
]
|
||||
]
|
||||
div [ _class "d-none d-xl-block col-xl-2" ] [ longDate user.CreatedOn ]
|
||||
div [ _class "col-12 col-md-4 col-xl-3" ] [
|
||||
match user.LastSeenOn with
|
||||
| Some it -> longDate it; raw " at "; shortTime it
|
||||
| None -> raw "--"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
Reference in New Issue
Block a user