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:
parent
197a19d339
commit
7c99da8cb5
@ -32,18 +32,11 @@ let getAllCategories conn (webLogId : string) =
|
||||
r.Table(Table.Category)
|
||||
.GetAll(webLogId).OptArg("index", "webLogId")
|
||||
.OrderBy("name")
|
||||
.RunCursorAsync<Category>(conn)
|
||||
.RunListAsync<Category>(conn)
|
||||
|> await
|
||||
|> Seq.toList
|
||||
|> 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
|
||||
let tryFindCategory conn webLogId catId : Category option =
|
||||
match (category webLogId catId)
|
||||
|
@ -37,20 +37,13 @@ let tryFindPageByPermalink conn (webLogId : string) (permalink : string) =
|
||||
|> await
|
||||
|> 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)
|
||||
let findAllPages conn (webLogId : string) =
|
||||
r.Table(Table.Page)
|
||||
.GetAll(webLogId)
|
||||
.GetAll(webLogId).OptArg("index", "webLogId")
|
||||
.OrderBy("title")
|
||||
.Without("text", "revisions")
|
||||
.RunCursorAsync<Page>(conn)
|
||||
.RunListAsync<Page>(conn)
|
||||
|> await
|
||||
|> Seq.toList
|
||||
|
||||
|
@ -25,7 +25,7 @@ let private toPostList conn pageNbr nbrPerPage (filter : ReqlExpr) =
|
||||
/// Shorthand to get a newer or older post
|
||||
// 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 : obj) (sort : obj) : Post option =
|
||||
let private adjacentPost conn post (theFilter : ReqlExpr -> obj) (sort : obj) : Post option =
|
||||
(publishedPosts post.webLogId)
|
||||
.Filter(theFilter)
|
||||
.OrderBy(sort)
|
||||
@ -58,36 +58,37 @@ let findPageOfTaggedPosts conn webLogId (tag : string) pageNbr nbrPerPage =
|
||||
|> toPostList conn pageNbr nbrPerPage
|
||||
|
||||
/// 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
|
||||
let tryFindNewerCategorizedPost conn (categoryId : string) post =
|
||||
newerPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Gt(post.publishedOn)
|
||||
.And(p.["categoryIds"].Contains(categoryId)))
|
||||
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 -> (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
|
||||
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
|
||||
let tryFindOlderCategorizedPost conn (categoryId : string) post =
|
||||
olderPost conn post (fun p -> (p :> ReqlExpr).["publishedOn"].Lt(post.publishedOn)
|
||||
.And(p.["categoryIds"].Contains(categoryId)))
|
||||
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 -> (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
|
||||
let findPageOfAllPosts conn (webLogId : string) pageNbr nbrPerPage =
|
||||
// FIXME: sort unpublished posts by their last updated date
|
||||
r.Table(Table.Post)
|
||||
.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)
|
||||
.RunCursorAsync<Post>(conn)
|
||||
.RunListAsync<Post>(conn)
|
||||
|> await
|
||||
|> Seq.toList
|
||||
|
||||
@ -107,15 +108,15 @@ let tryFindPostByPermalink conn webLogId permalink =
|
||||
.GetAll(r.Array(webLogId, permalink)).OptArg("index", "permalink")
|
||||
.Filter(fun p -> p.["status"].Eq(PostStatus.Published))
|
||||
.Without("revisions")
|
||||
.Merge(fun post -> ExpandoObject()?categories <-
|
||||
.Merge(fun post -> r.HashMap("categories",
|
||||
post.["categoryIds"]
|
||||
.Map(ReqlFunction1(fun cat -> upcast r.Table(Table.Category).Get(cat).Without("children")))
|
||||
.CoerceTo("array"))
|
||||
.Merge(fun post -> ExpandoObject()?comments <-
|
||||
.CoerceTo("array")))
|
||||
.Merge(fun post -> r.HashMap("comments",
|
||||
r.Table(Table.Comment)
|
||||
.GetAll(post.["id"]).OptArg("index", "postId")
|
||||
.OrderBy("postedOn")
|
||||
.CoerceTo("array"))
|
||||
.CoerceTo("array")))
|
||||
.RunCursorAsync<Post>(conn)
|
||||
|> await
|
||||
|> Seq.tryHead
|
||||
@ -145,11 +146,3 @@ let savePost conn post =
|
||||
.RunResultAsync(conn)
|
||||
|> ignore
|
||||
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
|
||||
|
@ -80,7 +80,7 @@ let checkIndexes cfg =
|
||||
"webLogAndStatus", webLogField "status"
|
||||
"permalink", webLogField "permalink"
|
||||
]
|
||||
Table.User, [ "logOn", Some <| fun row -> upcast r.Array(row.["userName"], row.["passwordHash"])
|
||||
Table.User, [ "userName", None
|
||||
]
|
||||
Table.WebLog, [ "urlBase", None
|
||||
]
|
||||
|
@ -6,10 +6,13 @@ open Rethink
|
||||
let private r = RethinkDb.Driver.RethinkDB.R
|
||||
|
||||
/// 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) =
|
||||
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)
|
||||
|> await
|
||||
|> Seq.tryHead
|
||||
|
@ -1,12 +1,20 @@
|
||||
module myWebLog.Data.WebLog
|
||||
|
||||
open FSharp.Interop.Dynamic
|
||||
open myWebLog.Entities
|
||||
open Rethink
|
||||
open System.Dynamic
|
||||
|
||||
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
|
||||
// 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)
|
||||
@ -24,3 +32,12 @@ let tryFindWebLogByUrlBase conn (urlBase : string) =
|
||||
.Pluck("title", "permalink")
|
||||
.RunListAsync<PageListEntry>(conn) |> await |> Seq.toList }
|
||||
| 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
|
||||
|
36
src/myWebLog.Resources/Resources.Designer.cs
generated
36
src/myWebLog.Resources/Resources.Designer.cs
generated
@ -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>
|
||||
/// Looks up a localized string similar to Categories.
|
||||
/// </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>
|
||||
/// Looks up a localized string similar to Dashboard.
|
||||
/// </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>
|
||||
/// Looks up a localized string similar to Last Updated.
|
||||
/// </summary>
|
||||
@ -626,5 +653,14 @@ namespace myWebLog {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -306,4 +306,16 @@
|
||||
<data name="ShowInPageList" xml:space="preserve">
|
||||
<value>Show in Page List</value>
|
||||
</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>
|
@ -1,8 +1,6 @@
|
||||
namespace myWebLog
|
||||
|
||||
open myWebLog.Data.Category
|
||||
open myWebLog.Data.Page
|
||||
open myWebLog.Data.Post
|
||||
open myWebLog.Data.WebLog
|
||||
open myWebLog.Entities
|
||||
open Nancy
|
||||
open RethinkDb.Driver.Net
|
||||
@ -17,9 +15,6 @@ type AdminModule(conn : IConnection) as this =
|
||||
/// Admin dashboard
|
||||
member this.Dashboard () =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let model = DashboardModel(this.Context, this.WebLog)
|
||||
model.posts <- countPosts conn this.WebLog.id
|
||||
model.pages <- countPages conn this.WebLog.id
|
||||
model.categories <- countCategories conn this.WebLog.id
|
||||
let model = DashboardModel(this.Context, this.WebLog, findDashboardCounts conn this.WebLog.id)
|
||||
model.pageTitle <- Resources.Dashboard
|
||||
this.View.["admin/dashboard", model]
|
||||
|
@ -18,7 +18,10 @@ type PostModule(conn : IConnection, clock : IClock) as this =
|
||||
|
||||
/// Get the page number from the dictionary
|
||||
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
|
||||
this.Get .["/" ] <- fun _ -> upcast this.HomePage ()
|
||||
@ -39,13 +42,15 @@ type PostModule(conn : IConnection, clock : IClock) as this =
|
||||
member this.PublishedPostsPage pageNbr =
|
||||
let model = PostsModel(this.Context, this.WebLog)
|
||||
model.pageNbr <- pageNbr
|
||||
model.posts <- findPageOfPublishedPosts conn this.WebLog.id pageNbr 10
|
||||
model.hasNewer <- match List.isEmpty model.posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindNewerPost conn (List.last model.posts)
|
||||
model.posts <- findPageOfPublishedPosts conn this.WebLog.id pageNbr 10 |> forDisplay
|
||||
model.hasNewer <- match pageNbr with
|
||||
| 1 -> false
|
||||
| _ -> 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
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindOlderPost conn (List.head model.posts)
|
||||
| _ -> Option.isSome <| tryFindOlderPost conn (List.head model.posts).post
|
||||
model.urlPrefix <- "/posts"
|
||||
model.pageTitle <- match pageNbr with
|
||||
| 1 -> ""
|
||||
@ -93,15 +98,15 @@ type PostModule(conn : IConnection, clock : IClock) as this =
|
||||
| Some cat -> let pageNbr = getPage parameters
|
||||
let model = PostsModel(this.Context, this.WebLog)
|
||||
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
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindNewerCategorizedPost conn cat.id
|
||||
(List.last model.posts)
|
||||
(List.last model.posts).post
|
||||
model.hasOlder <- match List.isEmpty model.posts with
|
||||
| true -> false
|
||||
| _ -> Option.isSome <| tryFindOlderCategorizedPost conn cat.id
|
||||
(List.last model.posts)
|
||||
(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)
|
||||
@ -117,13 +122,13 @@ type PostModule(conn : IConnection, clock : IClock) as this =
|
||||
let pageNbr = getPage parameters
|
||||
let model = PostsModel(this.Context, this.WebLog)
|
||||
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
|
||||
| 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
|
||||
| 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.pageTitle <- sprintf "\"%s\" Tag%s" tag (match pageNbr with | 1 -> "" | n -> sprintf " | Page %i" n)
|
||||
model.subtitle <- Some <| sprintf "Posts tagged \"%s\"" tag
|
||||
@ -137,10 +142,10 @@ type PostModule(conn : IConnection, clock : IClock) as this =
|
||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
||||
let model = PostsModel(this.Context, this.WebLog)
|
||||
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.hasOlder <- List.length model.posts < 25
|
||||
model.urlPrefix <- "/post/list"
|
||||
model.hasOlder <- List.length model.posts > 24
|
||||
model.urlPrefix <- "/posts/list"
|
||||
model.pageTitle <- Resources.Posts
|
||||
this.View.["admin/post/list", model]
|
||||
|
||||
|
@ -18,7 +18,7 @@ type UserModule(conn : IConnection) as this =
|
||||
/// Hash the user's password
|
||||
let pbkdf2 (pw : string) =
|
||||
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
|
||||
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
|
||||
member this.ShowLogOn (parameters : DynamicDictionary) =
|
||||
let model = LogOnModel(this.Context, this.WebLog)
|
||||
model.returnUrl <- match parameters.ContainsKey "returnUrl" with
|
||||
| true -> parameters.["returnUrl"].ToString ()
|
||||
| _ -> ""
|
||||
model.form.returnUrl <- match parameters.ContainsKey "returnUrl" with
|
||||
| true -> parameters.["returnUrl"].ToString ()
|
||||
| _ -> ""
|
||||
this.View.["admin/user/logon", model]
|
||||
|
||||
/// Process a user log on
|
||||
member this.DoLogOn (parameters : DynamicDictionary) =
|
||||
this.ValidateCsrfToken ()
|
||||
let model = this.Bind<LogOnModel> ()
|
||||
match tryUserLogOn conn model.email (pbkdf2 model.password) with
|
||||
let form = this.Bind<LogOnForm> ()
|
||||
let model = MyWebLogModel(this.Context, this.WebLog)
|
||||
match tryUserLogOn conn form.email (pbkdf2 form.password) with
|
||||
| Some user -> this.Session.[Keys.User] <- user
|
||||
{ level = Level.Info
|
||||
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
|
||||
// TODO: investigate if addMessage should update the session when it's called
|
||||
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
|
||||
message = Resources.ErrBadLogOnAttempt
|
||||
details = None }
|
||||
|> model.addMessage
|
||||
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... :/
|
||||
this.Response.AsRedirect((sprintf "/user/logon?returnUrl=%s" model.returnUrl),
|
||||
this.Response.AsRedirect((sprintf "/user/logon?returnUrl=%s" form.returnUrl),
|
||||
Responses.RedirectResponse.RedirectType.SeeOther)
|
||||
|
||||
/// Log a user off
|
||||
|
@ -1,8 +1,10 @@
|
||||
namespace myWebLog
|
||||
|
||||
open myWebLog.Data.WebLog
|
||||
open myWebLog.Entities
|
||||
open Nancy
|
||||
open Nancy.Session.Persistable
|
||||
open Newtonsoft.Json
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
@ -10,10 +12,13 @@ open NodaTime.Text
|
||||
/// Levels for a user message
|
||||
module Level =
|
||||
/// An informational message
|
||||
[<Literal>]
|
||||
let Info = "Info"
|
||||
/// A message regarding a non-fatal but non-optimal condition
|
||||
[<Literal>]
|
||||
let Warning = "WARNING"
|
||||
/// A message regarding a failure of the expected result
|
||||
[<Literal>]
|
||||
let Error = "ERROR"
|
||||
|
||||
|
||||
@ -28,11 +33,63 @@ type UserMessage = {
|
||||
}
|
||||
with
|
||||
/// An empty message
|
||||
static member empty =
|
||||
{ level = Level.Info
|
||||
message = ""
|
||||
details = None }
|
||||
static member empty = {
|
||||
level = Level.Info
|
||||
message = ""
|
||||
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 "\">×</button><strong>"
|
||||
match snd classAndLabel.[this.level] with
|
||||
| "" -> ()
|
||||
| lbl -> yield lbl.ToUpper ()
|
||||
yield " » "
|
||||
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
|
||||
type MyWebLogModel(ctx : NancyContext, webLog : WebLog) =
|
||||
@ -64,36 +121,25 @@ type MyWebLogModel(ctx : NancyContext, webLog : WebLog) =
|
||||
/// Add a message to the output
|
||||
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
|
||||
member this.displayLongDate ticks =
|
||||
this.zonedTime ticks
|
||||
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
|
||||
|
||||
member this.displayLongDate ticks = FormatDateTime.longDate this.webLog.timeZone ticks
|
||||
/// Display a short date
|
||||
member this.displayShortDate ticks =
|
||||
this.zonedTime ticks
|
||||
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
|
||||
|
||||
member this.displayShortDate ticks = FormatDateTime.shortDate this.webLog.timeZone ticks
|
||||
/// Display the time
|
||||
member this.displayTime ticks =
|
||||
(this.zonedTime ticks
|
||||
|> ZonedDateTimePattern.CreateWithCurrentCulture("h':'mmtt", DateTimeZoneProviders.Tzdb).Format).ToLower()
|
||||
member this.displayTime ticks = FormatDateTime.time this.webLog.timeZone ticks
|
||||
|
||||
|
||||
// ---- Admin models ----
|
||||
|
||||
/// Admin Dashboard view model
|
||||
type DashboardModel(ctx, webLog) =
|
||||
type DashboardModel(ctx, webLog, counts : DashboardCounts) =
|
||||
inherit MyWebLogModel(ctx, webLog)
|
||||
/// 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
|
||||
member val pages = 0 with get, set
|
||||
member val pages = counts.pages with get, set
|
||||
/// 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 ----
|
||||
@ -110,7 +156,7 @@ with
|
||||
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
|
||||
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 {
|
||||
@ -121,6 +167,9 @@ with
|
||||
yield "</option>"
|
||||
}
|
||||
|> 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) =
|
||||
@ -237,6 +286,28 @@ type PostModel(ctx, webLog, post) =
|
||||
|> List.sort
|
||||
|> 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
|
||||
type PostsModel(ctx, webLog) =
|
||||
inherit MyWebLogModel(ctx, webLog)
|
||||
@ -245,7 +316,7 @@ type PostsModel(ctx, webLog) =
|
||||
member val subtitle = Option<string>.None with get, set
|
||||
|
||||
/// 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
|
||||
member val pageNbr = 0 with get, set
|
||||
@ -320,12 +391,18 @@ type EditPostModel(ctx, webLog, post, revision) =
|
||||
|
||||
// ---- User models ----
|
||||
|
||||
/// Model to support the user log on page
|
||||
type LogOnModel(ctx, webLog) =
|
||||
inherit MyWebLogModel(ctx, webLog)
|
||||
/// 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
|
||||
|
@ -51,6 +51,7 @@
|
||||
<Content Include="data-config.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="views\admin\message.html" />
|
||||
<Content Include="views\themes\default\content\bootstrap-theme.css.map">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
@ -32,7 +32,9 @@
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
<!-- Partial['admin/messages', Model] // TODO -->
|
||||
@Each.messages
|
||||
@Current.toDisplay
|
||||
@EndEach
|
||||
@Section['Content'];
|
||||
</div>
|
||||
<footer>
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
@Section['Content']
|
||||
<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 class="row">
|
||||
<table class="table table-hover">
|
||||
@ -20,7 +20,14 @@
|
||||
</a>
|
||||
</td>
|
||||
<td>@Current.listName</td>
|
||||
<td>@Current.category.description</td>
|
||||
<td>
|
||||
@If.hasDescription
|
||||
@Current.category.description.Value
|
||||
@EndIf
|
||||
@IfNot.hasDescription
|
||||
|
||||
@EndIf
|
||||
</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
|
@ -23,7 +23,7 @@
|
||||
<div class="col-xs-6">
|
||||
<h3>@Translate.Categories <span class="badge">@Model.categories</span></h3>
|
||||
<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>
|
||||
|
||||
<a href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
||||
</p>
|
||||
|
18
src/myWebLog/views/admin/message.html
Normal file
18
src/myWebLog/views/admin/message.html
Normal 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">×</button>
|
||||
<strong>
|
||||
if 'danger' == message.type
|
||||
=__("Error").toUpperCase()
|
||||
| »
|
||||
else if 'warning' == message.type
|
||||
=__("Warning").toUpperCase()
|
||||
| »
|
||||
!= message.text
|
||||
</strong>
|
||||
if message.detail
|
||||
br
|
||||
!= message.detail
|
||||
</div>
|
@ -15,7 +15,7 @@
|
||||
<td>
|
||||
@Current.title<br />
|
||||
<a href="/@Current.permalink">@Translate.View</a>
|
||||
<a href="/page/@Current.id}/edit">@Translate.Edit</a>
|
||||
<a href="/page/@Current.id/edit">@Translate.Edit</a>
|
||||
<a href="javascript:void(0)" onclick="deletePage('@Current.id', '@Current.title')">@Translate.Delete</a>
|
||||
</td>
|
||||
<td>
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="row">
|
||||
<p>
|
||||
<a class="btn btn-primary" href="/post/new/edit">
|
||||
<i class="fa fa-plus"></i> | @Translate.AddNew
|
||||
<i class="fa fa-plus"></i> @Translate.AddNew
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@ -17,30 +17,19 @@
|
||||
<th>@Translate.Tags</th>
|
||||
</tr>
|
||||
@Each.posts
|
||||
<!-- - var theDate = moment(post.publishedDate ? post.publishedDate : post.lastUpdatedDate)
|
||||
- var tags = (post.tag || []).sort() -->
|
||||
<tr>
|
||||
<td>
|
||||
<!-- =theDate.format('MMM D, YYYY')
|
||||
br
|
||||
#{__("at")} #{theDate.format('h:mma')} -->
|
||||
// TODO format date
|
||||
<td style="white-space:nowrap;">
|
||||
@Current.publishedDate<br />
|
||||
@Translate.at @Current.publishedTime
|
||||
</td>
|
||||
<td>
|
||||
@Current.title<br />
|
||||
<a href="/@Current.permalink">@Translate.View</a> |
|
||||
<a href="/post/@Current.id/edit">@Translate.Edit</a> |
|
||||
<a href="/post/@Current.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
|
||||
@Current.post.title<br />
|
||||
<a href="/@Current.post.permalink">@Translate.View</a> |
|
||||
<a href="/post/@Current.post.id/edit">@Translate.Edit</a> |
|
||||
<a href="/post/@Current.post.id/delete">@Translate.Delete</a>
|
||||
</td>
|
||||
<td>@Current.post.status</td>
|
||||
<td>@Current.tags</td>
|
||||
</tr>
|
||||
@EndEach
|
||||
</table>
|
||||
|
@ -3,7 +3,7 @@
|
||||
@Section['Content']
|
||||
<form action="/user/logon" method="post">
|
||||
@AntiForgeryToken
|
||||
<input type="hidden" name="returnUrl" value="@Model.returnUrl" />
|
||||
<input type="hidden" name="returnUrl" value="@Model.form.returnUrl" />
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
|
||||
<div class="input-group">
|
||||
|
@ -1,4 +1,7 @@
|
||||
@If.subTitle.IsSome
|
||||
@Each.messages
|
||||
@Current.toDisplay
|
||||
@EndEach
|
||||
@If.subTitle.IsSome
|
||||
<h2>
|
||||
<span class="label label-info">@Model.subTitle</span>
|
||||
</h2>
|
||||
@ -8,14 +11,14 @@
|
||||
<div class="col-xs-12">
|
||||
<article>
|
||||
<h1>
|
||||
<a href="/@Current.permalink" title="@Translate.PermanentLinkTo "@Current.title@quot;">@Current.title</a>
|
||||
<a href="/@Current.post.permalink"
|
||||
title="@Translate.PermanentLinkTo "@Current.post.title@quot;">@Current.post.title</a>
|
||||
</h1>
|
||||
<!-- var pubDate = moment(post.publishedDate) -->
|
||||
<p>
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Current.publishedDate <!-- #{pubDate.format('MMMM Do, YYYY')} -->
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Current.publishedTime <!-- #{pubDate.format('h:mma')} -->
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Current.publishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Current.publishedTime
|
||||
</p>
|
||||
@Current.text
|
||||
@Current.post.text
|
||||
</article>
|
||||
<hr />
|
||||
</div>
|
||||
|
@ -5,8 +5,8 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h4>
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Model.publishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Model.publishedTime
|
||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Model.publishedDate
|
||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Model.publishedTime
|
||||
@Each.post.categories
|
||||
<i class="fa fa-folder-open-o" title="@Translate.Category"></i>
|
||||
<!-- <a href="/category/@Current.slug" title=__("Categorized under %s", category.name)) -->
|
||||
|
Loading…
Reference in New Issue
Block a user