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:
parent
3656bb384c
commit
6b24d416fc
28
src/myWebLog.Data/Category.fs
Normal file
28
src/myWebLog.Data/Category.fs
Normal 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
|
@ -6,17 +6,23 @@ open Newtonsoft.Json
|
||||
|
||||
/// Constants to use for revision source language
|
||||
module RevisionSource =
|
||||
[<Literal>]
|
||||
let Markdown = "markdown"
|
||||
[<Literal>]
|
||||
let HTML = "html"
|
||||
|
||||
/// Constants to use for authorization levels
|
||||
module AuthorizationLevel =
|
||||
[<Literal>]
|
||||
let Administrator = "Administrator"
|
||||
[<Literal>]
|
||||
let User = "User"
|
||||
|
||||
/// Constants to use for post statuses
|
||||
module PostStatus =
|
||||
[<Literal>]
|
||||
let Draft = "Draft"
|
||||
[<Literal>]
|
||||
let Published = "Published"
|
||||
|
||||
// ---- Entities ----
|
||||
@ -30,7 +36,12 @@ type Revision = {
|
||||
/// The text
|
||||
text : string
|
||||
}
|
||||
|
||||
with
|
||||
/// An empty revision
|
||||
static member empty =
|
||||
{ asOf = int64 0
|
||||
sourceType = RevisionSource.HTML
|
||||
text = "" }
|
||||
|
||||
/// A page with static content
|
||||
type Page = {
|
||||
@ -247,7 +258,7 @@ type Post = {
|
||||
}
|
||||
with
|
||||
static member empty =
|
||||
{ id = ""
|
||||
{ id = "new"
|
||||
webLogId = ""
|
||||
authorId = ""
|
||||
status = PostStatus.Draft
|
||||
|
@ -12,3 +12,14 @@ let tryFindPage conn webLogId pageId : Page option =
|
||||
|> box with
|
||||
| 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
|
@ -47,3 +47,29 @@ let findPageOfAllPosts conn webLogId pageNbr nbrPerPage =
|
||||
|> slice ((pageNbr - 1) * nbrPerPage) (pageNbr * nbrPerPage)
|
||||
|> runCursorAsync<Post> conn
|
||||
|> 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
|
||||
|
@ -14,7 +14,7 @@ let insert (expr : obj) (table : Table) = table.Insert expr
|
||||
let limit (expr : obj) (table : ReqlExpr) = table.Limit expr
|
||||
let optArg key (value : obj) (expr : GetAll) = expr.OptArg (key, value)
|
||||
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 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
|
||||
|
@ -56,6 +56,7 @@
|
||||
<Compile Include="DataConfig.fs" />
|
||||
<Compile Include="Rethink.fs" />
|
||||
<Compile Include="SetUp.fs" />
|
||||
<Compile Include="Category.fs" />
|
||||
<Compile Include="Page.fs" />
|
||||
<Compile Include="Post.fs" />
|
||||
<Compile Include="WebLog.fs" />
|
||||
|
297
src/myWebLog.Resources/Resources.Designer.cs
generated
297
src/myWebLog.Resources/Resources.Designer.cs
generated
@ -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>
|
||||
/// Looks up a localized string similar to Log On.
|
||||
/// </summary>
|
||||
@ -68,5 +194,176 @@ namespace myWebLog {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,7 +117,106 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</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">
|
||||
<value>Log On</value>
|
||||
</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>
|
@ -15,6 +15,7 @@ open Nancy.Session.Persistable
|
||||
open Nancy.Session.RethinkDb
|
||||
open Nancy.TinyIoc
|
||||
open Nancy.ViewEngines.SuperSimpleViewEngine
|
||||
open NodaTime
|
||||
open RethinkDb.Driver.Net
|
||||
open Suave
|
||||
open Suave.Owin
|
||||
@ -23,8 +24,7 @@ open System.Text.RegularExpressions
|
||||
|
||||
/// Set up a database connection
|
||||
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)
|
||||
|> raise
|
||||
with ex -> raise <| ApplicationException(Resources.ErrDataConfig, ex)
|
||||
|
||||
do
|
||||
startUpCheck cfg
|
||||
@ -75,6 +75,9 @@ type MyWebLogBootstrapper() =
|
||||
|> ignore
|
||||
container.Register<IConnection>(cfg.conn)
|
||||
|> ignore
|
||||
// NodaTime
|
||||
container.Register<IClock>(SystemClock.Instance)
|
||||
|> ignore
|
||||
// I18N in SSVE
|
||||
container.Register<seq<ISuperSimpleViewEngineMatcher>>(fun _ _ ->
|
||||
Seq.singleton (TranslateTokenViewEngineMatcher() :> ISuperSimpleViewEngineMatcher))
|
||||
@ -116,7 +119,7 @@ type RequestEnvironment() =
|
||||
match tryFindWebLogByUrlBase cfg.conn ctx.Request.Url.HostName with
|
||||
| Some webLog -> ctx.Items.[Keys.WebLog] <- webLog
|
||||
| None -> ApplicationException
|
||||
(sprintf "%s is not properly configured for myWebLog" ctx.Request.Url.HostName)
|
||||
(sprintf "%s %s" ctx.Request.Url.HostName Resources.ErrNotConfigured)
|
||||
|> raise
|
||||
ctx.Items.[Keys.Version] <- version
|
||||
null)
|
||||
|
@ -1,23 +1,30 @@
|
||||
namespace myWebLog
|
||||
[<AutoOpen>]
|
||||
module myWebLog.ModuleExtensions
|
||||
|
||||
open myWebLog
|
||||
open myWebLog.Entities
|
||||
open Nancy
|
||||
open Nancy.Security
|
||||
|
||||
/// Parent class for all myWebLog Nancy modules
|
||||
[<AbstractClass>]
|
||||
type MyWebLogModule() =
|
||||
inherit NancyModule()
|
||||
type NancyModule with
|
||||
|
||||
/// Strongly-typed access to the web log for the current request
|
||||
member this.WebLog = this.Context.Items.[Keys.WebLog] :?> WebLog
|
||||
|
||||
/// Display a view using the theme specified for the web log
|
||||
member this.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
|
||||
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
|
||||
member this.RequiresAccessLevel level =
|
||||
this.RequiresAuthentication()
|
@ -1,28 +1,36 @@
|
||||
namespace myWebLog
|
||||
|
||||
open FSharp.Markdown
|
||||
open myWebLog.Data.Category
|
||||
open myWebLog.Data.Page
|
||||
open myWebLog.Data.Post
|
||||
open myWebLog.Entities
|
||||
open Nancy
|
||||
open Nancy.Authentication.Forms
|
||||
open Nancy.ModelBinding
|
||||
open Nancy.Security
|
||||
open Nancy.Session.Persistable
|
||||
open NodaTime
|
||||
open RethinkDb.Driver.Net
|
||||
open myWebLog.Entities
|
||||
|
||||
type PostModule(conn : IConnection) as this =
|
||||
inherit MyWebLogModule()
|
||||
/// Routes dealing with posts (including the home page)
|
||||
type PostModule(conn : IConnection, clock : IClock) as this =
|
||||
inherit NancyModule()
|
||||
|
||||
let getPage (parms : obj) = ((parms :?> DynamicDictionary).["page"] :?> int)
|
||||
|
||||
do
|
||||
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/page/{page:int"] <- fun parms -> upcast this.PostList
|
||||
((parms :?> DynamicDictionary).["page"] :?> int)
|
||||
this.Get .["/posts/list/page/{page:int}"] <- fun parms -> upcast this.PostList (getPage parms)
|
||||
this.Get .["/post/{postId}/edit" ] <- fun parms -> upcast this.EditPost (downcast parms)
|
||||
this.Post.["/post/{postId}/edit" ] <- fun parms -> upcast this.SavePost (downcast parms)
|
||||
|
||||
// ---- Display posts to users ----
|
||||
|
||||
/// Display a page of published posts
|
||||
member private this.DisplayPageOfPublishedPosts pageNbr =
|
||||
member this.DisplayPageOfPublishedPosts pageNbr =
|
||||
let model = PostsModel(this.Context, this.WebLog)
|
||||
model.pageNbr <- pageNbr
|
||||
model.posts <- findPageOfPublishedPosts conn this.WebLog.id pageNbr 10
|
||||
@ -35,22 +43,18 @@ type PostModule(conn : IConnection) as this =
|
||||
model.urlPrefix <- "/posts"
|
||||
model.pageTitle <- match pageNbr with
|
||||
| 1 -> ""
|
||||
| _ -> sprintf "Page #%i" pageNbr
|
||||
this.ThemedRender "posts" model
|
||||
| _ -> sprintf "%s%i" Resources.PageHash pageNbr
|
||||
this.ThemedView "posts" model
|
||||
|
||||
/// Display either the newest posts or the configured home page
|
||||
member this.HomePage () =
|
||||
match this.WebLog.defaultPage with
|
||||
| "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)
|
||||
model.pageTitle <- page.title
|
||||
this.ThemedRender "page" model
|
||||
| None -> this.Negotiate.WithStatusCode 404
|
||||
|
||||
/// 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)
|
||||
this.ThemedView "page" model
|
||||
| None -> this.NotFound ()
|
||||
|
||||
// ---- Administer posts ----
|
||||
|
||||
@ -61,7 +65,78 @@ type PostModule(conn : IConnection) as this =
|
||||
model.pageNbr <- pageNbr
|
||||
model.posts <- findPageOfAllPosts conn this.WebLog.id pageNbr 25
|
||||
model.hasNewer <- pageNbr > 1
|
||||
model.hasOlder <- 25 > List.length model.posts
|
||||
model.hasOlder <- List.length model.posts < 25
|
||||
model.urlPrefix <- "/post/list"
|
||||
model.pageTitle <- "Posts"
|
||||
model.pageTitle <- Resources.Posts
|
||||
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) " ")
|
||||
(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 ()
|
||||
|
@ -3,6 +3,9 @@
|
||||
open myWebLog.Entities
|
||||
open Nancy
|
||||
open Nancy.Session.Persistable
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
|
||||
/// Levels for a user message
|
||||
module Level =
|
||||
@ -57,6 +60,23 @@ 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
|
||||
|
||||
/// 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
|
||||
type PostsModel(ctx, webLog) =
|
||||
@ -93,3 +113,52 @@ type PageModel(ctx, webLog, page) =
|
||||
|
||||
/// The page to be displayed
|
||||
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
|
||||
|
@ -53,7 +53,7 @@
|
||||
<Compile Include="AssemblyInfo.fs" />
|
||||
<Compile Include="Keys.fs" />
|
||||
<Compile Include="ViewModels.fs" />
|
||||
<Compile Include="MyWebLogModule.fs" />
|
||||
<Compile Include="ModuleExtensions.fs" />
|
||||
<Compile Include="PostModule.fs" />
|
||||
<Compile Include="App.fs" />
|
||||
<Content Include="packages.config" />
|
||||
@ -67,10 +67,42 @@
|
||||
<HintPath>..\packages\Common.Logging.Core.3.3.1\lib\net40\Common.Logging.Core.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
</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">
|
||||
<HintPath>..\packages\FSharp.Core.4.0.0.1\lib\net40\FSharp.Core.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
</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="Nancy">
|
||||
<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>
|
||||
<Private>True</Private>
|
||||
</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">
|
||||
<HintPath>..\packages\RethinkDb.Driver.2.3.8\lib\net45\RethinkDb.Driver.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
@ -101,8 +141,14 @@
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||
<Reference Include="System.Core" />
|
||||
<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>
|
||||
<ProjectReference Include="..\myWebLog.Data\myWebLog.Data.fsproj">
|
||||
|
@ -2,12 +2,16 @@
|
||||
<packages>
|
||||
<package id="Common.Logging" 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.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.Authentication.Forms" version="1.4.1" 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="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="Suave" version="1.1.3" targetFramework="net452" />
|
||||
</packages>
|
10
src/myWebLog/content/scripts/tinymce-init.js
Normal file
10
src/myWebLog/content/scripts/tinymce-init.js
Normal 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"
|
||||
})
|
@ -66,8 +66,10 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<ItemGroup>
|
||||
<Content Include="content\scripts\tinymce-init.js" />
|
||||
<Content Include="content\styles\admin.css" />
|
||||
<Content Include="views\admin\admin-layout.html" />
|
||||
<Content Include="views\admin\post\edit.html" />
|
||||
<Content Include="views\admin\post\list.html" />
|
||||
<Content Include="views\default\index-content.html" />
|
||||
<Content Include="views\default\index.html" />
|
||||
|
95
src/myWebLog/views/admin/post/edit.html
Normal file
95
src/myWebLog/views/admin/post/edit.html
Normal 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
|
||||
|
|
||||
- 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" />
|
||||
|
||||
<!-- // 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" />
|
||||
<label for="publishNow">@Translate.PublishThisPost</label>
|
||||
</div>
|
||||
@EndIf
|
||||
<p>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-floppy-o"></i> @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
|
@ -8,7 +8,7 @@
|
||||
<div class="col-xs-12">
|
||||
<article>
|
||||
<h1>
|
||||
<a href="/@Current.permalink" title="Permanent Link to "@Current.title@quot;">@Current.title</a>
|
||||
<a href="/@Current.permalink" title="@Translate.PermanentLinkTo "@Current.title@quot;">@Current.title</a>
|
||||
</h1>
|
||||
<!-- var pubDate = moment(post.publishedDate) -->
|
||||
<p>
|
||||
|
Loading…
Reference in New Issue
Block a user