post edit page

The majority of the changes in this commit have to do with the post edit
page; also caught up resource strings that hadn't been actually put in
Resources.resx yet
This commit is contained in:
Daniel J. Summers 2016-07-09 21:11:50 -05:00
parent 3656bb384c
commit 6b24d416fc
18 changed files with 818 additions and 34 deletions

View File

@ -0,0 +1,28 @@
module myWebLog.Data.Category
open myWebLog.Entities
open Rethink
/// Sort categories by their name, with their children sorted below them, including an indent level
let sortCategories categories =
let rec getChildren (cat : Category) indent =
seq {
yield cat, indent
for child in categories |> List.filter (fun c -> c.parentId = Some cat.id) do
yield! getChildren child (indent + 1)
}
categories
|> List.filter (fun c -> c.parentId.IsNone)
|> List.map (fun c -> getChildren c 0)
|> Seq.collect id
|> Seq.toList
/// Get all categories for a web log
let getAllCategories conn webLogId =
table Table.Category
|> getAll [| webLogId |]
|> optArg "index" "webLogId"
|> orderBy (fun c -> upcast c.["name"])
|> runCursorAsync<Category> conn
|> Seq.toList
|> sortCategories

View File

@ -6,17 +6,23 @@ open Newtonsoft.Json
/// Constants to use for revision source language /// Constants to use for revision source language
module RevisionSource = module RevisionSource =
[<Literal>]
let Markdown = "markdown" let Markdown = "markdown"
[<Literal>]
let HTML = "html" let HTML = "html"
/// Constants to use for authorization levels /// Constants to use for authorization levels
module AuthorizationLevel = module AuthorizationLevel =
[<Literal>]
let Administrator = "Administrator" let Administrator = "Administrator"
[<Literal>]
let User = "User" let User = "User"
/// Constants to use for post statuses /// Constants to use for post statuses
module PostStatus = module PostStatus =
[<Literal>]
let Draft = "Draft" let Draft = "Draft"
[<Literal>]
let Published = "Published" let Published = "Published"
// ---- Entities ---- // ---- Entities ----
@ -30,7 +36,12 @@ type Revision = {
/// The text /// The text
text : string text : string
} }
with
/// An empty revision
static member empty =
{ asOf = int64 0
sourceType = RevisionSource.HTML
text = "" }
/// A page with static content /// A page with static content
type Page = { type Page = {
@ -247,7 +258,7 @@ type Post = {
} }
with with
static member empty = static member empty =
{ id = "" { id = "new"
webLogId = "" webLogId = ""
authorId = "" authorId = ""
status = PostStatus.Draft status = PostStatus.Draft

View File

@ -11,4 +11,15 @@ let tryFindPage conn webLogId pageId : Page option =
|> runAtomAsync<Page> conn |> runAtomAsync<Page> conn
|> box with |> box with
| null -> None | null -> None
| page -> Some <| unbox page
/// Get a page by its Id (excluding revisions)
let tryFindPageWithoutRevisions conn webLogId pageId : Page option =
match table Table.Page
|> get pageId
|> filter (fun p -> upcast p.["webLogId"].Eq(webLogId))
|> without [| "revisions" |]
|> runAtomAsync<Page> conn
|> box with
| null -> None
| page -> Some <| unbox page | page -> Some <| unbox page

View File

@ -47,3 +47,29 @@ let findPageOfAllPosts conn webLogId pageNbr nbrPerPage =
|> slice ((pageNbr - 1) * nbrPerPage) (pageNbr * nbrPerPage) |> slice ((pageNbr - 1) * nbrPerPage) (pageNbr * nbrPerPage)
|> runCursorAsync<Post> conn |> runCursorAsync<Post> conn
|> Seq.toList |> Seq.toList
/// Try to find a post by its Id and web log Id
let tryFindPost conn webLogId postId : Post option =
match table Table.Post
|> get postId
|> filter (fun p -> upcast p.["webLogId"].Eq(webLogId))
|> runAtomAsync<Post> conn
|> box with
| null -> None
| post -> Some <| unbox post
/// Save a post
let savePost conn post =
match post.id with
| "new" -> let newPost = { post with id = string <| System.Guid.NewGuid() }
table Table.Post
|> insert newPost
|> runResultAsync conn
|> ignore
newPost.id
| _ -> table Table.Post
|> get post.id
|> replace post
|> runResultAsync conn
|> ignore
post.id

View File

@ -14,7 +14,7 @@ let insert (expr : obj) (table : Table) = table.Insert expr
let limit (expr : obj) (table : ReqlExpr) = table.Limit expr let limit (expr : obj) (table : ReqlExpr) = table.Limit expr
let optArg key (value : obj) (expr : GetAll) = expr.OptArg (key, value) let optArg key (value : obj) (expr : GetAll) = expr.OptArg (key, value)
let orderBy (exprA : ReqlExpr -> ReqlExpr) (expr : ReqlExpr) = expr.OrderBy exprA let orderBy (exprA : ReqlExpr -> ReqlExpr) (expr : ReqlExpr) = expr.OrderBy exprA
let replace (exprA : obj) (expr : ReqlExpr) = expr.Replace exprA let replace (exprA : obj) (expr : Get) = expr.Replace exprA
let runAtomAsync<'T> (conn : IConnection) (ast : ReqlAst) = ast.RunAtomAsync<'T> conn |> await let runAtomAsync<'T> (conn : IConnection) (ast : ReqlAst) = ast.RunAtomAsync<'T> conn |> await
let runCursorAsync<'T> (conn : IConnection) (ast : ReqlAst) = ast.RunCursorAsync<'T> conn |> await let runCursorAsync<'T> (conn : IConnection) (ast : ReqlAst) = ast.RunCursorAsync<'T> conn |> await
let runListAsync<'T> (conn : IConnection) (ast : ReqlAst) = ast.RunAtomAsync<System.Collections.Generic.List<'T>> conn let runListAsync<'T> (conn : IConnection) (ast : ReqlAst) = ast.RunAtomAsync<System.Collections.Generic.List<'T>> conn

View File

@ -56,6 +56,7 @@
<Compile Include="DataConfig.fs" /> <Compile Include="DataConfig.fs" />
<Compile Include="Rethink.fs" /> <Compile Include="Rethink.fs" />
<Compile Include="SetUp.fs" /> <Compile Include="SetUp.fs" />
<Compile Include="Category.fs" />
<Compile Include="Page.fs" /> <Compile Include="Page.fs" />
<Compile Include="Post.fs" /> <Compile Include="Post.fs" />
<Compile Include="WebLog.fs" /> <Compile Include="WebLog.fs" />

View File

@ -60,6 +60,132 @@ namespace myWebLog {
} }
} }
/// <summary>
/// Looks up a localized string similar to Added.
/// </summary>
public static string Added {
get {
return ResourceManager.GetString("Added", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add New.
/// </summary>
public static string AddNew {
get {
return ResourceManager.GetString("AddNew", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add New Post.
/// </summary>
public static string AddNewPost {
get {
return ResourceManager.GetString("AddNewPost", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Admin.
/// </summary>
public static string Admin {
get {
return ResourceManager.GetString("Admin", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to and Published.
/// </summary>
public static string AndPublished {
get {
return ResourceManager.GetString("AndPublished", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Categories.
/// </summary>
public static string Categories {
get {
return ResourceManager.GetString("Categories", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dashboard.
/// </summary>
public static string Dashboard {
get {
return ResourceManager.GetString("Dashboard", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Date.
/// </summary>
public static string Date {
get {
return ResourceManager.GetString("Date", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
public static string Delete {
get {
return ResourceManager.GetString("Delete", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit.
/// </summary>
public static string Edit {
get {
return ResourceManager.GetString("Edit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit Post.
/// </summary>
public static string EditPost {
get {
return ResourceManager.GetString("EditPost", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Could not convert data-config.json to RethinkDB connection.
/// </summary>
public static string ErrDataConfig {
get {
return ResourceManager.GetString("ErrDataConfig", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to is not properly configured for myWebLog.
/// </summary>
public static string ErrNotConfigured {
get {
return ResourceManager.GetString("ErrNotConfigured", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Log Off.
/// </summary>
public static string LogOff {
get {
return ResourceManager.GetString("LogOff", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Log On. /// Looks up a localized string similar to Log On.
/// </summary> /// </summary>
@ -68,5 +194,176 @@ namespace myWebLog {
return ResourceManager.GetString("LogOn", resourceCulture); return ResourceManager.GetString("LogOn", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to {0}{1} post successfully.
/// </summary>
public static string MsgPostEditSuccess {
get {
return ResourceManager.GetString("MsgPostEditSuccess", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Newer Posts.
/// </summary>
public static string NewerPosts {
get {
return ResourceManager.GetString("NewerPosts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Older Posts.
/// </summary>
public static string OlderPosts {
get {
return ResourceManager.GetString("OlderPosts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Page #.
/// </summary>
public static string PageHash {
get {
return ResourceManager.GetString("PageHash", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Permalink.
/// </summary>
public static string Permalink {
get {
return ResourceManager.GetString("Permalink", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Permanent link to.
/// </summary>
public static string PermanentLinkTo {
get {
return ResourceManager.GetString("PermanentLinkTo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Post Details.
/// </summary>
public static string PostDetails {
get {
return ResourceManager.GetString("PostDetails", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Posts.
/// </summary>
public static string Posts {
get {
return ResourceManager.GetString("Posts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Post Status.
/// </summary>
public static string PostStatus {
get {
return ResourceManager.GetString("PostStatus", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to PublishedDate.
/// </summary>
public static string PublishedDate {
get {
return ResourceManager.GetString("PublishedDate", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Publish This Post.
/// </summary>
public static string PublishThisPost {
get {
return ResourceManager.GetString("PublishThisPost", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save.
/// </summary>
public static string Save {
get {
return ResourceManager.GetString("Save", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to starting with.
/// </summary>
public static string startingWith {
get {
return ResourceManager.GetString("startingWith", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Status.
/// </summary>
public static string Status {
get {
return ResourceManager.GetString("Status", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Tags.
/// </summary>
public static string Tags {
get {
return ResourceManager.GetString("Tags", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Time.
/// </summary>
public static string Time {
get {
return ResourceManager.GetString("Time", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Title.
/// </summary>
public static string Title {
get {
return ResourceManager.GetString("Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Updated.
/// </summary>
public static string Updated {
get {
return ResourceManager.GetString("Updated", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View.
/// </summary>
public static string View {
get {
return ResourceManager.GetString("View", resourceCulture);
}
}
} }
} }

View File

@ -117,7 +117,106 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="Added" xml:space="preserve">
<value>Added</value>
</data>
<data name="AddNew" xml:space="preserve">
<value>Add New</value>
</data>
<data name="AddNewPost" xml:space="preserve">
<value>Add New Post</value>
</data>
<data name="Admin" xml:space="preserve">
<value>Admin</value>
</data>
<data name="AndPublished" xml:space="preserve">
<value> and Published</value>
</data>
<data name="Categories" xml:space="preserve">
<value>Categories</value>
</data>
<data name="Dashboard" xml:space="preserve">
<value>Dashboard</value>
</data>
<data name="Date" xml:space="preserve">
<value>Date</value>
</data>
<data name="Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="EditPost" xml:space="preserve">
<value>Edit Post</value>
</data>
<data name="ErrDataConfig" xml:space="preserve">
<value>Could not convert data-config.json to RethinkDB connection</value>
</data>
<data name="ErrNotConfigured" xml:space="preserve">
<value>is not properly configured for myWebLog</value>
</data>
<data name="LogOff" xml:space="preserve">
<value>Log Off</value>
</data>
<data name="LogOn" xml:space="preserve"> <data name="LogOn" xml:space="preserve">
<value>Log On</value> <value>Log On</value>
</data> </data>
<data name="MsgPostEditSuccess" xml:space="preserve">
<value>{0}{1} post successfully</value>
</data>
<data name="NewerPosts" xml:space="preserve">
<value>Newer Posts</value>
</data>
<data name="OlderPosts" xml:space="preserve">
<value>Older Posts</value>
</data>
<data name="PageHash" xml:space="preserve">
<value>Page #</value>
</data>
<data name="Permalink" xml:space="preserve">
<value>Permalink</value>
</data>
<data name="PermanentLinkTo" xml:space="preserve">
<value>Permanent link to</value>
</data>
<data name="PostDetails" xml:space="preserve">
<value>Post Details</value>
</data>
<data name="Posts" xml:space="preserve">
<value>Posts</value>
</data>
<data name="PostStatus" xml:space="preserve">
<value>Post Status</value>
</data>
<data name="PublishedDate" xml:space="preserve">
<value>PublishedDate</value>
</data>
<data name="PublishThisPost" xml:space="preserve">
<value>Publish This Post</value>
</data>
<data name="Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="startingWith" xml:space="preserve">
<value>starting with</value>
</data>
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
<data name="Tags" xml:space="preserve">
<value>Tags</value>
</data>
<data name="Time" xml:space="preserve">
<value>Time</value>
</data>
<data name="Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="Updated" xml:space="preserve">
<value>Updated</value>
</data>
<data name="View" xml:space="preserve">
<value>View</value>
</data>
</root> </root>

View File

@ -15,6 +15,7 @@ open Nancy.Session.Persistable
open Nancy.Session.RethinkDb open Nancy.Session.RethinkDb
open Nancy.TinyIoc open Nancy.TinyIoc
open Nancy.ViewEngines.SuperSimpleViewEngine open Nancy.ViewEngines.SuperSimpleViewEngine
open NodaTime
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open Suave open Suave
open Suave.Owin open Suave.Owin
@ -23,8 +24,7 @@ open System.Text.RegularExpressions
/// Set up a database connection /// Set up a database connection
let cfg = try DataConfig.fromJson (System.IO.File.ReadAllText "data-config.json") let cfg = try DataConfig.fromJson (System.IO.File.ReadAllText "data-config.json")
with ex -> ApplicationException("Could not convert data-config.json to RethinkDB connection", ex) with ex -> raise <| ApplicationException(Resources.ErrDataConfig, ex)
|> raise
do do
startUpCheck cfg startUpCheck cfg
@ -75,6 +75,9 @@ type MyWebLogBootstrapper() =
|> ignore |> ignore
container.Register<IConnection>(cfg.conn) container.Register<IConnection>(cfg.conn)
|> ignore |> ignore
// NodaTime
container.Register<IClock>(SystemClock.Instance)
|> ignore
// I18N in SSVE // I18N in SSVE
container.Register<seq<ISuperSimpleViewEngineMatcher>>(fun _ _ -> container.Register<seq<ISuperSimpleViewEngineMatcher>>(fun _ _ ->
Seq.singleton (TranslateTokenViewEngineMatcher() :> ISuperSimpleViewEngineMatcher)) Seq.singleton (TranslateTokenViewEngineMatcher() :> ISuperSimpleViewEngineMatcher))
@ -116,7 +119,7 @@ type RequestEnvironment() =
match tryFindWebLogByUrlBase cfg.conn ctx.Request.Url.HostName with match tryFindWebLogByUrlBase cfg.conn ctx.Request.Url.HostName with
| Some webLog -> ctx.Items.[Keys.WebLog] <- webLog | Some webLog -> ctx.Items.[Keys.WebLog] <- webLog
| None -> ApplicationException | None -> ApplicationException
(sprintf "%s is not properly configured for myWebLog" ctx.Request.Url.HostName) (sprintf "%s %s" ctx.Request.Url.HostName Resources.ErrNotConfigured)
|> raise |> raise
ctx.Items.[Keys.Version] <- version ctx.Items.[Keys.Version] <- version
null) null)

View File

@ -1,23 +1,30 @@
namespace myWebLog [<AutoOpen>]
module myWebLog.ModuleExtensions
open myWebLog
open myWebLog.Entities open myWebLog.Entities
open Nancy open Nancy
open Nancy.Security open Nancy.Security
/// Parent class for all myWebLog Nancy modules /// Parent class for all myWebLog Nancy modules
[<AbstractClass>] type NancyModule with
type MyWebLogModule() =
inherit NancyModule()
/// Strongly-typed access to the web log for the current request /// Strongly-typed access to the web log for the current request
member this.WebLog = this.Context.Items.[Keys.WebLog] :?> WebLog member this.WebLog = this.Context.Items.[Keys.WebLog] :?> WebLog
/// Display a view using the theme specified for the web log /// Display a view using the theme specified for the web log
member this.ThemedRender view model = this.View.[(sprintf "%s/%s" this.WebLog.themePath view), model] member this.ThemedView view model = this.View.[(sprintf "%s/%s" this.WebLog.themePath view), model]
/// Return a 404 /// Return a 404
member this.NotFound () = this.Negotiate.WithStatusCode 404 member this.NotFound () = this.Negotiate.WithStatusCode 404
/// Redirect a request, storing messages in the session if they exist
member this.Redirect url (model : MyWebLogModel) =
match List.length model.messages with
| 0 -> ()
| _ -> this.Session.[Keys.Messages] <- model.messages
this.Negotiate.WithHeader("Location", url).WithStatusCode(HttpStatusCode.TemporaryRedirect)
/// Require a specific level of access for the current web log /// Require a specific level of access for the current web log
member this.RequiresAccessLevel level = member this.RequiresAccessLevel level =
this.RequiresAuthentication() this.RequiresAuthentication()

View File

@ -1,28 +1,36 @@
namespace myWebLog namespace myWebLog
open FSharp.Markdown
open myWebLog.Data.Category
open myWebLog.Data.Page open myWebLog.Data.Page
open myWebLog.Data.Post open myWebLog.Data.Post
open myWebLog.Entities
open Nancy open Nancy
open Nancy.Authentication.Forms open Nancy.Authentication.Forms
open Nancy.ModelBinding
open Nancy.Security open Nancy.Security
open Nancy.Session.Persistable
open NodaTime
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open myWebLog.Entities
type PostModule(conn : IConnection) as this = /// Routes dealing with posts (including the home page)
inherit MyWebLogModule() type PostModule(conn : IConnection, clock : IClock) as this =
inherit NancyModule()
let getPage (parms : obj) = ((parms :?> DynamicDictionary).["page"] :?> int)
do do
this.Get.["/"] <- fun _ -> upcast this.HomePage () this.Get .["/" ] <- fun _ -> upcast this.HomePage ()
this.Get.["/posts/page/{page:int}"] <- fun parms -> upcast this.GetPageOfPublishedPosts (downcast parms) this.Get .["/posts/page/{page:int}" ] <- fun parms -> upcast this.DisplayPageOfPublishedPosts (getPage parms)
this.Get .["/posts/list" ] <- fun _ -> upcast this.PostList 1
this.Get.["/posts/list"] <- fun _ -> upcast this.PostList 1 this.Get .["/posts/list/page/{page:int}"] <- fun parms -> upcast this.PostList (getPage parms)
this.Get.["/posts/list/page/{page:int"] <- fun parms -> upcast this.PostList this.Get .["/post/{postId}/edit" ] <- fun parms -> upcast this.EditPost (downcast parms)
((parms :?> DynamicDictionary).["page"] :?> int) this.Post.["/post/{postId}/edit" ] <- fun parms -> upcast this.SavePost (downcast parms)
// ---- Display posts to users ---- // ---- Display posts to users ----
/// Display a page of published posts /// Display a page of published posts
member private this.DisplayPageOfPublishedPosts pageNbr = member this.DisplayPageOfPublishedPosts 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
@ -35,22 +43,18 @@ type PostModule(conn : IConnection) as this =
model.urlPrefix <- "/posts" model.urlPrefix <- "/posts"
model.pageTitle <- match pageNbr with model.pageTitle <- match pageNbr with
| 1 -> "" | 1 -> ""
| _ -> sprintf "Page #%i" pageNbr | _ -> sprintf "%s%i" Resources.PageHash pageNbr
this.ThemedRender "posts" model this.ThemedView "posts" model
/// Display either the newest posts or the configured home page /// Display either the newest posts or the configured home page
member this.HomePage () = member this.HomePage () =
match this.WebLog.defaultPage with match this.WebLog.defaultPage with
| "posts" -> this.DisplayPageOfPublishedPosts 1 | "posts" -> this.DisplayPageOfPublishedPosts 1
| page -> match tryFindPage conn this.WebLog.id page with | page -> match tryFindPageWithoutRevisions conn this.WebLog.id page with
| Some page -> let model = PageModel(this.Context, this.WebLog, page) | Some page -> let model = PageModel(this.Context, this.WebLog, page)
model.pageTitle <- page.title model.pageTitle <- page.title
this.ThemedRender "page" model this.ThemedView "page" model
| None -> this.Negotiate.WithStatusCode 404 | None -> this.NotFound ()
/// Get a page of public posts (other than the first one if the home page is a page of posts)
member this.GetPageOfPublishedPosts (parameters : DynamicDictionary) =
this.DisplayPageOfPublishedPosts (parameters.["page"] :?> int)
// ---- Administer posts ---- // ---- Administer posts ----
@ -61,7 +65,78 @@ type PostModule(conn : IConnection) as this =
model.pageNbr <- pageNbr model.pageNbr <- pageNbr
model.posts <- findPageOfAllPosts conn this.WebLog.id pageNbr 25 model.posts <- findPageOfAllPosts conn this.WebLog.id pageNbr 25
model.hasNewer <- pageNbr > 1 model.hasNewer <- pageNbr > 1
model.hasOlder <- 25 > List.length model.posts model.hasOlder <- List.length model.posts < 25
model.urlPrefix <- "/post/list" model.urlPrefix <- "/post/list"
model.pageTitle <- "Posts" model.pageTitle <- Resources.Posts
this.View.["admin/post/list", model] this.View.["admin/post/list", model]
/// Edit a post
member this.EditPost (parameters : DynamicDictionary) =
this.RequiresAccessLevel AuthorizationLevel.Administrator
let postId : string = downcast parameters.["postId"]
match (match postId with
| "new" -> Some Post.empty
| _ -> tryFindPost conn this.WebLog.id postId) with
| Some post -> let rev = match post.revisions
|> List.sortByDescending (fun r -> r.asOf)
|> List.tryHead with
| Some r -> r
| None -> Revision.empty
let model = EditPostModel(this.Context, this.WebLog, post, rev)
model.categories <- getAllCategories conn this.WebLog.id
|> List.map (fun cat -> string (fst cat).id,
sprintf "%s%s"
(String.replicate (snd cat) " &nbsp; &nbsp; ")
(fst cat).name)
model.pageTitle <- match post.id with
| "new" -> Resources.AddNewPost
| _ -> Resources.EditPost
this.View.["admin/post/edit"]
| None -> this.NotFound ()
/// Save a post
member this.SavePost (parameters : DynamicDictionary) =
this.RequiresAccessLevel AuthorizationLevel.Administrator
this.ValidateCsrfToken ()
let postId : string = downcast parameters.["postId"]
let form = this.Bind<EditPostForm>()
let now = clock.Now.Ticks
match (match postId with
| "new" -> Some Post.empty
| _ -> tryFindPost conn this.WebLog.id postId) with
| Some p -> let justPublished = p.publishedOn = int64 0 && form.publishNow
let post = match postId with
| "new" -> { p with
webLogId = this.WebLog.id
authorId = (this.Request.PersistableSession.GetOrDefault<User>
(Keys.User, User.empty)).id }
| _ -> p
let pId = { post with
status = match form.publishNow with
| true -> PostStatus.Published
| _ -> PostStatus.Draft
title = form.title
permalink = form.permalink
publishedOn = match justPublished with | true -> now | _ -> int64 0
updatedOn = now
text = match form.source with
| RevisionSource.Markdown -> Markdown.TransformHtml form.text
| _ -> form.text
categoryIds = Array.toList form.categories
tags = form.tags.Split ','
|> Seq.map (fun t -> t.Trim().ToLowerInvariant())
|> Seq.toList
revisions = { asOf = now
sourceType = form.source
text = form.text } :: post.revisions }
|> savePost conn
let model = MyWebLogModel(this.Context, this.WebLog)
{ level = Level.Info
message = System.String.Format
(Resources.MsgPostEditSuccess,
(match postId with | "new" -> Resources.Added | _ -> Resources.Updated),
(match justPublished with | true -> Resources.AndPublished | _ -> ""))
details = None }
|> model.addMessage
this.Redirect (sprintf "/post/%s/edit" pId) model
| None -> this.NotFound ()

View File

@ -3,6 +3,9 @@
open myWebLog.Entities open myWebLog.Entities
open Nancy open Nancy
open Nancy.Session.Persistable open Nancy.Session.Persistable
open NodaTime
open NodaTime.Text
/// Levels for a user message /// Levels for a user message
module Level = module Level =
@ -57,6 +60,23 @@ 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
member this.displayLongDate ticks =
this.zonedTime ticks
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
/// Display a short date
member this.displayShortDate ticks =
this.zonedTime ticks
|> ZonedDateTimePattern.CreateWithCurrentCulture("MMM d',' yyyy", DateTimeZoneProviders.Tzdb).Format
/// Display the time
member this.displayTime ticks =
(this.zonedTime ticks
|> ZonedDateTimePattern.CreateWithCurrentCulture("h':'mmtt", DateTimeZoneProviders.Tzdb).Format).ToLower()
/// Model for all page-of-posts pages /// Model for all page-of-posts pages
type PostsModel(ctx, webLog) = type PostsModel(ctx, webLog) =
@ -93,3 +113,52 @@ type PageModel(ctx, webLog, page) =
/// The page to be displayed /// The page to be displayed
member this.page : Page = page member this.page : Page = page
/// Form for editing a post
type EditPostForm() =
/// The title of the post
member val title = "" with get, set
/// The permalink for the post
member val permalink = "" with get, set
/// The source type for this revision
member val source = "" with get, set
/// The text
member val text = "" with get, set
/// Tags for the post
member val tags = "" with get, set
/// The selected category Ids for the post
member val categories = Array.empty<string> with get, set
/// Whether the post should be published
member val publishNow = true with get, set
/// Fill the form with applicable values from a post
member this.forPost post =
this.title <- post.title
this.permalink <- post.permalink
this.tags <- List.reduce (fun acc x -> sprintf "%s, %s" acc x) post.tags
this.categories <- List.toArray post.categoryIds
this
/// Fill the form with applicable values from a revision
member this.forRevision rev =
this.source <- rev.sourceType
this.text <- rev.text
this
/// View model for the edit post page
type EditPostModel(ctx, webLog, post, revision) =
inherit MyWebLogModel(ctx, webLog)
/// The form
member val form = EditPostForm().forPost(post).forRevision(revision) with get, set
/// The post being edited
member val post = post with get, set
/// The categories to which the post may be assigned
member val categories = List.empty<string * string> with get, set
/// Whether the post is currently published
member this.isPublished = PostStatus.Published = this.post.status
/// The published date
member this.publishedDate = this.displayLongDate this.post.publishedOn
/// The published time
member this.publishedTime = this.displayTime this.post.publishedOn

View File

@ -53,7 +53,7 @@
<Compile Include="AssemblyInfo.fs" /> <Compile Include="AssemblyInfo.fs" />
<Compile Include="Keys.fs" /> <Compile Include="Keys.fs" />
<Compile Include="ViewModels.fs" /> <Compile Include="ViewModels.fs" />
<Compile Include="MyWebLogModule.fs" /> <Compile Include="ModuleExtensions.fs" />
<Compile Include="PostModule.fs" /> <Compile Include="PostModule.fs" />
<Compile Include="App.fs" /> <Compile Include="App.fs" />
<Content Include="packages.config" /> <Content Include="packages.config" />
@ -67,10 +67,42 @@
<HintPath>..\packages\Common.Logging.Core.3.3.1\lib\net40\Common.Logging.Core.dll</HintPath> <HintPath>..\packages\Common.Logging.Core.3.3.1\lib\net40\Common.Logging.Core.dll</HintPath>
<Private>True</Private> <Private>True</Private>
</Reference> </Reference>
<Reference Include="CSharpFormat">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\CSharpFormat.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharp.CodeFormat">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\FSharp.CodeFormat.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharp.Compiler.Service">
<HintPath>..\packages\FSharp.Compiler.Service.2.0.0.6\lib\net45\FSharp.Compiler.Service.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharp.Core"> <Reference Include="FSharp.Core">
<HintPath>..\packages\FSharp.Core.4.0.0.1\lib\net40\FSharp.Core.dll</HintPath> <HintPath>..\packages\FSharp.Core.4.0.0.1\lib\net40\FSharp.Core.dll</HintPath>
<Private>True</Private> <Private>True</Private>
</Reference> </Reference>
<Reference Include="FSharp.Formatting.Common">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\FSharp.Formatting.Common.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharp.Literate">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\FSharp.Literate.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharp.Markdown">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\FSharp.Markdown.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharp.MetadataFormat">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\FSharp.MetadataFormat.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="FSharpVSPowerTools.Core">
<HintPath>..\packages\FSharpVSPowerTools.Core.2.3.0\lib\net45\FSharpVSPowerTools.Core.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="mscorlib" /> <Reference Include="mscorlib" />
<Reference Include="Nancy"> <Reference Include="Nancy">
<HintPath>..\packages\Nancy.1.4.3\lib\net40\Nancy.dll</HintPath> <HintPath>..\packages\Nancy.1.4.3\lib\net40\Nancy.dll</HintPath>
@ -92,6 +124,14 @@
<HintPath>..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll</HintPath> <HintPath>..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll</HintPath>
<Private>True</Private> <Private>True</Private>
</Reference> </Reference>
<Reference Include="NodaTime">
<HintPath>..\packages\NodaTime.1.3.2\lib\net35-Client\NodaTime.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="RazorEngine">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\RazorEngine.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="RethinkDb.Driver"> <Reference Include="RethinkDb.Driver">
<HintPath>..\packages\RethinkDb.Driver.2.3.8\lib\net45\RethinkDb.Driver.dll</HintPath> <HintPath>..\packages\RethinkDb.Driver.2.3.8\lib\net45\RethinkDb.Driver.dll</HintPath>
<Private>True</Private> <Private>True</Private>
@ -101,8 +141,14 @@
<Private>True</Private> <Private>True</Private>
</Reference> </Reference>
<Reference Include="System" /> <Reference Include="System" />
<Reference Include="System.ComponentModel.DataAnnotations" />
<Reference Include="System.Core" /> <Reference Include="System.Core" />
<Reference Include="System.Numerics" /> <Reference Include="System.Numerics" />
<Reference Include="System.Web.Razor">
<HintPath>..\packages\FSharp.Formatting.2.14.4\lib\net40\System.Web.Razor.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="System.Xml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\myWebLog.Data\myWebLog.Data.fsproj"> <ProjectReference Include="..\myWebLog.Data\myWebLog.Data.fsproj">

View File

@ -2,12 +2,16 @@
<packages> <packages>
<package id="Common.Logging" version="3.3.1" targetFramework="net452" /> <package id="Common.Logging" version="3.3.1" targetFramework="net452" />
<package id="Common.Logging.Core" version="3.3.1" targetFramework="net452" /> <package id="Common.Logging.Core" version="3.3.1" targetFramework="net452" />
<package id="FSharp.Compiler.Service" version="2.0.0.6" targetFramework="net452" />
<package id="FSharp.Core" version="4.0.0.1" targetFramework="net452" /> <package id="FSharp.Core" version="4.0.0.1" targetFramework="net452" />
<package id="FSharp.Formatting" version="2.14.4" targetFramework="net452" />
<package id="FSharpVSPowerTools.Core" version="2.3.0" targetFramework="net452" />
<package id="Nancy" version="1.4.3" targetFramework="net452" /> <package id="Nancy" version="1.4.3" targetFramework="net452" />
<package id="Nancy.Authentication.Forms" version="1.4.1" targetFramework="net452" /> <package id="Nancy.Authentication.Forms" version="1.4.1" targetFramework="net452" />
<package id="Nancy.Session.Persistable" version="0.8.6" targetFramework="net452" /> <package id="Nancy.Session.Persistable" version="0.8.6" targetFramework="net452" />
<package id="Nancy.Session.RethinkDB" version="0.8.6" targetFramework="net452" /> <package id="Nancy.Session.RethinkDB" version="0.8.6" targetFramework="net452" />
<package id="Newtonsoft.Json" version="9.0.1" targetFramework="net452" /> <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net452" />
<package id="NodaTime" version="1.3.2" targetFramework="net452" />
<package id="RethinkDb.Driver" version="2.3.8" targetFramework="net452" /> <package id="RethinkDb.Driver" version="2.3.8" targetFramework="net452" />
<package id="Suave" version="1.1.3" targetFramework="net452" /> <package id="Suave" version="1.1.3" targetFramework="net452" />
</packages> </packages>

View File

@ -0,0 +1,10 @@
tinymce.init({
menubar: false,
plugins: [
"advlist autolink link image lists charmap print preview hr anchor pagebreak spellchecker",
"searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking",
"save table contextmenu directionality emoticons template paste textcolor"
],
selector: "textarea",
toolbar: "styleselect | forecolor backcolor | bullist numlist | link unlink anchor | paste pastetext | spellchecker | visualblocks visualchars | code fullscreen"
})

View File

@ -66,8 +66,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup /> <ItemGroup />
<ItemGroup> <ItemGroup>
<Content Include="content\scripts\tinymce-init.js" />
<Content Include="content\styles\admin.css" /> <Content Include="content\styles\admin.css" />
<Content Include="views\admin\admin-layout.html" /> <Content Include="views\admin\admin-layout.html" />
<Content Include="views\admin\post\edit.html" />
<Content Include="views\admin\post\list.html" /> <Content Include="views\admin\post\list.html" />
<Content Include="views\default\index-content.html" /> <Content Include="views\default\index-content.html" />
<Content Include="views\default\index.html" /> <Content Include="views\default\index.html" />

View File

@ -0,0 +1,95 @@
@Master['admin/admin-layout']
@Section['Content']
<form action='/post/@Model.post.id/edit' method="post">
@AntiForgeryToken
<div class="row">
<div class="col-sm-9">
<div class="form-group">
<label class="control-label" for="title">@Translate.Title</label>
<input type="text" name="title" id="title" class="form-control" value="@Model.form.title" />
</div>
<div class="form-group">
<label class="control-label" for="permalink">@Translate.Permalink</label>
<input type="text" name="permalink" id="permalink" class="form-control" value="@Model.form.permalink" />
<!-- // FIXME: hard-coded "http" -->
<p class="form-hint"><em>@Translate.startingWith</em> http://@Model.webLog.urlBase/ </p>
</div>
<!-- // TODO: Markdown / HTML choice -->
<div class="form-group">
<textarea name="text" id="text" rows="15">@Model.form.text</textarea>
</div>
<div class="form-group">
<label class="control-label" for="tags">@Translate.Tags</label>
<input type="text" name="tags" id="tags" class="form-control" value="@Model.form.tags" />
</div>
</div>
<div class="col-sm-3">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">@Translate.PostDetails</h4>
</div>
<div class="panel-body">
<div class="form-group">
<label class="control-label">@Translate.PostStatus</label>
<p class="static-control">@Model.post.status</p>
</div>
@If.isPublished
<div class="form-group">
<label class="control-label">@Translate.PublishedDate</label>
<p class="static-control">@Model.publishedDate<br />@Model.publishedTime</p>
</div>
@EndIf
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">@Translate.Categories</h4>
</div>
<div class="panel-body" style="max-height:350px;overflow:scroll;">
<!-- // TODO: how to check the ones that are already selected? -->
@Each.categories
<!-- - var tab = 0
while tab < item.indent
| &nbsp; &nbsp;
- tab++
- var attributes = {}
if -1 < currentCategories.indexOf(item.category.id)
- attributes.checked = 'checked' -->
<input type="checkbox" id="category-@Current.Item1" name="category", value="@Current.Item1" />
&nbsp;
<!-- // FIXME: the title should be the category description -->
<label for="category-@Current.Item1" title="@Current.Item2">@Current.Item2</label>
<br/>
@EndEach
</div>
</div>
<div class="text-center">
@If.isPublished
<input type="hidden" name="publishNow" value="true" />
@EndIf
@IfNot.isPublished
<div>
<input type="checkbox" name="publishNow" id="publishNow" value="true" checked="checked" />
&nbsp; <label for="publishNow">@Translate.PublishThisPost</label>
</div>
@EndIf
<p>
<button type="submit" class="btn btn-primary">
<i class="fa fa-floppy-o"></i> &nbsp; @Translate.Save
</button>
</p>
</div>
</div>
</div>
</form>
@EndSection
@Section['Scripts']
<script type="text/javascript" src="/content/scripts/tinymce-init.js"></script>
<script type="text/javascript">
/** <![CDATA[ */
$(document).ready(function () { $("#title").focus() })
/** ]]> */
</script>
@EndSection

View File

@ -8,7 +8,7 @@
<div class="col-xs-12"> <div class="col-xs-12">
<article> <article>
<h1> <h1>
<a href="/@Current.permalink" title="Permanent Link to &quot;@Current.title@quot;">@Current.title</a> <a href="/@Current.permalink" title="@Translate.PermanentLinkTo &quot;@Current.title@quot;">@Current.title</a>
</h1> </h1>
<!-- var pubDate = moment(post.publishedDate) --> <!-- var pubDate = moment(post.publishedDate) -->
<p> <p>