User logon and list fixes

User logon now works; tweaked queries and display items on post, page,
and category list pages
This commit is contained in:
Daniel J. Summers 2016-07-23 13:44:49 -05:00
parent 197a19d339
commit 7c99da8cb5
22 changed files with 282 additions and 137 deletions

View File

@ -32,18 +32,11 @@ let getAllCategories conn (webLogId : string) =
r.Table(Table.Category) r.Table(Table.Category)
.GetAll(webLogId).OptArg("index", "webLogId") .GetAll(webLogId).OptArg("index", "webLogId")
.OrderBy("name") .OrderBy("name")
.RunCursorAsync<Category>(conn) .RunListAsync<Category>(conn)
|> await |> await
|> Seq.toList |> Seq.toList
|> sortCategories |> sortCategories
/// Count categories for a web log
let countCategories conn (webLogId : string) =
r.Table(Table.Category)
.GetAll(webLogId).OptArg("index", "webLogId")
.Count()
.RunAtomAsync<int>(conn) |> await
/// Get a specific category by its Id /// Get a specific category by its Id
let tryFindCategory conn webLogId catId : Category option = let tryFindCategory conn webLogId catId : Category option =
match (category webLogId catId) match (category webLogId catId)

View File

@ -37,20 +37,13 @@ let tryFindPageByPermalink conn (webLogId : string) (permalink : string) =
|> await |> await
|> Seq.tryHead |> Seq.tryHead
/// Count pages for a web log
let countPages conn (webLogId : string) =
r.Table(Table.Page)
.GetAll(webLogId).OptArg("index", "webLogId")
.Count()
.RunAtomAsync<int>(conn) |> await
/// Get a list of all pages (excludes page text and revisions) /// Get a list of all pages (excludes page text and revisions)
let findAllPages conn (webLogId : string) = let findAllPages conn (webLogId : string) =
r.Table(Table.Page) r.Table(Table.Page)
.GetAll(webLogId) .GetAll(webLogId).OptArg("index", "webLogId")
.OrderBy("title") .OrderBy("title")
.Without("text", "revisions") .Without("text", "revisions")
.RunCursorAsync<Page>(conn) .RunListAsync<Page>(conn)
|> await |> await
|> Seq.toList |> Seq.toList

View File

@ -25,7 +25,7 @@ let private toPostList conn pageNbr nbrPerPage (filter : ReqlExpr) =
/// Shorthand to get a newer or older post /// Shorthand to get a newer or older post
// TODO: older posts need to sort by published on DESC // TODO: older posts need to sort by published on DESC
//let private adjacentPost conn post (theFilter : ReqlExpr -> ReqlExpr) (sort :ReqlExpr) : Post option = //let private adjacentPost conn post (theFilter : ReqlExpr -> ReqlExpr) (sort :ReqlExpr) : Post option =
let private adjacentPost conn post (theFilter : obj) (sort : obj) : Post option = let private adjacentPost conn post (theFilter : ReqlExpr -> obj) (sort : obj) : Post option =
(publishedPosts post.webLogId) (publishedPosts post.webLogId)
.Filter(theFilter) .Filter(theFilter)
.OrderBy(sort) .OrderBy(sort)
@ -58,36 +58,37 @@ let findPageOfTaggedPosts conn webLogId (tag : string) pageNbr nbrPerPage =
|> toPostList conn pageNbr nbrPerPage |> toPostList conn pageNbr nbrPerPage
/// Try to get the next newest post from the given post /// Try to get the next newest post from the given post
let tryFindNewerPost conn post = newerPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Gt(post.publishedOn)) 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 /// Try to get the next newest post assigned to the given category
let tryFindNewerCategorizedPost conn (categoryId : string) post = let tryFindNewerCategorizedPost conn (categoryId : string) post =
newerPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Gt(post.publishedOn) newerPost conn post (fun p -> upcast p.["publishedOn"].Gt(post.publishedOn)
.And(p.["categoryIds"].Contains(categoryId))) .And(p.["categoryIds"].Contains(categoryId)))
/// Try to get the next newest tagged post from the given tagged post /// Try to get the next newest tagged post from the given tagged post
let tryFindNewerTaggedPost conn (tag : string) post = let tryFindNewerTaggedPost conn (tag : string) post =
newerPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Gt(post.publishedOn).And(p.["tags"].Contains(tag))) 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 /// Try to get the next oldest post from the given post
let tryFindOlderPost conn post = olderPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Lt(post.publishedOn)) 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 /// Try to get the next oldest post assigned to the given category
let tryFindOlderCategorizedPost conn (categoryId : string) post = let tryFindOlderCategorizedPost conn (categoryId : string) post =
olderPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Lt(post.publishedOn) olderPost conn post (fun p -> upcast p.["publishedOn"].Lt(post.publishedOn)
.And(p.["categoryIds"].Contains(categoryId))) .And(p.["categoryIds"].Contains(categoryId)))
/// Try to get the next oldest tagged post from the given tagged post /// Try to get the next oldest tagged post from the given tagged post
let tryFindOlderTaggedPost conn (tag : string) post = let tryFindOlderTaggedPost conn (tag : string) post =
olderPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Lt(post.publishedOn).And(p.["tags"].Contains(tag))) 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 /// Get a page of all posts in all statuses
let findPageOfAllPosts conn (webLogId : string) pageNbr nbrPerPage = let findPageOfAllPosts conn (webLogId : string) pageNbr nbrPerPage =
// FIXME: sort unpublished posts by their last updated date
r.Table(Table.Post) r.Table(Table.Post)
.GetAll(webLogId).OptArg("index", "webLogId") .GetAll(webLogId).OptArg("index", "webLogId")
.OrderBy(fun p -> r.Desc(r.Branch(p.["publishedOn"].Eq(int64 0), p.["lastUpdatedOn"], p.["publishedOn"]))) .OrderBy(r.Desc("publishedOn"))
.Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage) .Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage)
.RunCursorAsync<Post>(conn) .RunListAsync<Post>(conn)
|> await |> await
|> Seq.toList |> Seq.toList
@ -107,15 +108,15 @@ let tryFindPostByPermalink conn webLogId permalink =
.GetAll(r.Array(webLogId, permalink)).OptArg("index", "permalink") .GetAll(r.Array(webLogId, permalink)).OptArg("index", "permalink")
.Filter(fun p -> p.["status"].Eq(PostStatus.Published)) .Filter(fun p -> p.["status"].Eq(PostStatus.Published))
.Without("revisions") .Without("revisions")
.Merge(fun post -> ExpandoObject()?categories <- .Merge(fun post -> r.HashMap("categories",
post.["categoryIds"] post.["categoryIds"]
.Map(ReqlFunction1(fun cat -> upcast r.Table(Table.Category).Get(cat).Without("children"))) .Map(ReqlFunction1(fun cat -> upcast r.Table(Table.Category).Get(cat).Without("children")))
.CoerceTo("array")) .CoerceTo("array")))
.Merge(fun post -> ExpandoObject()?comments <- .Merge(fun post -> r.HashMap("comments",
r.Table(Table.Comment) r.Table(Table.Comment)
.GetAll(post.["id"]).OptArg("index", "postId") .GetAll(post.["id"]).OptArg("index", "postId")
.OrderBy("postedOn") .OrderBy("postedOn")
.CoerceTo("array")) .CoerceTo("array")))
.RunCursorAsync<Post>(conn) .RunCursorAsync<Post>(conn)
|> await |> await
|> Seq.tryHead |> Seq.tryHead
@ -145,11 +146,3 @@ let savePost conn post =
.RunResultAsync(conn) .RunResultAsync(conn)
|> ignore |> ignore
post.id post.id
/// Count posts for a web log
let countPosts conn (webLogId : string) =
r.Table(Table.Post)
.GetAll(webLogId).OptArg("index", "webLogId")
.Count()
.RunAtomAsync<int>(conn)
|> await

View File

@ -80,7 +80,7 @@ let checkIndexes cfg =
"webLogAndStatus", webLogField "status" "webLogAndStatus", webLogField "status"
"permalink", webLogField "permalink" "permalink", webLogField "permalink"
] ]
Table.User, [ "logOn", Some <| fun row -> upcast r.Array(row.["userName"], row.["passwordHash"]) Table.User, [ "userName", None
] ]
Table.WebLog, [ "urlBase", None Table.WebLog, [ "urlBase", None
] ]

View File

@ -6,10 +6,13 @@ open Rethink
let private r = RethinkDb.Driver.RethinkDB.R let private r = RethinkDb.Driver.RethinkDB.R
/// Log on a user /// Log on a user
// FIXME: the password hash may be longer than the significant size of a RethinkDB index // 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) = let tryUserLogOn conn (email : string) (passwordHash : string) =
r.Table(Table.User) r.Table(Table.User)
.GetAll(email, passwordHash).OptArg("index", "logOn") .GetAll(email).OptArg("index", "userName")
.Filter(fun u -> u.["passwordHash"].Eq(passwordHash))
.RunCursorAsync<User>(conn) .RunCursorAsync<User>(conn)
|> await |> await
|> Seq.tryHead |> Seq.tryHead

View File

@ -1,12 +1,20 @@
module myWebLog.Data.WebLog module myWebLog.Data.WebLog
open FSharp.Interop.Dynamic
open myWebLog.Entities open myWebLog.Entities
open Rethink open Rethink
open System.Dynamic
let private r = RethinkDb.Driver.RethinkDB.R let private r = RethinkDb.Driver.RethinkDB.R
/// 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
}
/// Detemine the web log by the URL base /// Detemine the web log by the URL base
// TODO: see if we can make .Merge work for page list even though the attribute is ignored // TODO: see if we can make .Merge work for page list even though the attribute is ignored
// (needs to be ignored for serialization, but included for deserialization) // (needs to be ignored for serialization, but included for deserialization)
@ -24,3 +32,12 @@ let tryFindWebLogByUrlBase conn (urlBase : string) =
.Pluck("title", "permalink") .Pluck("title", "permalink")
.RunListAsync<PageListEntry>(conn) |> await |> Seq.toList } .RunListAsync<PageListEntry>(conn) |> await |> Seq.toList }
| None -> None | None -> None
/// Get counts for the admin dashboard
let findDashboardCounts conn (webLogId : string) =
r.Expr( r.HashMap("pages", r.Table(Table.Page ).GetAll(webLogId).OptArg("index", "webLogId").Count()))
.Merge(r.HashMap("posts", r.Table(Table.Post ).GetAll(webLogId).OptArg("index", "webLogId").Count()))
.Merge(r.HashMap("categories", r.Table(Table.Category).GetAll(webLogId).OptArg("index", "webLogId").Count()))
.RunAtomAsync<DashboardCounts>(conn)
|> await

View File

@ -123,6 +123,15 @@ namespace myWebLog {
} }
} }
/// <summary>
/// Looks up a localized string similar to and {0} more....
/// </summary>
public static string andXMore {
get {
return ResourceManager.GetString("andXMore", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Categories. /// Looks up a localized string similar to Categories.
/// </summary> /// </summary>
@ -150,6 +159,15 @@ namespace myWebLog {
} }
} }
/// <summary>
/// Looks up a localized string similar to Close.
/// </summary>
public static string Close {
get {
return ResourceManager.GetString("Close", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Dashboard. /// Looks up a localized string similar to Dashboard.
/// </summary> /// </summary>
@ -249,6 +267,15 @@ namespace myWebLog {
} }
} }
/// <summary>
/// Looks up a localized string similar to Error.
/// </summary>
public static string Error {
get {
return ResourceManager.GetString("Error", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Last Updated. /// Looks up a localized string similar to Last Updated.
/// </summary> /// </summary>
@ -626,5 +653,14 @@ namespace myWebLog {
return ResourceManager.GetString("View", resourceCulture); return ResourceManager.GetString("View", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to Warning.
/// </summary>
public static string Warning {
get {
return ResourceManager.GetString("Warning", resourceCulture);
}
}
} }
} }

View File

@ -306,4 +306,16 @@
<data name="ShowInPageList" xml:space="preserve"> <data name="ShowInPageList" xml:space="preserve">
<value>Show in Page List</value> <value>Show in Page List</value>
</data> </data>
<data name="andXMore" xml:space="preserve">
<value>and {0} more...</value>
</data>
<data name="Close" xml:space="preserve">
<value>Close</value>
</data>
<data name="Error" xml:space="preserve">
<value>Error</value>
</data>
<data name="Warning" xml:space="preserve">
<value>Warning</value>
</data>
</root> </root>

View File

@ -1,8 +1,6 @@
namespace myWebLog namespace myWebLog
open myWebLog.Data.Category open myWebLog.Data.WebLog
open myWebLog.Data.Page
open myWebLog.Data.Post
open myWebLog.Entities open myWebLog.Entities
open Nancy open Nancy
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
@ -17,9 +15,6 @@ type AdminModule(conn : IConnection) as this =
/// Admin dashboard /// Admin dashboard
member this.Dashboard () = member this.Dashboard () =
this.RequiresAccessLevel AuthorizationLevel.Administrator this.RequiresAccessLevel AuthorizationLevel.Administrator
let model = DashboardModel(this.Context, this.WebLog) let model = DashboardModel(this.Context, this.WebLog, findDashboardCounts conn this.WebLog.id)
model.posts <- countPosts conn this.WebLog.id
model.pages <- countPages conn this.WebLog.id
model.categories <- countCategories conn this.WebLog.id
model.pageTitle <- Resources.Dashboard model.pageTitle <- Resources.Dashboard
this.View.["admin/dashboard", model] this.View.["admin/dashboard", model]

View File

@ -18,7 +18,10 @@ type PostModule(conn : IConnection, clock : IClock) as this =
/// Get the page number from the dictionary /// Get the page number from the dictionary
let getPage (parameters : DynamicDictionary) = let getPage (parameters : DynamicDictionary) =
match parameters.ContainsKey "page" with | true -> downcast parameters.["page"] | _ -> 1 match parameters.ContainsKey "page" with | true -> System.Int32.Parse (parameters.["page"].ToString ()) | _ -> 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))
do do
this.Get .["/" ] <- fun _ -> upcast this.HomePage () this.Get .["/" ] <- fun _ -> upcast this.HomePage ()
@ -39,13 +42,15 @@ type PostModule(conn : IConnection, clock : IClock) as this =
member this.PublishedPostsPage pageNbr = member this.PublishedPostsPage pageNbr =
let model = PostsModel(this.Context, this.WebLog) let model = PostsModel(this.Context, this.WebLog)
model.pageNbr <- pageNbr model.pageNbr <- pageNbr
model.posts <- findPageOfPublishedPosts conn this.WebLog.id pageNbr 10 model.posts <- findPageOfPublishedPosts conn this.WebLog.id pageNbr 10 |> forDisplay
model.hasNewer <- match List.isEmpty model.posts with model.hasNewer <- match pageNbr with
| true -> false | 1 -> false
| _ -> Option.isSome <| tryFindNewerPost conn (List.last model.posts) | _ -> match List.isEmpty model.posts with
| true -> false
| _ -> Option.isSome <| tryFindNewerPost conn (List.last model.posts).post
model.hasOlder <- match List.isEmpty model.posts with model.hasOlder <- match List.isEmpty model.posts with
| true -> false | true -> false
| _ -> Option.isSome <| tryFindOlderPost conn (List.head model.posts) | _ -> Option.isSome <| tryFindOlderPost conn (List.head model.posts).post
model.urlPrefix <- "/posts" model.urlPrefix <- "/posts"
model.pageTitle <- match pageNbr with model.pageTitle <- match pageNbr with
| 1 -> "" | 1 -> ""
@ -93,15 +98,15 @@ type PostModule(conn : IConnection, clock : IClock) as this =
| Some cat -> let pageNbr = getPage parameters | Some cat -> let pageNbr = getPage parameters
let model = PostsModel(this.Context, this.WebLog) let model = PostsModel(this.Context, this.WebLog)
model.pageNbr <- pageNbr model.pageNbr <- pageNbr
model.posts <- findPageOfCategorizedPosts conn this.WebLog.id cat.id pageNbr 10 model.posts <- findPageOfCategorizedPosts conn this.WebLog.id cat.id pageNbr 10 |> forDisplay
model.hasNewer <- match List.isEmpty model.posts with model.hasNewer <- match List.isEmpty model.posts with
| true -> false | true -> false
| _ -> Option.isSome <| tryFindNewerCategorizedPost conn cat.id | _ -> Option.isSome <| tryFindNewerCategorizedPost conn cat.id
(List.last model.posts) (List.last model.posts).post
model.hasOlder <- match List.isEmpty model.posts with model.hasOlder <- match List.isEmpty model.posts with
| true -> false | true -> false
| _ -> Option.isSome <| tryFindOlderCategorizedPost conn cat.id | _ -> Option.isSome <| tryFindOlderCategorizedPost conn cat.id
(List.last model.posts) (List.last model.posts).post
model.urlPrefix <- sprintf "/category/%s" slug model.urlPrefix <- sprintf "/category/%s" slug
model.pageTitle <- sprintf "\"%s\" Category%s" cat.name model.pageTitle <- sprintf "\"%s\" Category%s" cat.name
(match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n) (match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n)
@ -117,13 +122,13 @@ type PostModule(conn : IConnection, clock : IClock) as this =
let pageNbr = getPage parameters let pageNbr = getPage parameters
let model = PostsModel(this.Context, this.WebLog) let model = PostsModel(this.Context, this.WebLog)
model.pageNbr <- pageNbr model.pageNbr <- pageNbr
model.posts <- findPageOfTaggedPosts conn this.WebLog.id tag pageNbr 10 model.posts <- findPageOfTaggedPosts conn this.WebLog.id tag pageNbr 10 |> forDisplay
model.hasNewer <- match List.isEmpty model.posts with model.hasNewer <- match List.isEmpty model.posts with
| true -> false | true -> false
| _ -> Option.isSome <| tryFindNewerTaggedPost conn tag (List.last model.posts) | _ -> Option.isSome <| tryFindNewerTaggedPost conn tag (List.last model.posts).post
model.hasOlder <- match List.isEmpty model.posts with model.hasOlder <- match List.isEmpty model.posts with
| true -> false | true -> false
| _ -> Option.isSome <| tryFindOlderTaggedPost conn tag (List.last model.posts) | _ -> Option.isSome <| tryFindOlderTaggedPost conn tag (List.last model.posts).post
model.urlPrefix <- sprintf "/tag/%s" tag model.urlPrefix <- sprintf "/tag/%s" tag
model.pageTitle <- sprintf "\"%s\" Tag%s" tag (match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n) model.pageTitle <- sprintf "\"%s\" Tag%s" tag (match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n)
model.subtitle <- Some <| sprintf "Posts tagged \"%s\"" tag model.subtitle <- Some <| sprintf "Posts tagged \"%s\"" tag
@ -137,10 +142,10 @@ type PostModule(conn : IConnection, clock : IClock) as this =
this.RequiresAccessLevel AuthorizationLevel.Administrator this.RequiresAccessLevel AuthorizationLevel.Administrator
let model = PostsModel(this.Context, this.WebLog) let model = PostsModel(this.Context, this.WebLog)
model.pageNbr <- pageNbr model.pageNbr <- pageNbr
model.posts <- findPageOfAllPosts conn this.WebLog.id pageNbr 25 model.posts <- findPageOfAllPosts conn this.WebLog.id pageNbr 25 |> forDisplay
model.hasNewer <- pageNbr > 1 model.hasNewer <- pageNbr > 1
model.hasOlder <- List.length model.posts < 25 model.hasOlder <- List.length model.posts > 24
model.urlPrefix <- "/post/list" model.urlPrefix <- "/posts/list"
model.pageTitle <- Resources.Posts model.pageTitle <- Resources.Posts
this.View.["admin/post/list", model] this.View.["admin/post/list", model]

View File

@ -18,7 +18,7 @@ type UserModule(conn : IConnection) as this =
/// Hash the user's password /// Hash the user's password
let pbkdf2 (pw : string) = let pbkdf2 (pw : string) =
PassphraseKeyGenerator(pw, UTF8Encoding().GetBytes("// TODO: make this salt part of the config"), 4096).GetBytes 512 PassphraseKeyGenerator(pw, UTF8Encoding().GetBytes("// TODO: make this salt part of the config"), 4096).GetBytes 512
|> Seq.fold (fun acc bit -> System.String.Format("{0}{1:x2}", acc, bit)) "" |> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) ""
do do
this.Get .["/logon" ] <- fun parms -> upcast this.ShowLogOn (downcast parms) this.Get .["/logon" ] <- fun parms -> upcast this.ShowLogOn (downcast parms)
@ -28,16 +28,17 @@ type UserModule(conn : IConnection) as this =
/// Show the log on page /// Show the log on page
member this.ShowLogOn (parameters : DynamicDictionary) = member this.ShowLogOn (parameters : DynamicDictionary) =
let model = LogOnModel(this.Context, this.WebLog) let model = LogOnModel(this.Context, this.WebLog)
model.returnUrl <- match parameters.ContainsKey "returnUrl" with model.form.returnUrl <- match parameters.ContainsKey "returnUrl" with
| true -> parameters.["returnUrl"].ToString () | true -> parameters.["returnUrl"].ToString ()
| _ -> "" | _ -> ""
this.View.["admin/user/logon", model] this.View.["admin/user/logon", model]
/// Process a user log on /// Process a user log on
member this.DoLogOn (parameters : DynamicDictionary) = member this.DoLogOn (parameters : DynamicDictionary) =
this.ValidateCsrfToken () this.ValidateCsrfToken ()
let model = this.Bind<LogOnModel> () let form = this.Bind<LogOnForm> ()
match tryUserLogOn conn model.email (pbkdf2 model.password) with let model = MyWebLogModel(this.Context, this.WebLog)
match tryUserLogOn conn form.email (pbkdf2 form.password) with
| Some user -> this.Session.[Keys.User] <- user | Some user -> this.Session.[Keys.User] <- user
{ level = Level.Info { level = Level.Info
message = Resources.MsgLogOnSuccess message = Resources.MsgLogOnSuccess
@ -46,14 +47,14 @@ type UserModule(conn : IConnection) as this =
this.Redirect "" model |> ignore // Save the messages in the session before the Nancy redirect 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 // TODO: investigate if addMessage should update the session when it's called
this.LoginAndRedirect this.LoginAndRedirect
(System.Guid.Parse user.id, fallbackRedirectUrl = defaultArg (Option.ofObj(model.returnUrl)) "/") (System.Guid.Parse user.id, fallbackRedirectUrl = defaultArg (Option.ofObj(form.returnUrl)) "/")
| None -> { level = Level.Error | None -> { level = Level.Error
message = Resources.ErrBadLogOnAttempt message = Resources.ErrBadLogOnAttempt
details = None } details = None }
|> model.addMessage |> model.addMessage
this.Redirect "" model |> ignore // Save the messages in the session before the Nancy redirect this.Redirect "" model |> ignore // Save the messages in the session before the Nancy redirect
// Can't redirect with a negotiator when the other leg uses a straight response... :/ // Can't redirect with a negotiator when the other leg uses a straight response... :/
this.Response.AsRedirect((sprintf "/user/logon?returnUrl=%s" model.returnUrl), this.Response.AsRedirect((sprintf "/user/logon?returnUrl=%s" form.returnUrl),
Responses.RedirectResponse.RedirectType.SeeOther) Responses.RedirectResponse.RedirectType.SeeOther)
/// Log a user off /// Log a user off

View File

@ -1,8 +1,10 @@
namespace myWebLog namespace myWebLog
open myWebLog.Data.WebLog
open myWebLog.Entities open myWebLog.Entities
open Nancy open Nancy
open Nancy.Session.Persistable open Nancy.Session.Persistable
open Newtonsoft.Json
open NodaTime open NodaTime
open NodaTime.Text open NodaTime.Text
@ -10,10 +12,13 @@ open NodaTime.Text
/// Levels for a user message /// Levels for a user message
module Level = module Level =
/// An informational message /// An informational message
[<Literal>]
let Info = "Info" let Info = "Info"
/// A message regarding a non-fatal but non-optimal condition /// A message regarding a non-fatal but non-optimal condition
[<Literal>]
let Warning = "WARNING" let Warning = "WARNING"
/// A message regarding a failure of the expected result /// A message regarding a failure of the expected result
[<Literal>]
let Error = "ERROR" let Error = "ERROR"
@ -28,10 +33,62 @@ type UserMessage = {
} }
with with
/// An empty message /// An empty message
static member empty = static member empty = {
{ level = Level.Info level = Level.Info
message = "" message = ""
details = None } details = None
}
/// Display version
[<JsonIgnore>]
member this.toDisplay =
let classAndLabel =
dict [
Level.Error, ("danger", Resources.Error)
Level.Warning, ("warning", Resources.Warning)
Level.Info, ("info", "")
]
seq {
yield "<div class=\"alert alert-dismissable alert-"
yield fst classAndLabel.[this.level]
yield "\" role=\"alert\"><button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\""
yield Resources.Close
yield "\">&times;</button><strong>"
match snd classAndLabel.[this.level] with
| "" -> ()
| lbl -> yield lbl.ToUpper ()
yield " &#xbb; "
yield this.message
yield "</strong>"
match this.details with
| Some d -> yield "<br />"
yield d
| None -> ()
yield "</div>"
}
|> Seq.reduce (fun acc x -> acc + x)
/// Helpers to format local date/time using NodaTime
module FormatDateTime =
/// Convert ticks to a zoned date/time
let zonedTime timeZone ticks = Instant(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 /// Parent view model for all myWebLog views
@ -64,36 +121,25 @@ type MyWebLogModel(ctx : NancyContext, webLog : WebLog) =
/// Add a message to the output /// Add a message to the output
member this.addMessage message = this.messages <- message :: this.messages member this.addMessage message = this.messages <- message :: this.messages
/// Convert ticks to a zoned date/time for the current web log
member this.zonedTime ticks = Instant(ticks).InZone(DateTimeZoneProviders.Tzdb.[this.webLog.timeZone])
/// Display a long date /// Display a long date
member this.displayLongDate ticks = member this.displayLongDate ticks = FormatDateTime.longDate this.webLog.timeZone ticks
this.zonedTime ticks
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
/// Display a short date /// Display a short date
member this.displayShortDate ticks = member this.displayShortDate ticks = FormatDateTime.shortDate this.webLog.timeZone ticks
this.zonedTime ticks
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
/// Display the time /// Display the time
member this.displayTime ticks = member this.displayTime ticks = FormatDateTime.time this.webLog.timeZone ticks
(this.zonedTime ticks
|> ZonedDateTimePattern.CreateWithCurrentCulture("h':'mmtt", DateTimeZoneProviders.Tzdb).Format).ToLower()
// ---- Admin models ---- // ---- Admin models ----
/// Admin Dashboard view model /// Admin Dashboard view model
type DashboardModel(ctx, webLog) = type DashboardModel(ctx, webLog, counts : DashboardCounts) =
inherit MyWebLogModel(ctx, webLog) inherit MyWebLogModel(ctx, webLog)
/// The number of posts for the current web log /// The number of posts for the current web log
member val posts = 0 with get, set member val posts = counts.posts with get, set
/// The number of pages for the current web log /// The number of pages for the current web log
member val pages = 0 with get, set member val pages = counts.pages with get, set
/// The number of categories for the current web log /// The number of categories for the current web log
member val categories = 0 with get, set member val categories = counts.categories with get, set
// ---- Category models ---- // ---- Category models ----
@ -110,7 +156,7 @@ with
indent = snd cat indent = snd cat
selected = isSelected (fst cat).id } selected = isSelected (fst cat).id }
/// Display name for a category on the list page, complete with indents /// Display name for a category on the list page, complete with indents
member this.listName = sprintf "%s%s" (String.replicate this.indent " &#xabb; &nbsp; ") this.category.name member this.listName = sprintf "%s%s" (String.replicate this.indent " &#xbb; &nbsp; ") this.category.name
/// Display for this category as an option within a select box /// Display for this category as an option within a select box
member this.option = member this.option =
seq { seq {
@ -121,6 +167,9 @@ with
yield "</option>" yield "</option>"
} }
|> String.concat "" |> String.concat ""
/// Does the category have a description?
member this.hasDescription = this.category.description.IsSome
/// Model for the list of categories /// Model for the list of categories
type CategoryListModel(ctx, webLog, categories) = type CategoryListModel(ctx, webLog, categories) =
@ -237,6 +286,28 @@ type PostModel(ctx, webLog, post) =
|> List.sort |> List.sort
|> List.map (fun tag -> tag, tag.Replace(' ', '+')) |> List.map (fun tag -> tag, tag.Replace(' ', '+'))
/// 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 = FormatDateTime.longDate this.timeZone this.post.publishedOn
/// The time the post was published
member this.publishedTime = FormatDateTime.time this.timeZone this.post.publishedOn
/// 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(Resources.andXMore, count - 3))
/// Model for all page-of-posts pages /// Model for all page-of-posts pages
type PostsModel(ctx, webLog) = type PostsModel(ctx, webLog) =
inherit MyWebLogModel(ctx, webLog) inherit MyWebLogModel(ctx, webLog)
@ -245,7 +316,7 @@ type PostsModel(ctx, webLog) =
member val subtitle = Option<string>.None with get, set member val subtitle = Option<string>.None with get, set
/// The posts to display /// The posts to display
member val posts = List.empty<Post> with get, set member val posts = List.empty<PostForDisplay> with get, set
/// The page number of the post list /// The page number of the post list
member val pageNbr = 0 with get, set member val pageNbr = 0 with get, set
@ -320,12 +391,18 @@ type EditPostModel(ctx, webLog, post, revision) =
// ---- User models ---- // ---- User models ----
/// Model to support the user log on page /// Form for the log on page
type LogOnModel(ctx, webLog) = type LogOnForm() =
inherit MyWebLogModel(ctx, webLog)
/// The URL to which the user will be directed upon successful log on /// The URL to which the user will be directed upon successful log on
member val returnUrl = "" with get, set member val returnUrl = "" with get, set
/// The e-mail address /// The e-mail address
member val email = "" with get, set member val email = "" with get, set
/// The user's passwor /// The user's passwor
member val password = "" with get, set 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

View File

@ -51,6 +51,7 @@
<Content Include="data-config.json"> <Content Include="data-config.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="views\admin\message.html" />
<Content Include="views\themes\default\content\bootstrap-theme.css.map"> <Content Include="views\themes\default\content\bootstrap-theme.css.map">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>

View File

@ -32,7 +32,9 @@
</nav> </nav>
</header> </header>
<div class="container"> <div class="container">
<!-- Partial['admin/messages', Model] // TODO --> @Each.messages
@Current.toDisplay
@EndEach
@Section['Content']; @Section['Content'];
</div> </div>
<footer> <footer>

View File

@ -2,7 +2,7 @@
@Section['Content'] @Section['Content']
<div class="row"> <div class="row">
<p><a class="btn btn-primary" href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew"</a></p> <p><a class="btn btn-primary" href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
</div> </div>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover">
@ -20,7 +20,14 @@
</a> </a>
</td> </td>
<td>@Current.listName</td> <td>@Current.listName</td>
<td>@Current.category.description</td> <td>
@If.hasDescription
@Current.category.description.Value
@EndIf
@IfNot.hasDescription
&nbsp;
@EndIf
</td>
</tr> </tr>
@EndEach @EndEach
</table> </table>

View File

@ -23,7 +23,7 @@
<div class="col-xs-6"> <div class="col-xs-6">
<h3>@Translate.Categories &nbsp;<span class="badge">@Model.categories</span></h3> <h3>@Translate.Categories &nbsp;<span class="badge">@Model.categories</span></h3>
<p> <p>
<a href="/categories"><i class="fa fa-list.ul"></i> @Translate.ListAll</a> <a href="/categories"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
&nbsp; &nbsp; &nbsp; &nbsp;
<a href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a> <a href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
</p> </p>

View File

@ -0,0 +1,18 @@
if session && 0 < (session.messages || []).length
while 0 < session.messages.length
- var message = session.messages.shift()
<div class="alert alert-dismissable alert-@Model.level" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="@Translate.Close">&times;</button>
<strong>
if 'danger' == message.type
=__("Error").toUpperCase()
| &nbsp;&#xbb;
else if 'warning' == message.type
=__("Warning").toUpperCase()
| &nbsp;&#xbb;
!= message.text
</strong>
if message.detail
br
!= message.detail
</div>

View File

@ -15,7 +15,7 @@
<td> <td>
@Current.title<br /> @Current.title<br />
<a href="/@Current.permalink">@Translate.View</a> &nbsp; <a href="/@Current.permalink">@Translate.View</a> &nbsp;
<a href="/page/@Current.id}/edit">@Translate.Edit</a> &nbsp; <a href="/page/@Current.id/edit">@Translate.Edit</a> &nbsp;
<a href="javascript:void(0)" onclick="deletePage('@Current.id', '@Current.title')">@Translate.Delete</a> <a href="javascript:void(0)" onclick="deletePage('@Current.id', '@Current.title')">@Translate.Delete</a>
</td> </td>
<td> <td>

View File

@ -4,7 +4,7 @@
<div class="row"> <div class="row">
<p> <p>
<a class="btn btn-primary" href="/post/new/edit"> <a class="btn btn-primary" href="/post/new/edit">
<i class="fa fa-plus"></i> | &nbsp; @Translate.AddNew <i class="fa fa-plus"></i> &nbsp; @Translate.AddNew
</a> </a>
</p> </p>
</div> </div>
@ -17,30 +17,19 @@
<th>@Translate.Tags</th> <th>@Translate.Tags</th>
</tr> </tr>
@Each.posts @Each.posts
<!-- - var theDate = moment(post.publishedDate ? post.publishedDate : post.lastUpdatedDate)
- var tags = (post.tag || []).sort() -->
<tr> <tr>
<td> <td style="white-space:nowrap;">
<!-- =theDate.format('MMM D, YYYY') @Current.publishedDate<br />
br @Translate.at @Current.publishedTime
#{__("at")} #{theDate.format('h:mma')} -->
// TODO format date
</td> </td>
<td> <td>
@Current.title<br /> @Current.post.title<br />
<a href="/@Current.permalink">@Translate.View</a> | &nbsp; <a href="/@Current.post.permalink">@Translate.View</a> &nbsp;|&nbsp;
<a href="/post/@Current.id/edit">@Translate.Edit</a> | &nbsp; <a href="/post/@Current.post.id/edit">@Translate.Edit</a> &nbsp;|&nbsp;
<a href="/post/@Current.id/delete">@Translate.Delete</a> <a href="/post/@Current.post.id/delete">@Translate.Delete</a>
</td>
<td>@Current.status</td>
<td>
<!-- if 5 > tags.length
=tags.join(' | ')
else
=tags.slice(0, 3).join(' | ') + ' | '
span(title=tags.slice(3).join(' | '))= __("and %d more...", tags.slice(3).length) -->
// TODO fix tags
</td> </td>
<td>@Current.post.status</td>
<td>@Current.tags</td>
</tr> </tr>
@EndEach @EndEach
</table> </table>

View File

@ -3,7 +3,7 @@
@Section['Content'] @Section['Content']
<form action="/user/logon" method="post"> <form action="/user/logon" method="post">
@AntiForgeryToken @AntiForgeryToken
<input type="hidden" name="returnUrl" value="@Model.returnUrl" /> <input type="hidden" name="returnUrl" value="@Model.form.returnUrl" />
<div class="row"> <div class="row">
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6"> <div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
<div class="input-group"> <div class="input-group">

View File

@ -1,4 +1,7 @@
@If.subTitle.IsSome @Each.messages
@Current.toDisplay
@EndEach
@If.subTitle.IsSome
<h2> <h2>
<span class="label label-info">@Model.subTitle</span> <span class="label label-info">@Model.subTitle</span>
</h2> </h2>
@ -8,14 +11,14 @@
<div class="col-xs-12"> <div class="col-xs-12">
<article> <article>
<h1> <h1>
<a href="/@Current.permalink" title="@Translate.PermanentLinkTo &quot;@Current.title@quot;">@Current.title</a> <a href="/@Current.post.permalink"
title="@Translate.PermanentLinkTo &quot;@Current.post.title@quot;">@Current.post.title</a>
</h1> </h1>
<!-- var pubDate = moment(post.publishedDate) -->
<p> <p>
<i class="fa fa-calendar" title="@Translate.Date"></i> &nbsp; @Current.publishedDate <!-- #{pubDate.format('MMMM Do, YYYY')} --> <i class="fa fa-calendar" title="@Translate.Date"></i> @Current.publishedDate &nbsp;
<i class="fa fa-clock-o" title="@Translate.Time"></i> &nbsp; @Current.publishedTime <!-- #{pubDate.format('h:mma')} --> <i class="fa fa-clock-o" title="@Translate.Time"></i> @Current.publishedTime
</p> </p>
@Current.text @Current.post.text
</article> </article>
<hr /> <hr />
</div> </div>

View File

@ -5,8 +5,8 @@
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<h4> <h4>
<i class="fa fa-calendar" title="@Translate.Date"></i> &nbsp;@Model.publishedDate <i class="fa fa-calendar" title="@Translate.Date"></i> @Model.publishedDate &nbsp;
<i class="fa fa-clock-o" title="@Translate.Time"></i> &nbsp;@Model.publishedTime &nbsp; &nbsp; &nbsp; <i class="fa fa-clock-o" title="@Translate.Time"></i> @Model.publishedTime &nbsp; &nbsp; &nbsp;
@Each.post.categories @Each.post.categories
<i class="fa fa-folder-open-o" title="@Translate.Category"></i> &nbsp; <i class="fa fa-folder-open-o" title="@Translate.Category"></i> &nbsp;
<!-- <a href="/category/@Current.slug" title=__("Categorized under %s", category.name)) --> <!-- <a href="/category/@Current.slug" title=__("Categorized under %s", category.name)) -->