V2 #1
|
@ -1,155 +0,0 @@
|
||||||
module MyWebLog.App
|
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Data.RethinkDB
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Logic.WebLog
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open Nancy.Authentication.Forms
|
|
||||||
open Nancy.Bootstrapper
|
|
||||||
open Nancy.Conventions
|
|
||||||
open Nancy.Cryptography
|
|
||||||
open Nancy.Owin
|
|
||||||
open Nancy.Security
|
|
||||||
open Nancy.Session.Persistable
|
|
||||||
//open Nancy.Session.Relational
|
|
||||||
open Nancy.Session.RethinkDB
|
|
||||||
open Nancy.TinyIoc
|
|
||||||
open Nancy.ViewEngines.SuperSimpleViewEngine
|
|
||||||
open NodaTime
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open Suave
|
|
||||||
open Suave.Owin
|
|
||||||
open System
|
|
||||||
open System.IO
|
|
||||||
open System.Reflection
|
|
||||||
open System.Security.Claims
|
|
||||||
open System.Text.RegularExpressions
|
|
||||||
|
|
||||||
/// Establish the configuration for this instance
|
|
||||||
let cfg = try AppConfig.FromJson (System.IO.File.ReadAllText "config.json")
|
|
||||||
with ex -> raise <| Exception (Strings.get "ErrBadAppConfig", ex)
|
|
||||||
|
|
||||||
let data = lazy (RethinkMyWebLogData (cfg.DataConfig.Conn, cfg.DataConfig) :> IMyWebLogData)
|
|
||||||
|
|
||||||
/// Support RESX lookup via the @Translate SSVE alias
|
|
||||||
type TranslateTokenViewEngineMatcher() =
|
|
||||||
static let regex = Regex ("@Translate\.(?<TranslationKey>[a-zA-Z0-9-_]+);?", RegexOptions.Compiled)
|
|
||||||
interface ISuperSimpleViewEngineMatcher with
|
|
||||||
member this.Invoke (content, model, host) =
|
|
||||||
let translate (m : Match) = Strings.get m.Groups.["TranslationKey"].Value
|
|
||||||
regex.Replace(content, translate)
|
|
||||||
|
|
||||||
|
|
||||||
/// Handle forms authentication
|
|
||||||
type MyWebLogUser (claims : Claim seq) =
|
|
||||||
inherit ClaimsPrincipal (ClaimsIdentity (claims, "forms"))
|
|
||||||
|
|
||||||
new (user : User) =
|
|
||||||
// TODO: refactor the User.Claims property to produce this, and just pass it as the constructor
|
|
||||||
let claims =
|
|
||||||
seq {
|
|
||||||
yield Claim (ClaimTypes.Name, user.PreferredName)
|
|
||||||
for claim in user.Claims -> Claim (ClaimTypes.Role, claim)
|
|
||||||
}
|
|
||||||
MyWebLogUser claims
|
|
||||||
|
|
||||||
type MyWebLogUserMapper (container : TinyIoCContainer) =
|
|
||||||
|
|
||||||
interface IUserMapper with
|
|
||||||
member this.GetUserFromIdentifier (identifier, context) =
|
|
||||||
match context.Request.PersistableSession.GetOrDefault (Keys.User, User.Empty) with
|
|
||||||
| user when user.Id = string identifier -> upcast MyWebLogUser user
|
|
||||||
| _ -> null
|
|
||||||
|
|
||||||
|
|
||||||
/// Set up the application environment
|
|
||||||
type MyWebLogBootstrapper() =
|
|
||||||
inherit DefaultNancyBootstrapper()
|
|
||||||
|
|
||||||
override this.ConfigureRequestContainer (container, context) =
|
|
||||||
base.ConfigureRequestContainer (container, context)
|
|
||||||
/// User mapper for forms authentication
|
|
||||||
container.Register<IUserMapper, MyWebLogUserMapper>()
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
override this.ConfigureConventions (conventions) =
|
|
||||||
base.ConfigureConventions conventions
|
|
||||||
conventions.StaticContentsConventions.Add
|
|
||||||
(StaticContentConventionBuilder.AddDirectory ("admin/content", "views/admin/content"))
|
|
||||||
// Make theme content available at [theme-name]/
|
|
||||||
Directory.EnumerateDirectories (Path.Combine [| "views"; "themes" |])
|
|
||||||
|> Seq.map (fun themeDir -> themeDir, Path.Combine [| themeDir; "content" |])
|
|
||||||
|> Seq.filter (fun (_, contentDir) -> Directory.Exists contentDir)
|
|
||||||
|> Seq.iter (fun (themeDir, contentDir) ->
|
|
||||||
conventions.StaticContentsConventions.Add
|
|
||||||
(StaticContentConventionBuilder.AddDirectory ((Path.GetFileName themeDir), contentDir)))
|
|
||||||
|
|
||||||
override this.ConfigureApplicationContainer (container) =
|
|
||||||
base.ConfigureApplicationContainer container
|
|
||||||
container.Register<AppConfig> cfg
|
|
||||||
|> ignore
|
|
||||||
data.Force().SetUp ()
|
|
||||||
container.Register<IMyWebLogData> (data.Force ())
|
|
||||||
|> ignore
|
|
||||||
// NodaTime
|
|
||||||
container.Register<IClock> SystemClock.Instance
|
|
||||||
|> ignore
|
|
||||||
// I18N in SSVE
|
|
||||||
container.Register<ISuperSimpleViewEngineMatcher seq> (fun _ _ ->
|
|
||||||
Seq.singleton (TranslateTokenViewEngineMatcher () :> ISuperSimpleViewEngineMatcher))
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
override this.ApplicationStartup (container, pipelines) =
|
|
||||||
base.ApplicationStartup (container, pipelines)
|
|
||||||
// Forms authentication configuration
|
|
||||||
let auth =
|
|
||||||
FormsAuthenticationConfiguration (
|
|
||||||
CryptographyConfiguration =
|
|
||||||
CryptographyConfiguration (
|
|
||||||
AesEncryptionProvider (PassphraseKeyGenerator (cfg.AuthEncryptionPassphrase, cfg.AuthSalt)),
|
|
||||||
DefaultHmacProvider (PassphraseKeyGenerator (cfg.AuthHmacPassphrase, cfg.AuthSalt))),
|
|
||||||
RedirectUrl = "~/user/log-on",
|
|
||||||
UserMapper = container.Resolve<IUserMapper> ())
|
|
||||||
FormsAuthentication.Enable (pipelines, auth)
|
|
||||||
// CSRF
|
|
||||||
Csrf.Enable pipelines
|
|
||||||
// Sessions
|
|
||||||
let sessions = RethinkDBSessionConfiguration cfg.DataConfig.Conn
|
|
||||||
sessions.Database <- cfg.DataConfig.Database
|
|
||||||
//let sessions = RelationalSessionConfiguration(ConfigurationManager.ConnectionStrings.["SessionStore"].ConnectionString)
|
|
||||||
PersistableSessions.Enable (pipelines, sessions)
|
|
||||||
()
|
|
||||||
|
|
||||||
override this.Configure (environment) =
|
|
||||||
base.Configure environment
|
|
||||||
environment.Tracing (true, true)
|
|
||||||
|
|
||||||
|
|
||||||
let version =
|
|
||||||
let v = typeof<AppConfig>.GetTypeInfo().Assembly.GetName().Version
|
|
||||||
match v.Build with
|
|
||||||
| 0 -> match v.Minor with 0 -> string v.Major | _ -> sprintf "%d.%d" v.Major v.Minor
|
|
||||||
| _ -> sprintf "%d.%d.%d" v.Major v.Minor v.Build
|
|
||||||
|> sprintf "v%s"
|
|
||||||
|
|
||||||
/// Set up the request environment
|
|
||||||
type RequestEnvironment() =
|
|
||||||
interface IRequestStartup with
|
|
||||||
member this.Initialize (pipelines, context) =
|
|
||||||
let establishEnv (ctx : NancyContext) =
|
|
||||||
ctx.Items.[Keys.RequestStart] <- DateTime.Now.Ticks
|
|
||||||
match tryFindWebLogByUrlBase (data.Force ()) ctx.Request.Url.HostName with
|
|
||||||
| Some webLog -> ctx.Items.[Keys.WebLog] <- webLog
|
|
||||||
| None -> // TODO: redirect to domain set up page
|
|
||||||
Exception (sprintf "%s %s" ctx.Request.Url.HostName (Strings.get "ErrNotConfigured"))
|
|
||||||
|> raise
|
|
||||||
ctx.Items.[Keys.Version] <- version
|
|
||||||
null
|
|
||||||
pipelines.BeforeRequest.AddItemToStartOfPipeline establishEnv
|
|
||||||
|
|
||||||
let Run () =
|
|
||||||
OwinApp.ofMidFunc "/" (NancyMiddleware.UseNancy (NancyOptions (Bootstrapper = new MyWebLogBootstrapper ())))
|
|
||||||
|> startWebServer defaultConfig
|
|
|
@ -1,33 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data.RethinkDB
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open System.Text
|
|
||||||
|
|
||||||
/// Configuration for this myWebLog instance
|
|
||||||
type AppConfig =
|
|
||||||
{ /// The text from which to derive salt to use for passwords
|
|
||||||
[<JsonProperty("password-salt")>]
|
|
||||||
PasswordSaltString : string
|
|
||||||
/// The text from which to derive salt to use for forms authentication
|
|
||||||
[<JsonProperty("auth-salt")>]
|
|
||||||
AuthSaltString : string
|
|
||||||
/// The encryption passphrase to use for forms authentication
|
|
||||||
[<JsonProperty("encryption-passphrase")>]
|
|
||||||
AuthEncryptionPassphrase : string
|
|
||||||
/// The HMAC passphrase to use for forms authentication
|
|
||||||
[<JsonProperty("hmac-passphrase")>]
|
|
||||||
AuthHmacPassphrase : string
|
|
||||||
/// The data configuration
|
|
||||||
[<JsonProperty("data")>]
|
|
||||||
DataConfig : DataConfig }
|
|
||||||
with
|
|
||||||
/// The salt to use for passwords
|
|
||||||
member this.PasswordSalt = Encoding.UTF8.GetBytes this.PasswordSaltString
|
|
||||||
/// The salt to use for forms authentication
|
|
||||||
member this.AuthSalt = Encoding.UTF8.GetBytes this.AuthSaltString
|
|
||||||
|
|
||||||
/// Deserialize the configuration from the JSON file
|
|
||||||
static member FromJson json =
|
|
||||||
let cfg = JsonConvert.DeserializeObject<AppConfig> json
|
|
||||||
{ cfg with DataConfig = DataConfig.Connect cfg.DataConfig }
|
|
|
@ -1,21 +0,0 @@
|
||||||
namespace MyWebLog.AssemblyInfo
|
|
||||||
|
|
||||||
open System.Reflection
|
|
||||||
open System.Runtime.CompilerServices
|
|
||||||
open System.Runtime.InteropServices
|
|
||||||
|
|
||||||
[<assembly: AssemblyTitle("MyWebLog.Web")>]
|
|
||||||
[<assembly: AssemblyDescription("Main Nancy assembly for myWebLog")>]
|
|
||||||
[<assembly: AssemblyConfiguration("")>]
|
|
||||||
[<assembly: AssemblyCompany("DJS Consulting")>]
|
|
||||||
[<assembly: AssemblyProduct("MyWebLog.Web")>]
|
|
||||||
[<assembly: AssemblyCopyright("Copyright © 2016")>]
|
|
||||||
[<assembly: AssemblyTrademark("")>]
|
|
||||||
[<assembly: AssemblyCulture("")>]
|
|
||||||
[<assembly: ComVisible(false)>]
|
|
||||||
[<assembly: Guid("e6ee110a-27a6-4a19-b0cb-d24f48f71b53")>]
|
|
||||||
[<assembly: AssemblyVersion("0.9.2.0")>]
|
|
||||||
[<assembly: AssemblyFileVersion("1.0.0.0")>]
|
|
||||||
|
|
||||||
do
|
|
||||||
()
|
|
|
@ -1,140 +0,0 @@
|
||||||
module MyWebLog.Data.RethinkDB.Category
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
let private r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
|
|
||||||
/// Get all categories for a web log
|
|
||||||
let getAllCategories conn (webLogId : string) =
|
|
||||||
async {
|
|
||||||
return! r.Table(Table.Category)
|
|
||||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
|
||||||
.OrderBy("Name")
|
|
||||||
.RunResultAsync<Category list> conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Get a specific category by its Id
|
|
||||||
let tryFindCategory conn webLogId catId : Category option =
|
|
||||||
async {
|
|
||||||
let! c =
|
|
||||||
r.Table(Table.Category)
|
|
||||||
.Get(catId)
|
|
||||||
.RunResultAsync<Category> conn
|
|
||||||
return
|
|
||||||
match box c with
|
|
||||||
| null -> None
|
|
||||||
| catt ->
|
|
||||||
let cat : Category = unbox catt
|
|
||||||
match cat.WebLogId = webLogId with true -> Some cat | _ -> None
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Add a category
|
|
||||||
let addCategory conn (cat : Category) =
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Category)
|
|
||||||
.Insert(cat)
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
type CategoryUpdateRecord =
|
|
||||||
{ Name : string
|
|
||||||
Slug : string
|
|
||||||
Description : string option
|
|
||||||
ParentId : string option
|
|
||||||
}
|
|
||||||
/// Update a category
|
|
||||||
let updateCategory conn (cat : Category) =
|
|
||||||
match tryFindCategory conn cat.WebLogId cat.Id with
|
|
||||||
| Some _ ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Category)
|
|
||||||
.Get(cat.Id)
|
|
||||||
.Update(
|
|
||||||
{ CategoryUpdateRecord.Name = cat.Name
|
|
||||||
Slug = cat.Slug
|
|
||||||
Description = cat.Description
|
|
||||||
ParentId = cat.ParentId
|
|
||||||
})
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
| _ -> ()
|
|
||||||
|
|
||||||
/// Update a category's children
|
|
||||||
let updateChildren conn webLogId parentId (children : string list) =
|
|
||||||
match tryFindCategory conn webLogId parentId with
|
|
||||||
| Some _ ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Category)
|
|
||||||
.Get(parentId)
|
|
||||||
.Update(dict [ "Children", children ])
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
| _ -> ()
|
|
||||||
|
|
||||||
/// Delete a category
|
|
||||||
let deleteCategory conn (cat : Category) =
|
|
||||||
async {
|
|
||||||
// Remove the category from its parent
|
|
||||||
match cat.ParentId with
|
|
||||||
| Some parentId ->
|
|
||||||
match tryFindCategory conn cat.WebLogId parentId with
|
|
||||||
| Some parent -> parent.Children
|
|
||||||
|> List.filter (fun childId -> childId <> cat.Id)
|
|
||||||
|> updateChildren conn cat.WebLogId parentId
|
|
||||||
| _ -> ()
|
|
||||||
| _ -> ()
|
|
||||||
// Move this category's children to its parent
|
|
||||||
cat.Children
|
|
||||||
|> List.map (fun childId ->
|
|
||||||
match tryFindCategory conn cat.WebLogId childId with
|
|
||||||
| Some _ ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Category)
|
|
||||||
.Get(childId)
|
|
||||||
.Update(dict [ "ParentId", cat.ParentId ])
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Some
|
|
||||||
| _ -> None)
|
|
||||||
|> List.filter Option.isSome
|
|
||||||
|> List.map Option.get
|
|
||||||
|> List.iter Async.RunSynchronously
|
|
||||||
// Remove the category from posts where it is assigned
|
|
||||||
let! posts =
|
|
||||||
r.Table(Table.Post)
|
|
||||||
.GetAll(cat.WebLogId).OptArg("index", "WebLogId")
|
|
||||||
.Filter(ReqlFunction1 (fun p -> upcast p.["CategoryIds"].Contains cat.Id))
|
|
||||||
.RunResultAsync<Post list> conn
|
|
||||||
|> Async.AwaitTask
|
|
||||||
posts
|
|
||||||
|> List.map (fun post ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Post)
|
|
||||||
.Get(post.Id)
|
|
||||||
.Update(dict [ "CategoryIds", post.CategoryIds |> List.filter (fun c -> c <> cat.Id) ])
|
|
||||||
.RunResultAsync conn
|
|
||||||
})
|
|
||||||
|> List.iter Async.RunSynchronously
|
|
||||||
// Now, delete the category
|
|
||||||
do! r.Table(Table.Category)
|
|
||||||
.Get(cat.Id)
|
|
||||||
.Delete()
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Get a category by its slug
|
|
||||||
let tryFindCategoryBySlug conn (webLogId : string) (slug : string) =
|
|
||||||
async {
|
|
||||||
let! cat = r.Table(Table.Category)
|
|
||||||
.GetAll(r.Array (webLogId, slug)).OptArg("index", "Slug")
|
|
||||||
.RunResultAsync<Category list> conn
|
|
||||||
return cat |> List.tryHead
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
|
@ -1,43 +0,0 @@
|
||||||
namespace MyWebLog.Data.RethinkDB
|
|
||||||
|
|
||||||
open RethinkDb.Driver
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open Newtonsoft.Json
|
|
||||||
|
|
||||||
/// Data configuration
|
|
||||||
type DataConfig =
|
|
||||||
{ /// The hostname for the RethinkDB server
|
|
||||||
[<JsonProperty("hostname")>]
|
|
||||||
Hostname : string
|
|
||||||
/// The port for the RethinkDB server
|
|
||||||
[<JsonProperty("port")>]
|
|
||||||
Port : int
|
|
||||||
/// The authorization key to use when connecting to the server
|
|
||||||
[<JsonProperty("authKey")>]
|
|
||||||
AuthKey : string
|
|
||||||
/// How long an attempt to connect to the server should wait before giving up
|
|
||||||
[<JsonProperty("timeout")>]
|
|
||||||
Timeout : int
|
|
||||||
/// The name of the default database to use on the connection
|
|
||||||
[<JsonProperty("database")>]
|
|
||||||
Database : string
|
|
||||||
/// A connection to the RethinkDB server using the configuration in this object
|
|
||||||
[<JsonIgnore>]
|
|
||||||
Conn : IConnection }
|
|
||||||
with
|
|
||||||
/// Use RethinkDB defaults for non-provided options, and connect to the server
|
|
||||||
static member Connect config =
|
|
||||||
let host cfg = match cfg.Hostname with null -> { cfg with Hostname = RethinkDBConstants.DefaultHostname } | _ -> cfg
|
|
||||||
let port cfg = match cfg.Port with 0 -> { cfg with Port = RethinkDBConstants.DefaultPort } | _ -> cfg
|
|
||||||
let auth cfg = match cfg.AuthKey with null -> { cfg with AuthKey = RethinkDBConstants.DefaultAuthkey } | _ -> cfg
|
|
||||||
let timeout cfg = match cfg.Timeout with 0 -> { cfg with Timeout = RethinkDBConstants.DefaultTimeout } | _ -> cfg
|
|
||||||
let db cfg = match cfg.Database with null -> { cfg with Database = RethinkDBConstants.DefaultDbName } | _ -> cfg
|
|
||||||
let connect cfg =
|
|
||||||
{ cfg with Conn = RethinkDB.R.Connection()
|
|
||||||
.Hostname(cfg.Hostname)
|
|
||||||
.Port(cfg.Port)
|
|
||||||
.AuthKey(cfg.AuthKey)
|
|
||||||
.Db(cfg.Database)
|
|
||||||
.Timeout(cfg.Timeout)
|
|
||||||
.Connect () }
|
|
||||||
(host >> port >> auth >> timeout >> db >> connect) config
|
|
|
@ -1,16 +0,0 @@
|
||||||
[<AutoOpen>]
|
|
||||||
module MyWebLog.Data.RethinkDB.Extensions
|
|
||||||
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
// H/T: Suave
|
|
||||||
type AsyncBuilder with
|
|
||||||
/// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on
|
|
||||||
/// a standard .NET task
|
|
||||||
member x.Bind(t : Task<'T>, f:'T -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f)
|
|
||||||
|
|
||||||
/// An extension method that overloads the standard 'Bind' of the 'async' builder. The new overload awaits on
|
|
||||||
/// a standard .NET task which does not commpute a value
|
|
||||||
member x.Bind(t : Task, f : unit -> Async<'R>) : Async<'R> = async.Bind(Async.AwaitTask t, f)
|
|
||||||
|
|
||||||
member x.ReturnFrom(t : Task<'T>) = Async.AwaitTask t
|
|
|
@ -1,98 +0,0 @@
|
||||||
module MyWebLog.Data.RethinkDB.Page
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
let private r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
|
|
||||||
/// Try to find a page by its Id, optionally including revisions
|
|
||||||
let tryFindPageById conn webLogId (pageId : string) includeRevs =
|
|
||||||
async {
|
|
||||||
let q =
|
|
||||||
r.Table(Table.Page)
|
|
||||||
.Get pageId
|
|
||||||
let! thePage =
|
|
||||||
match includeRevs with
|
|
||||||
| true -> q.RunResultAsync<Page> conn
|
|
||||||
| _ -> q.Without("Revisions").RunResultAsync<Page> conn
|
|
||||||
return
|
|
||||||
match box thePage with
|
|
||||||
| null -> None
|
|
||||||
| page ->
|
|
||||||
let pg : Page = unbox page
|
|
||||||
match pg.WebLogId = webLogId with true -> Some pg | _ -> None
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Find a page by its permalink
|
|
||||||
let tryFindPageByPermalink conn (webLogId : string) (permalink : string) =
|
|
||||||
async {
|
|
||||||
let! pg =
|
|
||||||
r.Table(Table.Page)
|
|
||||||
.GetAll(r.Array (webLogId, permalink)).OptArg("index", "Permalink")
|
|
||||||
.Without("Revisions")
|
|
||||||
.RunResultAsync<Page list> conn
|
|
||||||
return List.tryHead pg
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Get a list of all pages (excludes page text and revisions)
|
|
||||||
let findAllPages conn (webLogId : string) =
|
|
||||||
async {
|
|
||||||
return!
|
|
||||||
r.Table(Table.Page)
|
|
||||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
|
||||||
.OrderBy("Title")
|
|
||||||
.Without("Text", "Revisions")
|
|
||||||
.RunResultAsync<Page list> conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Add a page
|
|
||||||
let addPage conn (page : Page) =
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Page)
|
|
||||||
.Insert(page)
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> (Async.RunSynchronously >> ignore)
|
|
||||||
|
|
||||||
type PageUpdateRecord =
|
|
||||||
{ Title : string
|
|
||||||
Permalink : string
|
|
||||||
PublishedOn : int64
|
|
||||||
UpdatedOn : int64
|
|
||||||
ShowInPageList : bool
|
|
||||||
Text : string
|
|
||||||
Revisions : Revision list }
|
|
||||||
/// Update a page
|
|
||||||
let updatePage conn (page : Page) =
|
|
||||||
match tryFindPageById conn page.WebLogId page.Id false with
|
|
||||||
| Some _ ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Page)
|
|
||||||
.Get(page.Id)
|
|
||||||
.Update({ PageUpdateRecord.Title = page.Title
|
|
||||||
Permalink = page.Permalink
|
|
||||||
PublishedOn = page.PublishedOn
|
|
||||||
UpdatedOn = page.UpdatedOn
|
|
||||||
ShowInPageList = page.ShowInPageList
|
|
||||||
Text = page.Text
|
|
||||||
Revisions = page.Revisions })
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> (Async.RunSynchronously >> ignore)
|
|
||||||
| _ -> ()
|
|
||||||
|
|
||||||
/// Delete a page
|
|
||||||
let deletePage conn webLogId pageId =
|
|
||||||
match tryFindPageById conn webLogId pageId false with
|
|
||||||
| Some _ ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Page)
|
|
||||||
.Get(pageId)
|
|
||||||
.Delete()
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> (Async.RunSynchronously >> ignore)
|
|
||||||
| _ -> ()
|
|
|
@ -1,225 +0,0 @@
|
||||||
module MyWebLog.Data.RethinkDB.Post
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
let private r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
|
|
||||||
/// Shorthand to select all published posts for a web log
|
|
||||||
let private publishedPosts (webLogId : string) =
|
|
||||||
r.Table(Table.Post)
|
|
||||||
.GetAll(r.Array (webLogId, PostStatus.Published)).OptArg("index", "WebLogAndStatus")
|
|
||||||
.Without("Revisions")
|
|
||||||
// This allows us to count comments without retrieving them all
|
|
||||||
.Merge(ReqlFunction1 (fun p ->
|
|
||||||
upcast r.HashMap(
|
|
||||||
"Comments", r.Table(Table.Comment)
|
|
||||||
.GetAll(p.["id"]).OptArg("index", "PostId")
|
|
||||||
.Pluck("id")
|
|
||||||
.CoerceTo("array"))))
|
|
||||||
|
|
||||||
|
|
||||||
/// Shorthand to sort posts by published date, slice for the given page, and return a list
|
|
||||||
let private toPostList conn pageNbr nbrPerPage (filter : ReqlExpr) =
|
|
||||||
async {
|
|
||||||
return!
|
|
||||||
filter
|
|
||||||
.OrderBy(r.Desc "PublishedOn")
|
|
||||||
.Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage)
|
|
||||||
.RunResultAsync<Post list> conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Shorthand to get a newer or older post
|
|
||||||
let private adjacentPost conn (post : Post) (theFilter : ReqlExpr -> obj) (sort : obj) =
|
|
||||||
async {
|
|
||||||
let! post =
|
|
||||||
(publishedPosts post.WebLogId)
|
|
||||||
.Filter(theFilter)
|
|
||||||
.OrderBy(sort)
|
|
||||||
.Limit(1)
|
|
||||||
.RunResultAsync<Post list> conn
|
|
||||||
return List.tryHead post
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Find a newer post
|
|
||||||
let private newerPost conn post theFilter = adjacentPost conn post theFilter <| r.Asc "PublishedOn"
|
|
||||||
|
|
||||||
/// Find an older post
|
|
||||||
let private olderPost conn post theFilter = adjacentPost conn post theFilter <| r.Desc "PublishedOn"
|
|
||||||
|
|
||||||
/// Get a page of published posts
|
|
||||||
let findPageOfPublishedPosts conn webLogId pageNbr nbrPerPage =
|
|
||||||
publishedPosts webLogId
|
|
||||||
|> toPostList conn pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Get a page of published posts assigned to a given category
|
|
||||||
let findPageOfCategorizedPosts conn webLogId (categoryId : string) pageNbr nbrPerPage =
|
|
||||||
(publishedPosts webLogId)
|
|
||||||
.Filter(ReqlFunction1 (fun p -> upcast p.["CategoryIds"].Contains categoryId))
|
|
||||||
|> toPostList conn pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Get a page of published posts tagged with a given tag
|
|
||||||
let findPageOfTaggedPosts conn webLogId (tag : string) pageNbr nbrPerPage =
|
|
||||||
(publishedPosts webLogId)
|
|
||||||
.Filter(ReqlFunction1 (fun p -> upcast p.["Tags"].Contains tag))
|
|
||||||
|> toPostList conn pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Try to get the next newest post from the given post
|
|
||||||
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 -> 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 -> 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 -> 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 -> 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 -> 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
|
|
||||||
async {
|
|
||||||
// .orderBy(r.desc(r.branch(r.row("Status").eq("Published"), r.row("PublishedOn"), r.row("UpdatedOn"))))
|
|
||||||
return!
|
|
||||||
r.Table(Table.Post)
|
|
||||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
|
||||||
.OrderBy(r.Desc (ReqlFunction1 (fun p ->
|
|
||||||
upcast r.Branch (p.["Status"].Eq("Published"), p.["PublishedOn"], p.["UpdatedOn"]))))
|
|
||||||
.Slice((pageNbr - 1) * nbrPerPage, pageNbr * nbrPerPage)
|
|
||||||
.RunResultAsync<Post list> conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Try to find a post by its Id and web log Id
|
|
||||||
let tryFindPost conn webLogId postId : Post option =
|
|
||||||
async {
|
|
||||||
let! p =
|
|
||||||
r.Table(Table.Post)
|
|
||||||
.Get(postId)
|
|
||||||
.RunAtomAsync<Post> conn
|
|
||||||
return
|
|
||||||
match box p with
|
|
||||||
| null -> None
|
|
||||||
| pst ->
|
|
||||||
let post : Post = unbox pst
|
|
||||||
match post.WebLogId = webLogId with true -> Some post | _ -> None
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Try to find a post by its permalink
|
|
||||||
let tryFindPostByPermalink conn webLogId permalink =
|
|
||||||
async {
|
|
||||||
let! post =
|
|
||||||
r.Table(Table.Post)
|
|
||||||
.GetAll(r.Array (webLogId, permalink)).OptArg("index", "Permalink")
|
|
||||||
.Filter(ReqlFunction1 (fun p -> upcast p.["Status"].Eq PostStatus.Published))
|
|
||||||
.Without("Revisions")
|
|
||||||
.Merge(ReqlFunction1 (fun p ->
|
|
||||||
upcast r.HashMap(
|
|
||||||
"Categories", r.Table(Table.Category)
|
|
||||||
.GetAll(r.Args p.["CategoryIds"])
|
|
||||||
.Without("Children")
|
|
||||||
.OrderBy("Name")
|
|
||||||
.CoerceTo("array")).With(
|
|
||||||
"Comments", r.Table(Table.Comment)
|
|
||||||
.GetAll(p.["id"]).OptArg("index", "PostId")
|
|
||||||
.OrderBy("PostedOn")
|
|
||||||
.CoerceTo("array"))))
|
|
||||||
.RunResultAsync<Post list> conn
|
|
||||||
return List.tryHead post
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Try to find a post by its prior permalink
|
|
||||||
let tryFindPostByPriorPermalink conn (webLogId : string) (permalink : string) =
|
|
||||||
async {
|
|
||||||
let! post =
|
|
||||||
r.Table(Table.Post)
|
|
||||||
.GetAll(webLogId).OptArg("index", "WebLogId")
|
|
||||||
.Filter(ReqlFunction1 (fun p ->
|
|
||||||
upcast p.["PriorPermalinks"].Contains(permalink).And(p.["Status"].Eq PostStatus.Published)))
|
|
||||||
.Without("Revisions")
|
|
||||||
.RunResultAsync<Post list> conn
|
|
||||||
return List.tryHead post
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Get a set of posts for RSS
|
|
||||||
let findFeedPosts conn webLogId nbr : (Post * User option) list =
|
|
||||||
let tryFindUser userId =
|
|
||||||
async {
|
|
||||||
let! u =
|
|
||||||
r.Table(Table.User)
|
|
||||||
.Get(userId)
|
|
||||||
.RunAtomAsync<User> conn
|
|
||||||
return match box u with null -> None | user -> Some <| unbox user
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
(publishedPosts webLogId)
|
|
||||||
.Merge(ReqlFunction1 (fun post ->
|
|
||||||
upcast r.HashMap(
|
|
||||||
"Categories", r.Table(Table.Category)
|
|
||||||
.GetAll(r.Args post.["CategoryIds"])
|
|
||||||
.OrderBy("Name")
|
|
||||||
.Pluck("id", "Name")
|
|
||||||
.CoerceTo("array"))))
|
|
||||||
|> toPostList conn 1 nbr
|
|
||||||
|> List.map (fun post -> post, tryFindUser post.AuthorId)
|
|
||||||
|
|
||||||
/// Add a post
|
|
||||||
let addPost conn post =
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Post)
|
|
||||||
.Insert(post)
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> (Async.RunSynchronously >> ignore)
|
|
||||||
|
|
||||||
/// Update a post
|
|
||||||
let updatePost conn (post : Post) =
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Post)
|
|
||||||
.Get(post.Id)
|
|
||||||
.Replace( { post with Categories = []
|
|
||||||
Comments = [] } )
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> (Async.RunSynchronously >> ignore)
|
|
||||||
|
|
||||||
/// Save a post
|
|
||||||
let savePost conn (post : Post) =
|
|
||||||
match post.Id with
|
|
||||||
| "new" ->
|
|
||||||
let newPost = { post with Id = string <| System.Guid.NewGuid() }
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Post)
|
|
||||||
.Insert(newPost)
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
newPost.Id
|
|
||||||
| _ ->
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.Post)
|
|
||||||
.Get(post.Id)
|
|
||||||
.Replace( { post with Categories = []
|
|
||||||
Comments = [] } )
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
post.Id
|
|
|
@ -1,48 +0,0 @@
|
||||||
namespace MyWebLog.Data.RethinkDB
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
/// RethinkDB implementation of myWebLog data persistence
|
|
||||||
type RethinkMyWebLogData(conn : IConnection, cfg : DataConfig) =
|
|
||||||
interface IMyWebLogData with
|
|
||||||
member __.SetUp = fun () -> SetUp.startUpCheck cfg
|
|
||||||
|
|
||||||
member __.AllCategories = Category.getAllCategories conn
|
|
||||||
member __.CategoryById = Category.tryFindCategory conn
|
|
||||||
member __.CategoryBySlug = Category.tryFindCategoryBySlug conn
|
|
||||||
member __.AddCategory = Category.addCategory conn
|
|
||||||
member __.UpdateCategory = Category.updateCategory conn
|
|
||||||
member __.UpdateChildren = Category.updateChildren conn
|
|
||||||
member __.DeleteCategory = Category.deleteCategory conn
|
|
||||||
|
|
||||||
member __.PageById = Page.tryFindPageById conn
|
|
||||||
member __.PageByPermalink = Page.tryFindPageByPermalink conn
|
|
||||||
member __.AllPages = Page.findAllPages conn
|
|
||||||
member __.AddPage = Page.addPage conn
|
|
||||||
member __.UpdatePage = Page.updatePage conn
|
|
||||||
member __.DeletePage = Page.deletePage conn
|
|
||||||
|
|
||||||
member __.PageOfPublishedPosts = Post.findPageOfPublishedPosts conn
|
|
||||||
member __.PageOfCategorizedPosts = Post.findPageOfCategorizedPosts conn
|
|
||||||
member __.PageOfTaggedPosts = Post.findPageOfTaggedPosts conn
|
|
||||||
member __.NewerPost = Post.tryFindNewerPost conn
|
|
||||||
member __.NewerCategorizedPost = Post.tryFindNewerCategorizedPost conn
|
|
||||||
member __.NewerTaggedPost = Post.tryFindNewerTaggedPost conn
|
|
||||||
member __.OlderPost = Post.tryFindOlderPost conn
|
|
||||||
member __.OlderCategorizedPost = Post.tryFindOlderCategorizedPost conn
|
|
||||||
member __.OlderTaggedPost = Post.tryFindOlderTaggedPost conn
|
|
||||||
member __.PageOfAllPosts = Post.findPageOfAllPosts conn
|
|
||||||
member __.PostById = Post.tryFindPost conn
|
|
||||||
member __.PostByPermalink = Post.tryFindPostByPermalink conn
|
|
||||||
member __.PostByPriorPermalink = Post.tryFindPostByPriorPermalink conn
|
|
||||||
member __.FeedPosts = Post.findFeedPosts conn
|
|
||||||
member __.AddPost = Post.addPost conn
|
|
||||||
member __.UpdatePost = Post.updatePost conn
|
|
||||||
|
|
||||||
member __.LogOn = User.tryUserLogOn conn
|
|
||||||
member __.SetUserPassword = User.setUserPassword conn
|
|
||||||
|
|
||||||
member __.WebLogByUrlBase = WebLog.tryFindWebLogByUrlBase conn
|
|
||||||
member __.DashboardCounts = WebLog.findDashboardCounts conn
|
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
module MyWebLog.Data.RethinkDB.SetUp
|
|
||||||
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
open System
|
|
||||||
|
|
||||||
let private r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
let private logStep step = Console.Out.WriteLine (sprintf "[myWebLog] %s" step)
|
|
||||||
let private logStepStart text = Console.Out.Write (sprintf "[myWebLog] %s..." text)
|
|
||||||
let private logStepDone () = Console.Out.WriteLine (" done.")
|
|
||||||
|
|
||||||
/// Ensure the myWebLog database exists
|
|
||||||
let private checkDatabase (cfg : DataConfig) =
|
|
||||||
async {
|
|
||||||
logStep "|> Checking database"
|
|
||||||
let! dbs = r.DbList().RunResultAsync<string list> cfg.Conn
|
|
||||||
match List.contains cfg.Database dbs with
|
|
||||||
| true -> ()
|
|
||||||
| _ -> logStepStart (sprintf " %s database not found - creating" cfg.Database)
|
|
||||||
do! r.DbCreate(cfg.Database).RunResultAsync cfg.Conn
|
|
||||||
logStepDone ()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Ensure all required tables exist
|
|
||||||
let private checkTables cfg =
|
|
||||||
async {
|
|
||||||
logStep "|> Checking tables"
|
|
||||||
let! tables = r.Db(cfg.Database).TableList().RunResultAsync<string list> cfg.Conn
|
|
||||||
[ Table.Category; Table.Comment; Table.Page; Table.Post; Table.User; Table.WebLog ]
|
|
||||||
|> List.filter (fun tbl -> not (List.contains tbl tables))
|
|
||||||
|> List.iter (fun tbl -> logStepStart (sprintf " Creating table %s" tbl)
|
|
||||||
async { do! (r.TableCreate tbl).RunResultAsync cfg.Conn } |> Async.RunSynchronously
|
|
||||||
logStepDone ())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shorthand to get the table
|
|
||||||
let private tbl cfg table = r.Db(cfg.Database).Table table
|
|
||||||
|
|
||||||
/// Create the given index
|
|
||||||
let private createIndex cfg table (index : string * (ReqlExpr -> obj) option) =
|
|
||||||
async {
|
|
||||||
let idxName, idxFunc = index
|
|
||||||
logStepStart (sprintf """ Creating index "%s" on table %s""" idxName table)
|
|
||||||
do! (match idxFunc with
|
|
||||||
| Some f -> (tbl cfg table).IndexCreate(idxName, f)
|
|
||||||
| None -> (tbl cfg table).IndexCreate(idxName))
|
|
||||||
.RunResultAsync cfg.Conn
|
|
||||||
logStepDone ()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure that the given indexes exist, and create them if required
|
|
||||||
let private ensureIndexes cfg (indexes : (string * (string * (ReqlExpr -> obj) option) list) list) =
|
|
||||||
let ensureForTable (tblName, idxs) =
|
|
||||||
async {
|
|
||||||
let! idx = (tbl cfg tblName).IndexList().RunResultAsync<string list> cfg.Conn
|
|
||||||
idxs
|
|
||||||
|> List.filter (fun (idxName, _) -> not (List.contains idxName idx))
|
|
||||||
|> List.map (fun index -> createIndex cfg tblName index)
|
|
||||||
|> List.iter Async.RunSynchronously
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
indexes
|
|
||||||
|> List.iter ensureForTable
|
|
||||||
|
|
||||||
/// Create an index on web log Id and the given field
|
|
||||||
let private webLogField (name : string) : (ReqlExpr -> obj) option =
|
|
||||||
Some <| fun row -> upcast r.Array(row.["WebLogId"], row.[name])
|
|
||||||
|
|
||||||
/// Ensure all the required indexes exist
|
|
||||||
let private checkIndexes cfg =
|
|
||||||
logStep "|> Checking indexes"
|
|
||||||
[ Table.Category, [ "WebLogId", None
|
|
||||||
"Slug", webLogField "Slug"
|
|
||||||
]
|
|
||||||
Table.Comment, [ "PostId", None
|
|
||||||
]
|
|
||||||
Table.Page, [ "WebLogId", None
|
|
||||||
"Permalink", webLogField "Permalink"
|
|
||||||
]
|
|
||||||
Table.Post, [ "WebLogId", None
|
|
||||||
"WebLogAndStatus", webLogField "Status"
|
|
||||||
"Permalink", webLogField "Permalink"
|
|
||||||
]
|
|
||||||
Table.User, [ "UserName", None
|
|
||||||
]
|
|
||||||
Table.WebLog, [ "UrlBase", None
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|> ensureIndexes cfg
|
|
||||||
|
|
||||||
/// Start up checks to ensure the database, tables, and indexes exist
|
|
||||||
let startUpCheck cfg =
|
|
||||||
async {
|
|
||||||
logStep "Database Start Up Checks Starting"
|
|
||||||
do! checkDatabase cfg
|
|
||||||
do! checkTables cfg
|
|
||||||
checkIndexes cfg
|
|
||||||
logStep "Database Start Up Checks Complete"
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
|
@ -1,21 +0,0 @@
|
||||||
/// Constants for tables used in myWebLog
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module MyWebLog.Data.RethinkDB.Table
|
|
||||||
|
|
||||||
/// The Category table
|
|
||||||
let Category = "Category"
|
|
||||||
|
|
||||||
/// The Comment table
|
|
||||||
let Comment = "Comment"
|
|
||||||
|
|
||||||
/// The Page table
|
|
||||||
let Page = "Page"
|
|
||||||
|
|
||||||
/// The Post table
|
|
||||||
let Post = "Post"
|
|
||||||
|
|
||||||
/// The WebLog table
|
|
||||||
let WebLog = "WebLog"
|
|
||||||
|
|
||||||
/// The User table
|
|
||||||
let User = "User"
|
|
|
@ -1,31 +0,0 @@
|
||||||
module MyWebLog.Data.RethinkDB.User
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
let private r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
|
|
||||||
/// Log on a user
|
|
||||||
// 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) =
|
|
||||||
async {
|
|
||||||
let! user =
|
|
||||||
r.Table(Table.User)
|
|
||||||
.GetAll(email).OptArg("index", "UserName")
|
|
||||||
.Filter(ReqlFunction1 (fun u -> upcast u.["PasswordHash"].Eq passwordHash))
|
|
||||||
.RunResultAsync<User list> conn
|
|
||||||
return user |> List.tryHead
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Set a user's password
|
|
||||||
let setUserPassword conn (email : string) (passwordHash : string) =
|
|
||||||
async {
|
|
||||||
do! r.Table(Table.User)
|
|
||||||
.GetAll(email).OptArg("index", "UserName")
|
|
||||||
.Update(dict [ "PasswordHash", passwordHash ])
|
|
||||||
.RunResultAsync conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
|
@ -1,39 +0,0 @@
|
||||||
module MyWebLog.Data.RethinkDB.WebLog
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
let private r = RethinkDb.Driver.RethinkDB.R
|
|
||||||
|
|
||||||
/// Detemine the web log by the URL base
|
|
||||||
let tryFindWebLogByUrlBase conn (urlBase : string) =
|
|
||||||
async {
|
|
||||||
let! cursor =
|
|
||||||
r.Table(Table.WebLog)
|
|
||||||
.GetAll(urlBase).OptArg("index", "UrlBase")
|
|
||||||
.Merge(ReqlFunction1 (fun w ->
|
|
||||||
upcast r.HashMap(
|
|
||||||
"PageList", r.Table(Table.Page)
|
|
||||||
.GetAll(w.G("id")).OptArg("index", "WebLogId")
|
|
||||||
.Filter(ReqlFunction1 (fun pg -> upcast pg.["ShowInPageList"].Eq true))
|
|
||||||
.OrderBy("Title")
|
|
||||||
.Pluck("Title", "Permalink")
|
|
||||||
.CoerceTo("array"))))
|
|
||||||
.RunCursorAsync<WebLog> conn
|
|
||||||
return cursor |> Seq.tryHead
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
||||||
/// Get counts for the admin dashboard
|
|
||||||
let findDashboardCounts conn (webLogId : string) =
|
|
||||||
async {
|
|
||||||
return!
|
|
||||||
r.Expr(
|
|
||||||
r.HashMap(
|
|
||||||
"Pages", r.Table(Table.Page ).GetAll(webLogId).OptArg("index", "WebLogId").Count()).With(
|
|
||||||
"Posts", r.Table(Table.Post ).GetAll(webLogId).OptArg("index", "WebLogId").Count()).With(
|
|
||||||
"Categories", r.Table(Table.Category).GetAll(webLogId).OptArg("index", "WebLogId").Count()))
|
|
||||||
.RunResultAsync<DashboardCounts> conn
|
|
||||||
}
|
|
||||||
|> Async.RunSynchronously
|
|
||||||
|
|
|
@ -1,301 +0,0 @@
|
||||||
namespace MyWebLog.Entities
|
|
||||||
|
|
||||||
open Newtonsoft.Json
|
|
||||||
|
|
||||||
// --- Constants ---
|
|
||||||
|
|
||||||
/// Constants to use for revision source language
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module RevisionSource =
|
|
||||||
[<Literal>]
|
|
||||||
let Markdown = "markdown"
|
|
||||||
[<Literal>]
|
|
||||||
let HTML = "html"
|
|
||||||
|
|
||||||
/// Constants to use for authorization levels
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module AuthorizationLevel =
|
|
||||||
[<Literal>]
|
|
||||||
let Administrator = "Administrator"
|
|
||||||
[<Literal>]
|
|
||||||
let User = "User"
|
|
||||||
|
|
||||||
/// Constants to use for post statuses
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module PostStatus =
|
|
||||||
[<Literal>]
|
|
||||||
let Draft = "Draft"
|
|
||||||
[<Literal>]
|
|
||||||
let Published = "Published"
|
|
||||||
|
|
||||||
/// Constants to use for comment statuses
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module CommentStatus =
|
|
||||||
[<Literal>]
|
|
||||||
let Approved = "Approved"
|
|
||||||
[<Literal>]
|
|
||||||
let Pending = "Pending"
|
|
||||||
[<Literal>]
|
|
||||||
let Spam = "Spam"
|
|
||||||
|
|
||||||
// --- Entities ---
|
|
||||||
|
|
||||||
/// A revision of a post or page
|
|
||||||
type Revision =
|
|
||||||
{ /// The instant which this revision was saved
|
|
||||||
AsOf : int64
|
|
||||||
/// The source language
|
|
||||||
SourceType : string
|
|
||||||
/// 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 =
|
|
||||||
{ /// The Id
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The Id of the web log to which this page belongs
|
|
||||||
WebLogId : string
|
|
||||||
/// The Id of the author of this page
|
|
||||||
AuthorId : string
|
|
||||||
/// The title of the page
|
|
||||||
Title : string
|
|
||||||
/// The link at which this page is displayed
|
|
||||||
Permalink : string
|
|
||||||
/// The instant this page was published
|
|
||||||
PublishedOn : int64
|
|
||||||
/// The instant this page was last updated
|
|
||||||
UpdatedOn : int64
|
|
||||||
/// Whether this page shows as part of the web log's navigation
|
|
||||||
ShowInPageList : bool
|
|
||||||
/// The current text of the page
|
|
||||||
Text : string
|
|
||||||
/// Revisions of this page
|
|
||||||
Revisions : Revision list }
|
|
||||||
with
|
|
||||||
static member Empty =
|
|
||||||
{ Id = ""
|
|
||||||
WebLogId = ""
|
|
||||||
AuthorId = ""
|
|
||||||
Title = ""
|
|
||||||
Permalink = ""
|
|
||||||
PublishedOn = int64 0
|
|
||||||
UpdatedOn = int64 0
|
|
||||||
ShowInPageList = false
|
|
||||||
Text = ""
|
|
||||||
Revisions = []
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// An entry in the list of pages displayed as part of the web log (derived via query)
|
|
||||||
type PageListEntry =
|
|
||||||
{ Permalink : string
|
|
||||||
Title : string }
|
|
||||||
|
|
||||||
/// A web log
|
|
||||||
type WebLog =
|
|
||||||
{ /// The Id
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The name
|
|
||||||
Name : string
|
|
||||||
/// The subtitle
|
|
||||||
Subtitle : string option
|
|
||||||
/// The default page ("posts" or a page Id)
|
|
||||||
DefaultPage : string
|
|
||||||
/// The path of the theme (within /views/themes)
|
|
||||||
ThemePath : string
|
|
||||||
/// The URL base
|
|
||||||
UrlBase : string
|
|
||||||
/// The time zone in which dates/times should be displayed
|
|
||||||
TimeZone : string
|
|
||||||
/// A list of pages to be rendered as part of the site navigation (not stored)
|
|
||||||
PageList : PageListEntry list }
|
|
||||||
with
|
|
||||||
/// An empty web log
|
|
||||||
static member Empty =
|
|
||||||
{ Id = ""
|
|
||||||
Name = ""
|
|
||||||
Subtitle = None
|
|
||||||
DefaultPage = ""
|
|
||||||
ThemePath = "default"
|
|
||||||
UrlBase = ""
|
|
||||||
TimeZone = "America/New_York"
|
|
||||||
PageList = [] }
|
|
||||||
|
|
||||||
|
|
||||||
/// An authorization between a user and a web log
|
|
||||||
type Authorization =
|
|
||||||
{ /// The Id of the web log to which this authorization grants access
|
|
||||||
WebLogId : string
|
|
||||||
/// The level of access granted by this authorization
|
|
||||||
Level : string }
|
|
||||||
|
|
||||||
|
|
||||||
/// A user of myWebLog
|
|
||||||
type User =
|
|
||||||
{ /// The Id
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The user name (e-mail address)
|
|
||||||
UserName : string
|
|
||||||
/// The first name
|
|
||||||
FirstName : string
|
|
||||||
/// The last name
|
|
||||||
LastName : string
|
|
||||||
/// The user's preferred name
|
|
||||||
PreferredName : string
|
|
||||||
/// The hash of the user's password
|
|
||||||
PasswordHash : string
|
|
||||||
/// The URL of the user's personal site
|
|
||||||
Url : string option
|
|
||||||
/// The user's authorizations
|
|
||||||
Authorizations : Authorization list }
|
|
||||||
with
|
|
||||||
/// An empty user
|
|
||||||
static member Empty =
|
|
||||||
{ Id = ""
|
|
||||||
UserName = ""
|
|
||||||
FirstName = ""
|
|
||||||
LastName = ""
|
|
||||||
PreferredName = ""
|
|
||||||
PasswordHash = ""
|
|
||||||
Url = None
|
|
||||||
Authorizations = [] }
|
|
||||||
|
|
||||||
/// Claims for this user
|
|
||||||
[<JsonIgnore>]
|
|
||||||
member this.Claims = this.Authorizations
|
|
||||||
|> List.map (fun auth -> sprintf "%s|%s" auth.WebLogId auth.Level)
|
|
||||||
|
|
||||||
|
|
||||||
/// A category to which posts may be assigned
|
|
||||||
type Category =
|
|
||||||
{ /// The Id
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The Id of the web log to which this category belongs
|
|
||||||
WebLogId : string
|
|
||||||
/// The displayed name
|
|
||||||
Name : string
|
|
||||||
/// The slug (used in category URLs)
|
|
||||||
Slug : string
|
|
||||||
/// A longer description of the category
|
|
||||||
Description : string option
|
|
||||||
/// The parent Id of this category (if a subcategory)
|
|
||||||
ParentId : string option
|
|
||||||
/// The categories for which this category is the parent
|
|
||||||
Children : string list }
|
|
||||||
with
|
|
||||||
/// An empty category
|
|
||||||
static member Empty =
|
|
||||||
{ Id = "new"
|
|
||||||
WebLogId = ""
|
|
||||||
Name = ""
|
|
||||||
Slug = ""
|
|
||||||
Description = None
|
|
||||||
ParentId = None
|
|
||||||
Children = [] }
|
|
||||||
|
|
||||||
|
|
||||||
/// A comment (applies to a post)
|
|
||||||
type Comment =
|
|
||||||
{ /// The Id
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The Id of the post to which this comment applies
|
|
||||||
PostId : string
|
|
||||||
/// The Id of the comment to which this comment is a reply
|
|
||||||
InReplyToId : string option
|
|
||||||
/// The name of the commentor
|
|
||||||
Name : string
|
|
||||||
/// The e-mail address of the commentor
|
|
||||||
Email : string
|
|
||||||
/// The URL of the commentor's personal website
|
|
||||||
Url : string option
|
|
||||||
/// The status of the comment
|
|
||||||
Status : string
|
|
||||||
/// The instant the comment was posted
|
|
||||||
PostedOn : int64
|
|
||||||
/// The text of the comment
|
|
||||||
Text : string }
|
|
||||||
with
|
|
||||||
static member Empty =
|
|
||||||
{ Id = ""
|
|
||||||
PostId = ""
|
|
||||||
InReplyToId = None
|
|
||||||
Name = ""
|
|
||||||
Email = ""
|
|
||||||
Url = None
|
|
||||||
Status = CommentStatus.Pending
|
|
||||||
PostedOn = int64 0
|
|
||||||
Text = "" }
|
|
||||||
|
|
||||||
|
|
||||||
/// A post
|
|
||||||
type Post =
|
|
||||||
{ /// The Id
|
|
||||||
[<JsonProperty("id")>]
|
|
||||||
Id : string
|
|
||||||
/// The Id of the web log to which this post belongs
|
|
||||||
WebLogId : string
|
|
||||||
/// The Id of the author of this post
|
|
||||||
AuthorId : string
|
|
||||||
/// The status
|
|
||||||
Status : string
|
|
||||||
/// The title
|
|
||||||
Title : string
|
|
||||||
/// The link at which the post resides
|
|
||||||
Permalink : string
|
|
||||||
/// The instant on which the post was originally published
|
|
||||||
PublishedOn : int64
|
|
||||||
/// The instant on which the post was last updated
|
|
||||||
UpdatedOn : int64
|
|
||||||
/// The text of the post
|
|
||||||
Text : string
|
|
||||||
/// The Ids of the categories to which this is assigned
|
|
||||||
CategoryIds : string list
|
|
||||||
/// The tags for the post
|
|
||||||
Tags : string list
|
|
||||||
/// The permalinks at which this post may have once resided
|
|
||||||
PriorPermalinks : string list
|
|
||||||
/// Revisions of this post
|
|
||||||
Revisions : Revision list
|
|
||||||
/// The categories to which this is assigned (not stored in database)
|
|
||||||
Categories : Category list
|
|
||||||
/// The comments (not stored in database)
|
|
||||||
Comments : Comment list }
|
|
||||||
with
|
|
||||||
static member Empty =
|
|
||||||
{ Id = "new"
|
|
||||||
WebLogId = ""
|
|
||||||
AuthorId = ""
|
|
||||||
Status = PostStatus.Draft
|
|
||||||
Title = ""
|
|
||||||
Permalink = ""
|
|
||||||
PublishedOn = int64 0
|
|
||||||
UpdatedOn = int64 0
|
|
||||||
Text = ""
|
|
||||||
CategoryIds = []
|
|
||||||
Tags = []
|
|
||||||
PriorPermalinks = []
|
|
||||||
Revisions = []
|
|
||||||
Categories = []
|
|
||||||
Comments = [] }
|
|
||||||
|
|
||||||
// --- UI Support ---
|
|
||||||
|
|
||||||
/// 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 }
|
|
|
@ -1,117 +0,0 @@
|
||||||
namespace MyWebLog.Data
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
|
|
||||||
/// Interface required to provide data to myWebLog's logic layer
|
|
||||||
type IMyWebLogData =
|
|
||||||
/// Function to set up the data store
|
|
||||||
abstract SetUp : (unit -> unit)
|
|
||||||
|
|
||||||
// --- Category ---
|
|
||||||
|
|
||||||
/// Get all categories for a web log
|
|
||||||
abstract AllCategories : (string -> Category list)
|
|
||||||
|
|
||||||
/// Try to find a category by its Id and web log Id (web log, category Ids)
|
|
||||||
abstract CategoryById : (string -> string -> Category option)
|
|
||||||
|
|
||||||
/// Try to find a category by its slug (web log Id, slug)
|
|
||||||
abstract CategoryBySlug : (string -> string -> Category option)
|
|
||||||
|
|
||||||
/// Add a category
|
|
||||||
abstract AddCategory : (Category -> unit)
|
|
||||||
|
|
||||||
/// Update a category
|
|
||||||
abstract UpdateCategory : (Category -> unit)
|
|
||||||
|
|
||||||
/// Update a category's children
|
|
||||||
abstract UpdateChildren : (string -> string -> string list -> unit)
|
|
||||||
|
|
||||||
/// Delete a Category
|
|
||||||
abstract DeleteCategory : (Category -> unit)
|
|
||||||
|
|
||||||
// --- Page ---
|
|
||||||
|
|
||||||
/// Try to find a page by its Id and web log Id (web log, page Ids), choosing whether to include revisions
|
|
||||||
abstract PageById : (string -> string -> bool -> Page option)
|
|
||||||
|
|
||||||
/// Try to find a page by its permalink and web log Id (web log Id, permalink)
|
|
||||||
abstract PageByPermalink : (string -> string -> Page option)
|
|
||||||
|
|
||||||
/// Get all pages for a web log
|
|
||||||
abstract AllPages : (string -> Page list)
|
|
||||||
|
|
||||||
/// Add a page
|
|
||||||
abstract AddPage : (Page -> unit)
|
|
||||||
|
|
||||||
/// Update a page
|
|
||||||
abstract UpdatePage : (Page -> unit)
|
|
||||||
|
|
||||||
/// Delete a page by its Id and web log Id (web log, page Ids)
|
|
||||||
abstract DeletePage : (string -> string -> unit)
|
|
||||||
|
|
||||||
// --- Post ---
|
|
||||||
|
|
||||||
/// Find a page of published posts for the given web log (web log Id, page #, # per page)
|
|
||||||
abstract PageOfPublishedPosts : (string -> int -> int -> Post list)
|
|
||||||
|
|
||||||
/// Find a page of published posts within a given category (web log Id, cat Id, page #, # per page)
|
|
||||||
abstract PageOfCategorizedPosts : (string -> string -> int -> int -> Post list)
|
|
||||||
|
|
||||||
/// Find a page of published posts tagged with a given tag (web log Id, tag, page #, # per page)
|
|
||||||
abstract PageOfTaggedPosts : (string -> string -> int -> int -> Post list)
|
|
||||||
|
|
||||||
/// Try to find the next newer published post for the given post
|
|
||||||
abstract NewerPost : (Post -> Post option)
|
|
||||||
|
|
||||||
/// Try to find the next newer published post within a given category
|
|
||||||
abstract NewerCategorizedPost : (string -> Post -> Post option)
|
|
||||||
|
|
||||||
/// Try to find the next newer published post tagged with a given tag
|
|
||||||
abstract NewerTaggedPost : (string -> Post -> Post option)
|
|
||||||
|
|
||||||
/// Try to find the next older published post for the given post
|
|
||||||
abstract OlderPost : (Post -> Post option)
|
|
||||||
|
|
||||||
/// Try to find the next older published post within a given category
|
|
||||||
abstract OlderCategorizedPost : (string -> Post -> Post option)
|
|
||||||
|
|
||||||
/// Try to find the next older published post tagged with a given tag
|
|
||||||
abstract OlderTaggedPost : (string -> Post -> Post option)
|
|
||||||
|
|
||||||
/// Find a page of all posts for the given web log (web log Id, page #, # per page)
|
|
||||||
abstract PageOfAllPosts : (string -> int -> int -> Post list)
|
|
||||||
|
|
||||||
/// Try to find a post by its Id and web log Id (web log, post Ids)
|
|
||||||
abstract PostById : (string -> string -> Post option)
|
|
||||||
|
|
||||||
/// Try to find a post by its permalink (web log Id, permalink)
|
|
||||||
abstract PostByPermalink : (string -> string -> Post option)
|
|
||||||
|
|
||||||
/// Try to find a post by a prior permalink (web log Id, permalink)
|
|
||||||
abstract PostByPriorPermalink : (string -> string -> Post option)
|
|
||||||
|
|
||||||
/// Get posts for the RSS feed for the given web log and number of posts
|
|
||||||
abstract FeedPosts : (string -> int -> (Post * User option) list)
|
|
||||||
|
|
||||||
/// Add a post
|
|
||||||
abstract AddPost : (Post -> unit)
|
|
||||||
|
|
||||||
/// Update a post
|
|
||||||
abstract UpdatePost : (Post -> unit)
|
|
||||||
|
|
||||||
// --- User ---
|
|
||||||
|
|
||||||
/// Attempt to log on a user
|
|
||||||
abstract LogOn : (string -> string -> User option)
|
|
||||||
|
|
||||||
/// Set a user's password (e-mail, password hash)
|
|
||||||
abstract SetUserPassword : (string -> string -> unit)
|
|
||||||
|
|
||||||
// --- WebLog ---
|
|
||||||
|
|
||||||
/// Get a web log by its URL base
|
|
||||||
abstract WebLogByUrlBase : (string -> WebLog option)
|
|
||||||
|
|
||||||
/// Get dashboard counts for a web log
|
|
||||||
abstract DashboardCounts : (string -> DashboardCounts)
|
|
|
@ -1,17 +0,0 @@
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module MyWebLog.Keys
|
|
||||||
|
|
||||||
/// Messages stored in the session
|
|
||||||
let Messages = "messages"
|
|
||||||
|
|
||||||
/// The request start time (stored in the context for each request)
|
|
||||||
let RequestStart = "request-start"
|
|
||||||
|
|
||||||
/// The current user
|
|
||||||
let User = "user"
|
|
||||||
|
|
||||||
/// The version of myWebLog
|
|
||||||
let Version = "version"
|
|
||||||
|
|
||||||
/// The web log
|
|
||||||
let WebLog = "web-log"
|
|
|
@ -1,56 +0,0 @@
|
||||||
module MyWebLog.Logic.Category
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
|
|
||||||
/// Find all categories for a given web log
|
|
||||||
let findAllCategories (data : IMyWebLogData) webLogId =
|
|
||||||
data.AllCategories webLogId
|
|
||||||
|> sortCategories
|
|
||||||
|
|
||||||
/// Try to find a category for a given web log Id and category Id
|
|
||||||
let tryFindCategory (data : IMyWebLogData) webLogId catId = data.CategoryById webLogId catId
|
|
||||||
|
|
||||||
/// Try to find a category by its slug for a given web log
|
|
||||||
let tryFindCategoryBySlug (data : IMyWebLogData) webLogId slug = data.CategoryBySlug webLogId slug
|
|
||||||
|
|
||||||
/// Save a category
|
|
||||||
let saveCategory (data : IMyWebLogData) (cat : Category) =
|
|
||||||
match cat.Id with
|
|
||||||
| "new" -> let newCat = { cat with Id = string <| System.Guid.NewGuid() }
|
|
||||||
data.AddCategory newCat
|
|
||||||
newCat.Id
|
|
||||||
| _ -> data.UpdateCategory cat
|
|
||||||
cat.Id
|
|
||||||
|
|
||||||
/// Remove a category from its parent
|
|
||||||
let removeCategoryFromParent (data : IMyWebLogData) webLogId parentId catId =
|
|
||||||
match tryFindCategory data webLogId parentId with
|
|
||||||
| Some parent -> parent.Children
|
|
||||||
|> List.filter (fun childId -> childId <> catId)
|
|
||||||
|> data.UpdateChildren webLogId parentId
|
|
||||||
| None -> ()
|
|
||||||
|
|
||||||
/// Add a category to a given parent
|
|
||||||
let addCategoryToParent (data : IMyWebLogData) webLogId parentId catId =
|
|
||||||
match tryFindCategory data webLogId parentId with
|
|
||||||
| Some parent -> catId :: parent.Children
|
|
||||||
|> data.UpdateChildren webLogId parentId
|
|
||||||
| None -> ()
|
|
||||||
|
|
||||||
/// Delete a category
|
|
||||||
let deleteCategory (data : IMyWebLogData) cat = data.DeleteCategory cat
|
|
|
@ -1,29 +0,0 @@
|
||||||
/// Logic for manipulating <see cref="Page" /> entities
|
|
||||||
module MyWebLog.Logic.Page
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
|
|
||||||
/// Find a page by its Id and web log Id
|
|
||||||
let tryFindPage (data : IMyWebLogData) webLogId pageId = data.PageById webLogId pageId true
|
|
||||||
|
|
||||||
/// Find a page by its Id and web log Id, without the revision list
|
|
||||||
let tryFindPageWithoutRevisions (data : IMyWebLogData) webLogId pageId = data.PageById webLogId pageId false
|
|
||||||
|
|
||||||
/// Find a page by its permalink
|
|
||||||
let tryFindPageByPermalink (data : IMyWebLogData) webLogId permalink = data.PageByPermalink webLogId permalink
|
|
||||||
|
|
||||||
/// Find a list of all pages (excludes text and revisions)
|
|
||||||
let findAllPages (data : IMyWebLogData) webLogId = data.AllPages webLogId
|
|
||||||
|
|
||||||
/// Save a page
|
|
||||||
let savePage (data : IMyWebLogData) (page : Page) =
|
|
||||||
match page.Id with
|
|
||||||
| "new" -> let newPg = { page with Id = string <| System.Guid.NewGuid () }
|
|
||||||
data.AddPage newPg
|
|
||||||
newPg.Id
|
|
||||||
| _ -> data.UpdatePage page
|
|
||||||
page.Id
|
|
||||||
|
|
||||||
/// Delete a page
|
|
||||||
let deletePage (data : IMyWebLogData) webLogId pageId = data.DeletePage webLogId pageId
|
|
|
@ -1,60 +0,0 @@
|
||||||
/// Logic for manipulating <see cref="Post" /> entities
|
|
||||||
module MyWebLog.Logic.Post
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
|
|
||||||
/// Find a page of published posts
|
|
||||||
let findPageOfPublishedPosts (data : IMyWebLogData) webLogId pageNbr nbrPerPage =
|
|
||||||
data.PageOfPublishedPosts webLogId pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Find a pages of published posts in a given category
|
|
||||||
let findPageOfCategorizedPosts (data : IMyWebLogData) webLogId catId pageNbr nbrPerPage =
|
|
||||||
data.PageOfCategorizedPosts webLogId catId pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Find a page of published posts tagged with a given tag
|
|
||||||
let findPageOfTaggedPosts (data : IMyWebLogData) webLogId tag pageNbr nbrPerPage =
|
|
||||||
data.PageOfTaggedPosts webLogId tag pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Find the next newer published post for the given post
|
|
||||||
let tryFindNewerPost (data : IMyWebLogData) post = data.NewerPost post
|
|
||||||
|
|
||||||
/// Find the next newer published post in a given category for the given post
|
|
||||||
let tryFindNewerCategorizedPost (data : IMyWebLogData) catId post = data.NewerCategorizedPost catId post
|
|
||||||
|
|
||||||
/// Find the next newer published post tagged with a given tag for the given post
|
|
||||||
let tryFindNewerTaggedPost (data : IMyWebLogData) tag post = data.NewerTaggedPost tag post
|
|
||||||
|
|
||||||
/// Find the next older published post for the given post
|
|
||||||
let tryFindOlderPost (data : IMyWebLogData) post = data.OlderPost post
|
|
||||||
|
|
||||||
/// Find the next older published post in a given category for the given post
|
|
||||||
let tryFindOlderCategorizedPost (data : IMyWebLogData) catId post = data.OlderCategorizedPost catId post
|
|
||||||
|
|
||||||
/// Find the next older published post tagged with a given tag for the given post
|
|
||||||
let tryFindOlderTaggedPost (data : IMyWebLogData) tag post = data.OlderTaggedPost tag post
|
|
||||||
|
|
||||||
/// Find a page of all posts for a web log
|
|
||||||
let findPageOfAllPosts (data : IMyWebLogData) webLogId pageNbr nbrPerPage =
|
|
||||||
data.PageOfAllPosts webLogId pageNbr nbrPerPage
|
|
||||||
|
|
||||||
/// Try to find a post by its Id
|
|
||||||
let tryFindPost (data : IMyWebLogData) webLogId postId = data.PostById webLogId postId
|
|
||||||
|
|
||||||
/// Try to find a post by its permalink
|
|
||||||
let tryFindPostByPermalink (data : IMyWebLogData) webLogId permalink = data.PostByPermalink webLogId permalink
|
|
||||||
|
|
||||||
/// Try to find a post by its prior permalink
|
|
||||||
let tryFindPostByPriorPermalink (data : IMyWebLogData) webLogId permalink = data.PostByPriorPermalink webLogId permalink
|
|
||||||
|
|
||||||
/// Find posts for the RSS feed
|
|
||||||
let findFeedPosts (data : IMyWebLogData) webLogId nbrOfPosts = data.FeedPosts webLogId nbrOfPosts
|
|
||||||
|
|
||||||
/// Save a post
|
|
||||||
let savePost (data : IMyWebLogData) (post : Post) =
|
|
||||||
match post.Id with
|
|
||||||
| "new" -> let newPost = { post with Id = string <| System.Guid.NewGuid() }
|
|
||||||
data.AddPost newPost
|
|
||||||
newPost.Id
|
|
||||||
| _ -> data.UpdatePost post
|
|
||||||
post.Id
|
|
|
@ -1,9 +0,0 @@
|
||||||
/// Logic for manipulating <see cref="User" /> entities
|
|
||||||
module MyWebLog.Logic.User
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
|
|
||||||
/// Try to log on a user
|
|
||||||
let tryUserLogOn (data : IMyWebLogData) email passwordHash = data.LogOn email passwordHash
|
|
||||||
|
|
||||||
let setUserPassword (data : IMyWebLogData) = data.SetUserPassword
|
|
|
@ -1,11 +0,0 @@
|
||||||
/// Logic for manipulating <see cref="WebLog" /> entities
|
|
||||||
module MyWebLog.Logic.WebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
|
|
||||||
/// Find a web log by its URL base
|
|
||||||
let tryFindWebLogByUrlBase (data : IMyWebLogData) urlBase = data.WebLogByUrlBase urlBase
|
|
||||||
|
|
||||||
/// Find the counts for the admin dashboard
|
|
||||||
let findDashboardCounts (data : IMyWebLogData) webLogId = data.DashboardCounts webLogId
|
|
|
@ -1,22 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Logic.WebLog
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
/// Handle /admin routes
|
|
||||||
type AdminModule (data : IMyWebLogData) as this =
|
|
||||||
inherit NancyModule ("/admin")
|
|
||||||
|
|
||||||
do
|
|
||||||
this.Get ("/", fun _ -> this.Dashboard ())
|
|
||||||
|
|
||||||
/// Admin dashboard
|
|
||||||
member this.Dashboard () : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let model = DashboardModel (this.Context, this.WebLog, findDashboardCounts data this.WebLog.Id)
|
|
||||||
model.PageTitle <- Strings.get "Dashboard"
|
|
||||||
upcast this.View.["admin/dashboard", model]
|
|
|
@ -1,97 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Logic.Category
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open Nancy.ModelBinding
|
|
||||||
open Nancy.Security
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
/// Handle /category and /categories URLs
|
|
||||||
type CategoryModule (data : IMyWebLogData) as this =
|
|
||||||
inherit NancyModule ()
|
|
||||||
|
|
||||||
do
|
|
||||||
this.Get ("/categories", fun _ -> this.CategoryList ())
|
|
||||||
this.Get ("/category/{id}/edit", fun p -> this.EditCategory (downcast p))
|
|
||||||
this.Post ("/category/{id}/edit", fun p -> this.SaveCategory (downcast p))
|
|
||||||
this.Post ("/category/{id}/delete", fun p -> this.DeleteCategory (downcast p))
|
|
||||||
|
|
||||||
/// Display a list of categories
|
|
||||||
member this.CategoryList () : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let model =
|
|
||||||
CategoryListModel (
|
|
||||||
this.Context, this.WebLog, findAllCategories data this.WebLog.Id
|
|
||||||
|> List.map (fun cat -> IndentedCategory.Create cat (fun _ -> false)))
|
|
||||||
model.PageTitle <- Strings.get "Categories"
|
|
||||||
upcast this.View.["admin/category/list", model]
|
|
||||||
|
|
||||||
/// Edit a category
|
|
||||||
member this.EditCategory (parameters : DynamicDictionary) : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let catId = parameters.["id"].ToString ()
|
|
||||||
match catId with "new" -> Some Category.Empty | _ -> tryFindCategory data this.WebLog.Id catId
|
|
||||||
|> function
|
|
||||||
| Some cat ->
|
|
||||||
let model = CategoryEditModel (this.Context, this.WebLog, cat)
|
|
||||||
model.Categories <- findAllCategories data this.WebLog.Id
|
|
||||||
|> List.map (fun c ->
|
|
||||||
IndentedCategory.Create c (fun catId -> catId = defaultArg cat.ParentId ""))
|
|
||||||
model.PageTitle <- Strings.get <| match catId with "new" -> "AddNewCategory" | _ -> "EditCategory"
|
|
||||||
upcast this.View.["admin/category/edit", model]
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Save a category
|
|
||||||
member this.SaveCategory (parameters : DynamicDictionary) : obj =
|
|
||||||
this.ValidateCsrfToken ()
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let catId = parameters.["id"].ToString ()
|
|
||||||
let form = this.Bind<CategoryForm> ()
|
|
||||||
match catId with
|
|
||||||
| "new" -> Some { Category.Empty with WebLogId = this.WebLog.Id }
|
|
||||||
| _ -> tryFindCategory data this.WebLog.Id catId
|
|
||||||
|> function
|
|
||||||
| Some old ->
|
|
||||||
let cat =
|
|
||||||
{ old with
|
|
||||||
Name = form.Name
|
|
||||||
Slug = form.Slug
|
|
||||||
Description = match form.Description with "" -> None | d -> Some d
|
|
||||||
ParentId = match form.ParentId with "" -> None | p -> Some p
|
|
||||||
}
|
|
||||||
let newCatId = saveCategory data cat
|
|
||||||
match old.ParentId = cat.ParentId with
|
|
||||||
| true -> ()
|
|
||||||
| _ ->
|
|
||||||
match old.ParentId with
|
|
||||||
| Some parentId -> removeCategoryFromParent data this.WebLog.Id parentId newCatId
|
|
||||||
| _ -> ()
|
|
||||||
match cat.ParentId with
|
|
||||||
| Some parentId -> addCategoryToParent data this.WebLog.Id parentId newCatId
|
|
||||||
| _ -> ()
|
|
||||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
|
||||||
model.AddMessage
|
|
||||||
{ UserMessage.Empty with
|
|
||||||
Message = System.String.Format
|
|
||||||
(Strings.get "MsgCategoryEditSuccess",
|
|
||||||
Strings.get (match catId with "new" -> "Added" | _ -> "Updated"))
|
|
||||||
}
|
|
||||||
this.Redirect (sprintf "/category/%s/edit" newCatId) model
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Delete a category
|
|
||||||
member this.DeleteCategory (parameters : DynamicDictionary) : obj =
|
|
||||||
this.ValidateCsrfToken ()
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let catId = parameters.["id"].ToString ()
|
|
||||||
match tryFindCategory data this.WebLog.Id catId with
|
|
||||||
| Some cat ->
|
|
||||||
deleteCategory data cat
|
|
||||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
|
||||||
model.AddMessage
|
|
||||||
{ UserMessage.Empty with Message = System.String.Format(Strings.get "MsgCategoryDeleted", cat.Name) }
|
|
||||||
this.Redirect "/categories" model
|
|
||||||
| _ -> this.NotFound ()
|
|
|
@ -1,36 +0,0 @@
|
||||||
[<AutoOpen>]
|
|
||||||
module MyWebLog.ModuleExtensions
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open Nancy
|
|
||||||
open Nancy.Security
|
|
||||||
open System
|
|
||||||
open System.Security.Claims
|
|
||||||
|
|
||||||
/// Parent class for all myWebLog Nancy modules
|
|
||||||
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.ThemedView view (model : MyWebLogModel) : obj =
|
|
||||||
upcast this.View.[(sprintf "themes/%s/%s" this.WebLog.ThemePath view), model]
|
|
||||||
|
|
||||||
/// Return a 404
|
|
||||||
member this.NotFound () : obj = upcast HttpStatusCode.NotFound
|
|
||||||
|
|
||||||
/// Redirect a request, storing messages in the session if they exist
|
|
||||||
member this.Redirect url (model : MyWebLogModel) : obj =
|
|
||||||
match List.length model.Messages with
|
|
||||||
| 0 -> ()
|
|
||||||
| _ -> this.Session.[Keys.Messages] <- model.Messages
|
|
||||||
// Temp (307) redirects don't reset the HTTP method; this allows POST-process-REDIRECT workflow
|
|
||||||
upcast this.Response.AsRedirect(url).WithStatusCode HttpStatusCode.MovedPermanently
|
|
||||||
|
|
||||||
/// Require a specific level of access for the current web log
|
|
||||||
member this.RequiresAccessLevel level =
|
|
||||||
let findClaim = Predicate<Claim> (fun claim ->
|
|
||||||
claim.Type = ClaimTypes.Role && claim.Value = sprintf "%s|%s" this.WebLog.Id level)
|
|
||||||
this.RequiresAuthentication ()
|
|
||||||
this.RequiresClaims [| findClaim |]
|
|
|
@ -1,97 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Logic.Page
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open Nancy.ModelBinding
|
|
||||||
open Nancy.Security
|
|
||||||
open NodaTime
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
|
|
||||||
/// Handle /pages and /page URLs
|
|
||||||
type PageModule (data : IMyWebLogData, clock : IClock) as this =
|
|
||||||
inherit NancyModule ()
|
|
||||||
|
|
||||||
do
|
|
||||||
this.Get ("/pages", fun _ -> this.PageList ())
|
|
||||||
this.Get ("/page/{id}/edit", fun p -> this.EditPage (downcast p))
|
|
||||||
this.Post ("/page/{id}/edit", fun p -> this.SavePage (downcast p))
|
|
||||||
this.Delete ("/page/{id}/delete", fun p -> this.DeletePage (downcast p))
|
|
||||||
|
|
||||||
/// List all pages
|
|
||||||
member this.PageList () : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let model =
|
|
||||||
PagesModel (this.Context, this.WebLog, findAllPages data this.WebLog.Id
|
|
||||||
|> List.map (fun p -> PageForDisplay (this.WebLog, p)))
|
|
||||||
model.PageTitle <- Strings.get "Pages"
|
|
||||||
upcast this.View.["admin/page/list", model]
|
|
||||||
|
|
||||||
/// Edit a page
|
|
||||||
member this.EditPage (parameters : DynamicDictionary) : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let pageId = parameters.["id"].ToString ()
|
|
||||||
match pageId with "new" -> Some Page.Empty | _ -> tryFindPage data this.WebLog.Id pageId
|
|
||||||
|> function
|
|
||||||
| Some page ->
|
|
||||||
let rev = match page.Revisions
|
|
||||||
|> List.sortByDescending (fun r -> r.AsOf)
|
|
||||||
|> List.tryHead with
|
|
||||||
| Some r -> r
|
|
||||||
| _ -> Revision.Empty
|
|
||||||
let model = EditPageModel (this.Context, this.WebLog, page, rev)
|
|
||||||
model.PageTitle <- Strings.get <| match pageId with "new" -> "AddNewPage" | _ -> "EditPage"
|
|
||||||
upcast this.View.["admin/page/edit", model]
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Save a page
|
|
||||||
member this.SavePage (parameters : DynamicDictionary) : obj =
|
|
||||||
this.ValidateCsrfToken ()
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let pageId = parameters.["id"].ToString ()
|
|
||||||
let form = this.Bind<EditPageForm> ()
|
|
||||||
let now = clock.GetCurrentInstant().ToUnixTimeTicks ()
|
|
||||||
match pageId with "new" -> Some Page.Empty | _ -> tryFindPage data this.WebLog.Id pageId
|
|
||||||
|> function
|
|
||||||
| Some p ->
|
|
||||||
let page = match pageId with "new" -> { p with WebLogId = this.WebLog.Id } | _ -> p
|
|
||||||
let pId =
|
|
||||||
{ p with
|
|
||||||
Title = form.Title
|
|
||||||
Permalink = form.Permalink
|
|
||||||
PublishedOn = match pageId with "new" -> now | _ -> page.PublishedOn
|
|
||||||
UpdatedOn = now
|
|
||||||
ShowInPageList = form.ShowInPageList
|
|
||||||
Text = match form.Source with
|
|
||||||
| RevisionSource.Markdown -> (* Markdown.TransformHtml *) form.Text
|
|
||||||
| _ -> form.Text
|
|
||||||
Revisions = { AsOf = now
|
|
||||||
SourceType = form.Source
|
|
||||||
Text = form.Text
|
|
||||||
} :: page.Revisions
|
|
||||||
}
|
|
||||||
|> savePage data
|
|
||||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
|
||||||
model.AddMessage
|
|
||||||
{ UserMessage.Empty with
|
|
||||||
Message = System.String.Format
|
|
||||||
(Strings.get "MsgPageEditSuccess",
|
|
||||||
Strings.get (match pageId with "new" -> "Added" | _ -> "Updated"))
|
|
||||||
}
|
|
||||||
this.Redirect (sprintf "/page/%s/edit" pId) model
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Delete a page
|
|
||||||
member this.DeletePage (parameters : DynamicDictionary) : obj =
|
|
||||||
this.ValidateCsrfToken ()
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let pageId = parameters.["id"].ToString ()
|
|
||||||
match tryFindPageWithoutRevisions data this.WebLog.Id pageId with
|
|
||||||
| Some page ->
|
|
||||||
deletePage data page.WebLogId page.Id
|
|
||||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
|
||||||
model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgPageDeleted" }
|
|
||||||
this.Redirect "/pages" model
|
|
||||||
| _ -> this.NotFound ()
|
|
|
@ -1,317 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Logic.Category
|
|
||||||
open MyWebLog.Logic.Page
|
|
||||||
open MyWebLog.Logic.Post
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open Nancy.ModelBinding
|
|
||||||
open Nancy.Security
|
|
||||||
open Nancy.Session.Persistable
|
|
||||||
open NodaTime
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open System
|
|
||||||
open System.Xml.Linq
|
|
||||||
|
|
||||||
type NewsItem =
|
|
||||||
{ Title : string
|
|
||||||
Link : string
|
|
||||||
ReleaseDate : DateTime
|
|
||||||
Description : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Routes dealing with posts (including the home page, /tag, /category, RSS, and catch-all routes)
|
|
||||||
type PostModule (data : IMyWebLogData, clock : IClock) as this =
|
|
||||||
inherit NancyModule ()
|
|
||||||
|
|
||||||
/// Get the page number from the dictionary
|
|
||||||
let getPage (parameters : DynamicDictionary) =
|
|
||||||
match parameters.ContainsKey "page" with
|
|
||||||
| true -> match System.Int32.TryParse (parameters.["page"].ToString ()) with true, pg -> pg | _ -> 1
|
|
||||||
| _ -> 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))
|
|
||||||
|
|
||||||
/// Generate an RSS/Atom feed of the latest posts
|
|
||||||
let generateFeed format : obj =
|
|
||||||
let myChannelFeed channelTitle channelLink channelDescription (items : NewsItem list) =
|
|
||||||
let xn = XName.Get
|
|
||||||
let elem name (valu : string) = XElement (xn name, valu)
|
|
||||||
let elems =
|
|
||||||
items
|
|
||||||
|> List.sortByDescending (fun i -> i.ReleaseDate)
|
|
||||||
|> List.map (fun i ->
|
|
||||||
XElement (
|
|
||||||
xn "item",
|
|
||||||
elem "title" (System.Net.WebUtility.HtmlEncode i.Title),
|
|
||||||
elem "link" i.Link,
|
|
||||||
elem "guid" i.Link,
|
|
||||||
elem "pubDate" (i.ReleaseDate.ToString "r"),
|
|
||||||
elem "description" (System.Net.WebUtility.HtmlEncode i.Description)
|
|
||||||
))
|
|
||||||
XDocument (
|
|
||||||
XDeclaration ("1.0", "utf-8", "yes"),
|
|
||||||
XElement (
|
|
||||||
xn "rss",
|
|
||||||
XAttribute (xn "version", "2.0"),
|
|
||||||
elem "title" channelTitle,
|
|
||||||
elem "link" channelLink,
|
|
||||||
elem "description" (defaultArg channelDescription ""),
|
|
||||||
elem "language" "en-us",
|
|
||||||
XElement (xn "channel", elems))
|
|
||||||
|> box)
|
|
||||||
let schemeAndUrl = sprintf "%s://%s" this.Request.Url.Scheme this.WebLog.UrlBase
|
|
||||||
let feed =
|
|
||||||
findFeedPosts data this.WebLog.Id 10
|
|
||||||
|> List.map (fun (post, _) ->
|
|
||||||
{ Title = post.Title
|
|
||||||
Link = sprintf "%s/%s" schemeAndUrl post.Permalink
|
|
||||||
ReleaseDate = Instant.FromUnixTimeTicks(post.PublishedOn).ToDateTimeOffset().DateTime
|
|
||||||
Description = post.Text
|
|
||||||
})
|
|
||||||
|> myChannelFeed this.WebLog.Name schemeAndUrl this.WebLog.Subtitle
|
|
||||||
let stream = new IO.MemoryStream ()
|
|
||||||
Xml.XmlWriter.Create stream |> feed.Save
|
|
||||||
//|> match format with "atom" -> feed.SaveAsAtom10 | _ -> feed.SaveAsRss20
|
|
||||||
stream.Position <- 0L
|
|
||||||
upcast this.Response.FromStream (stream, sprintf "application/%s+xml" format)
|
|
||||||
// TODO: how to return this?
|
|
||||||
|
|
||||||
(*
|
|
||||||
let feed =
|
|
||||||
SyndicationFeed(
|
|
||||||
this.WebLog.Name, defaultArg this.WebLog.Subtitle null,
|
|
||||||
Uri(sprintf "%s://%s" this.Request.Url.Scheme this.WebLog.UrlBase), null,
|
|
||||||
(match posts |> List.tryHead with
|
|
||||||
| Some (post, _) -> Instant(post.UpdatedOn).ToDateTimeOffset ()
|
|
||||||
| _ -> System.DateTimeOffset(System.DateTime.MinValue)),
|
|
||||||
posts
|
|
||||||
|> List.map (fun (post, user) ->
|
|
||||||
let item =
|
|
||||||
SyndicationItem(
|
|
||||||
BaseUri = Uri(sprintf "%s://%s/%s" this.Request.Url.Scheme this.WebLog.UrlBase post.Permalink),
|
|
||||||
PublishDate = Instant(post.PublishedOn).ToDateTimeOffset (),
|
|
||||||
LastUpdatedTime = Instant(post.UpdatedOn).ToDateTimeOffset (),
|
|
||||||
Title = TextSyndicationContent(post.Title),
|
|
||||||
Content = TextSyndicationContent(post.Text, TextSyndicationContentKind.Html))
|
|
||||||
user
|
|
||||||
|> Option.iter (fun u -> item.Authors.Add
|
|
||||||
(SyndicationPerson(u.UserName, u.PreferredName, defaultArg u.Url null)))
|
|
||||||
post.Categories
|
|
||||||
|> List.iter (fun c -> item.Categories.Add(SyndicationCategory(c.Name)))
|
|
||||||
item))
|
|
||||||
let stream = new IO.MemoryStream()
|
|
||||||
Xml.XmlWriter.Create(stream)
|
|
||||||
|> match format with "atom" -> feed.SaveAsAtom10 | _ -> feed.SaveAsRss20
|
|
||||||
stream.Position <- int64 0
|
|
||||||
upcast this.Response.FromStream(stream, sprintf "application/%s+xml" format) *)
|
|
||||||
|
|
||||||
do
|
|
||||||
this.Get ("/", fun _ -> this.HomePage ())
|
|
||||||
this.Get ("/{permalink*}", fun p -> this.CatchAll (downcast p))
|
|
||||||
this.Get ("/posts/page/{page:int}", fun p -> this.PublishedPostsPage (getPage <| downcast p))
|
|
||||||
this.Get ("/category/{slug}", fun p -> this.CategorizedPosts (downcast p))
|
|
||||||
this.Get ("/category/{slug}/page/{page:int}", fun p -> this.CategorizedPosts (downcast p))
|
|
||||||
this.Get ("/tag/{tag}", fun p -> this.TaggedPosts (downcast p))
|
|
||||||
this.Get ("/tag/{tag}/page/{page:int}", fun p -> this.TaggedPosts (downcast p))
|
|
||||||
this.Get ("/feed", fun _ -> this.Feed ())
|
|
||||||
this.Get ("/posts/list", fun _ -> this.PostList 1)
|
|
||||||
this.Get ("/posts/list/page/{page:int}", fun p -> this.PostList (getPage <| downcast p))
|
|
||||||
this.Get ("/post/{postId}/edit", fun p -> this.EditPost (downcast p))
|
|
||||||
this.Post ("/post/{postId}/edit", fun p -> this.SavePost (downcast p))
|
|
||||||
|
|
||||||
// ---- Display posts to users ----
|
|
||||||
|
|
||||||
/// Display a page of published posts
|
|
||||||
member this.PublishedPostsPage pageNbr : obj =
|
|
||||||
let model = PostsModel (this.Context, this.WebLog)
|
|
||||||
model.PageNbr <- pageNbr
|
|
||||||
model.Posts <- findPageOfPublishedPosts data this.WebLog.Id pageNbr 10 |> forDisplay
|
|
||||||
model.HasNewer <- match pageNbr with
|
|
||||||
| 1 -> false
|
|
||||||
| _ -> match List.isEmpty model.Posts with
|
|
||||||
| true -> false
|
|
||||||
| _ -> Option.isSome <| tryFindNewerPost data (List.last model.Posts).Post
|
|
||||||
model.HasOlder <- match List.isEmpty model.Posts with
|
|
||||||
| true -> false
|
|
||||||
| _ -> Option.isSome <| tryFindOlderPost data (List.head model.Posts).Post
|
|
||||||
model.UrlPrefix <- "/posts"
|
|
||||||
model.PageTitle <- match pageNbr with 1 -> "" | _ -> sprintf "%s%i" (Strings.get "PageHash") pageNbr
|
|
||||||
this.ThemedView "index" model
|
|
||||||
|
|
||||||
/// Display either the newest posts or the configured home page
|
|
||||||
member this.HomePage () : obj =
|
|
||||||
match this.WebLog.DefaultPage with
|
|
||||||
| "posts" -> this.PublishedPostsPage 1
|
|
||||||
| pageId ->
|
|
||||||
match tryFindPageWithoutRevisions data this.WebLog.Id pageId with
|
|
||||||
| Some page ->
|
|
||||||
let model = PageModel(this.Context, this.WebLog, page)
|
|
||||||
model.PageTitle <- page.Title
|
|
||||||
this.ThemedView "page" model
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Derive a post or page from the URL, or redirect from a prior URL to the current one
|
|
||||||
member this.CatchAll (parameters : DynamicDictionary) : obj =
|
|
||||||
let url = parameters.["permalink"].ToString ()
|
|
||||||
match tryFindPostByPermalink data this.WebLog.Id url with
|
|
||||||
| Some post -> // Hopefully the most common result; the permalink is a permalink!
|
|
||||||
let model = PostModel(this.Context, this.WebLog, post)
|
|
||||||
model.NewerPost <- tryFindNewerPost data post
|
|
||||||
model.OlderPost <- tryFindOlderPost data post
|
|
||||||
model.PageTitle <- post.Title
|
|
||||||
this.ThemedView "single" model
|
|
||||||
| _ -> // Maybe it's a page permalink instead...
|
|
||||||
match tryFindPageByPermalink data this.WebLog.Id url with
|
|
||||||
| Some page -> // ...and it is!
|
|
||||||
let model = PageModel (this.Context, this.WebLog, page)
|
|
||||||
model.PageTitle <- page.Title
|
|
||||||
this.ThemedView "page" model
|
|
||||||
| _ -> // Maybe it's an old permalink for a post
|
|
||||||
match tryFindPostByPriorPermalink data this.WebLog.Id url with
|
|
||||||
| Some post -> // Redirect them to the proper permalink
|
|
||||||
upcast this.Response.AsRedirect(sprintf "/%s" post.Permalink)
|
|
||||||
.WithStatusCode HttpStatusCode.MovedPermanently
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Display categorized posts
|
|
||||||
member this.CategorizedPosts (parameters : DynamicDictionary) : obj =
|
|
||||||
let slug = parameters.["slug"].ToString ()
|
|
||||||
match tryFindCategoryBySlug data this.WebLog.Id slug with
|
|
||||||
| Some cat ->
|
|
||||||
let pageNbr = getPage parameters
|
|
||||||
let model = PostsModel (this.Context, this.WebLog)
|
|
||||||
model.PageNbr <- pageNbr
|
|
||||||
model.Posts <- findPageOfCategorizedPosts data this.WebLog.Id cat.Id pageNbr 10 |> forDisplay
|
|
||||||
model.HasNewer <- match List.isEmpty model.Posts with
|
|
||||||
| true -> false
|
|
||||||
| _ -> Option.isSome <| tryFindNewerCategorizedPost data cat.Id
|
|
||||||
(List.head model.Posts).Post
|
|
||||||
model.HasOlder <- match List.isEmpty model.Posts with
|
|
||||||
| true -> false
|
|
||||||
| _ -> Option.isSome <| tryFindOlderCategorizedPost data cat.Id
|
|
||||||
(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)
|
|
||||||
model.Subtitle <- Some <| match cat.Description with
|
|
||||||
| Some desc -> desc
|
|
||||||
| _ -> sprintf "Posts in the \"%s\" category" cat.Name
|
|
||||||
this.ThemedView "index" model
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Display tagged posts
|
|
||||||
member this.TaggedPosts (parameters : DynamicDictionary) : obj =
|
|
||||||
let tag = parameters.["tag"].ToString ()
|
|
||||||
let pageNbr = getPage parameters
|
|
||||||
let model = PostsModel (this.Context, this.WebLog)
|
|
||||||
model.PageNbr <- pageNbr
|
|
||||||
model.Posts <- findPageOfTaggedPosts data this.WebLog.Id tag pageNbr 10 |> forDisplay
|
|
||||||
model.HasNewer <- match List.isEmpty model.Posts with
|
|
||||||
| true -> false
|
|
||||||
| _ -> Option.isSome <| tryFindNewerTaggedPost data tag (List.head model.Posts).Post
|
|
||||||
model.HasOlder <- match List.isEmpty model.Posts with
|
|
||||||
| true -> false
|
|
||||||
| _ -> Option.isSome <| tryFindOlderTaggedPost data 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
|
|
||||||
this.ThemedView "index" model
|
|
||||||
|
|
||||||
/// Generate an RSS feed
|
|
||||||
member this.Feed () : obj =
|
|
||||||
let query = this.Request.Query :?> DynamicDictionary
|
|
||||||
match query.ContainsKey "format" with
|
|
||||||
| true ->
|
|
||||||
match query.["format"].ToString () with
|
|
||||||
| x when x = "atom" || x = "rss" -> generateFeed x
|
|
||||||
| x when x = "rss2" -> generateFeed "rss"
|
|
||||||
| _ -> this.Redirect "/feed" (MyWebLogModel (this.Context, this.WebLog))
|
|
||||||
| _ -> generateFeed "rss"
|
|
||||||
|
|
||||||
// ---- Administer posts ----
|
|
||||||
|
|
||||||
/// Display a page of posts in the admin area
|
|
||||||
member this.PostList pageNbr : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let model = PostsModel (this.Context, this.WebLog)
|
|
||||||
model.PageNbr <- pageNbr
|
|
||||||
model.Posts <- findPageOfAllPosts data this.WebLog.Id pageNbr 25 |> forDisplay
|
|
||||||
model.HasNewer <- pageNbr > 1
|
|
||||||
model.HasOlder <- List.length model.Posts > 24
|
|
||||||
model.UrlPrefix <- "/posts/list"
|
|
||||||
model.PageTitle <- Strings.get "Posts"
|
|
||||||
upcast this.View.["admin/post/list", model]
|
|
||||||
|
|
||||||
/// Edit a post
|
|
||||||
member this.EditPost (parameters : DynamicDictionary) : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
let postId = parameters.["postId"].ToString ()
|
|
||||||
match postId with "new" -> Some Post.Empty | _ -> tryFindPost data this.WebLog.Id postId
|
|
||||||
|> function
|
|
||||||
| 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 <- findAllCategories data this.WebLog.Id
|
|
||||||
|> List.map (fun cat ->
|
|
||||||
DisplayCategory.Create cat (post.CategoryIds |> List.contains (fst cat).Id))
|
|
||||||
model.PageTitle <- Strings.get <| match post.Id with "new" -> "AddNewPost" | _ -> "EditPost"
|
|
||||||
upcast this.View.["admin/post/edit", model]
|
|
||||||
| _ -> this.NotFound ()
|
|
||||||
|
|
||||||
/// Save a post
|
|
||||||
member this.SavePost (parameters : DynamicDictionary) : obj =
|
|
||||||
this.RequiresAccessLevel AuthorizationLevel.Administrator
|
|
||||||
this.ValidateCsrfToken ()
|
|
||||||
let postId = parameters.["postId"].ToString ()
|
|
||||||
let form = this.Bind<EditPostForm> ()
|
|
||||||
let now = clock.GetCurrentInstant().ToUnixTimeTicks ()
|
|
||||||
match postId with "new" -> Some Post.Empty | _ -> tryFindPost data this.WebLog.Id postId
|
|
||||||
|> function
|
|
||||||
| Some p ->
|
|
||||||
let justPublished = p.PublishedOn = 0L && 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 | _ -> post.PublishedOn
|
|
||||||
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.sort
|
|
||||||
|> Seq.toList
|
|
||||||
Revisions = { AsOf = now
|
|
||||||
SourceType = form.Source
|
|
||||||
Text = form.Text } :: post.Revisions }
|
|
||||||
|> savePost data
|
|
||||||
let model = MyWebLogModel(this.Context, this.WebLog)
|
|
||||||
model.AddMessage
|
|
||||||
{ UserMessage.Empty with
|
|
||||||
Message = System.String.Format
|
|
||||||
(Strings.get "MsgPostEditSuccess",
|
|
||||||
Strings.get (match postId with "new" -> "Added" | _ -> "Updated"),
|
|
||||||
(match justPublished with true -> Strings.get "AndPublished" | _ -> ""))
|
|
||||||
}
|
|
||||||
this.Redirect (sprintf "/post/%s/edit" pId) model
|
|
||||||
| _ -> this.NotFound ()
|
|
|
@ -1,64 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Data
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Logic.User
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open Nancy.Authentication.Forms
|
|
||||||
open Nancy.Cryptography
|
|
||||||
open Nancy.ModelBinding
|
|
||||||
open Nancy.Security
|
|
||||||
open Nancy.Session.Persistable
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open System.Text
|
|
||||||
|
|
||||||
/// Handle /user URLs
|
|
||||||
type UserModule (data : IMyWebLogData, cfg : AppConfig) as this =
|
|
||||||
inherit NancyModule ("/user")
|
|
||||||
|
|
||||||
/// Hash the user's password
|
|
||||||
let pbkdf2 (pw : string) =
|
|
||||||
PassphraseKeyGenerator(pw, cfg.PasswordSalt, 4096).GetBytes 512
|
|
||||||
|> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) ""
|
|
||||||
|
|
||||||
do
|
|
||||||
this.Get ("/log-on", fun _ -> this.ShowLogOn ())
|
|
||||||
this.Post ("/log-on", fun p -> this.DoLogOn (downcast p))
|
|
||||||
this.Get ("/log-off", fun _ -> this.LogOff ())
|
|
||||||
|
|
||||||
/// Show the log on page
|
|
||||||
member this.ShowLogOn () : obj =
|
|
||||||
let model = LogOnModel (this.Context, this.WebLog)
|
|
||||||
let query = this.Request.Query :?> DynamicDictionary
|
|
||||||
model.Form.ReturnUrl <- match query.ContainsKey "returnUrl" with true -> query.["returnUrl"].ToString () | _ -> ""
|
|
||||||
model.PageTitle <- Strings.get "LogOn"
|
|
||||||
upcast this.View.["admin/user/log-on", model]
|
|
||||||
|
|
||||||
/// Process a user log on
|
|
||||||
member this.DoLogOn (parameters : DynamicDictionary) : obj =
|
|
||||||
this.ValidateCsrfToken ()
|
|
||||||
let form = this.Bind<LogOnForm> ()
|
|
||||||
let model = MyWebLogModel(this.Context, this.WebLog)
|
|
||||||
match tryUserLogOn data form.Email (pbkdf2 form.Password) with
|
|
||||||
| Some user ->
|
|
||||||
this.Session.[Keys.User] <- user
|
|
||||||
model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgLogOnSuccess" }
|
|
||||||
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
|
|
||||||
upcast this.LoginAndRedirect (System.Guid.Parse user.Id,
|
|
||||||
fallbackRedirectUrl = defaultArg (Option.ofObj form.ReturnUrl) "/")
|
|
||||||
| _ ->
|
|
||||||
{ UserMessage.Empty with
|
|
||||||
Level = Level.Error
|
|
||||||
Message = Strings.get "ErrBadLogOnAttempt" }
|
|
||||||
|> model.AddMessage
|
|
||||||
this.Redirect (sprintf "/user/log-on?returnUrl=%s" form.ReturnUrl) model
|
|
||||||
|
|
||||||
/// Log a user off
|
|
||||||
member this.LogOff () : obj =
|
|
||||||
this.Session.DeleteAll ()
|
|
||||||
let model = MyWebLogModel (this.Context, this.WebLog)
|
|
||||||
model.AddMessage { UserMessage.Empty with Message = Strings.get "MsgLogOffSuccess" }
|
|
||||||
this.Redirect "" model |> ignore
|
|
||||||
upcast this.LogoutAndRedirect "/"
|
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<PropertyGroup>
|
|
||||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
|
||||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
|
||||||
<PropertyGroup Label="Globals">
|
|
||||||
<ProjectGuid>9cea3a8b-e8aa-44e6-9f5f-2095ceed54eb</ProjectGuid>
|
|
||||||
<RootNamespace>Nancy.Session.Persistable</RootNamespace>
|
|
||||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
|
||||||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
|
||||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<SchemaVersion>2.0</SchemaVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
|
||||||
</Project>
|
|
|
@ -1,42 +0,0 @@
|
||||||
module MyWebLog.Resources.Strings
|
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open System.Collections.Generic
|
|
||||||
open System.Reflection
|
|
||||||
|
|
||||||
/// The locales we'll try to load
|
|
||||||
let private supportedLocales = [ "en-US" ]
|
|
||||||
|
|
||||||
/// The fallback locale, if a key is not found in a non-default locale
|
|
||||||
let private fallbackLocale = "en-US"
|
|
||||||
|
|
||||||
/// Get an embedded JSON file as a string
|
|
||||||
let private getEmbedded locale =
|
|
||||||
use rdr =
|
|
||||||
new System.IO.StreamReader
|
|
||||||
(typeof<AppConfig>.GetTypeInfo().Assembly.GetManifestResourceStream(sprintf "MyWebLog.App.%s.json" locale))
|
|
||||||
rdr.ReadToEnd()
|
|
||||||
|
|
||||||
/// The dictionary of localized strings
|
|
||||||
let private strings =
|
|
||||||
supportedLocales
|
|
||||||
|> List.map (fun loc -> loc, JsonConvert.DeserializeObject<Dictionary<string, string>>(getEmbedded loc))
|
|
||||||
|> dict
|
|
||||||
|
|
||||||
/// Get a key from the resources file for the given locale
|
|
||||||
let getForLocale locale key =
|
|
||||||
let getString thisLocale =
|
|
||||||
match strings.ContainsKey thisLocale with
|
|
||||||
| true -> match strings.[thisLocale].ContainsKey key with
|
|
||||||
| true -> Some strings.[thisLocale].[key]
|
|
||||||
| _ -> None
|
|
||||||
| _ -> None
|
|
||||||
match getString locale with
|
|
||||||
| Some xlat -> Some xlat
|
|
||||||
| _ when locale <> fallbackLocale -> getString fallbackLocale
|
|
||||||
| _ -> None
|
|
||||||
|> function Some xlat -> xlat | _ -> sprintf "%s.%s" locale key
|
|
||||||
|
|
||||||
/// Translate the key for the current locale
|
|
||||||
let get key = getForLocale System.Globalization.CultureInfo.CurrentCulture.Name key
|
|
|
@ -1,504 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
|
|
||||||
open MyWebLog.Entities
|
|
||||||
open MyWebLog.Logic.WebLog
|
|
||||||
open MyWebLog.Resources
|
|
||||||
open Nancy
|
|
||||||
open Nancy.Session.Persistable
|
|
||||||
open Newtonsoft.Json
|
|
||||||
open NodaTime
|
|
||||||
open NodaTime.Text
|
|
||||||
open System
|
|
||||||
open System.Net
|
|
||||||
|
|
||||||
/// Levels for a user message
|
|
||||||
[<RequireQualifiedAccess>]
|
|
||||||
module Level =
|
|
||||||
/// An informational message
|
|
||||||
let Info = "Info"
|
|
||||||
/// A message regarding a non-fatal but non-optimal condition
|
|
||||||
let Warning = "WARNING"
|
|
||||||
/// A message regarding a failure of the expected result
|
|
||||||
let Error = "ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
/// A message for the user
|
|
||||||
type UserMessage =
|
|
||||||
{ /// The level of the message (use Level module constants)
|
|
||||||
Level : string
|
|
||||||
/// The text of the message
|
|
||||||
Message : string
|
|
||||||
/// Further details regarding the message
|
|
||||||
Details : string option }
|
|
||||||
with
|
|
||||||
/// An empty message
|
|
||||||
static member Empty =
|
|
||||||
{ Level = Level.Info
|
|
||||||
Message = ""
|
|
||||||
Details = None }
|
|
||||||
|
|
||||||
/// Display version
|
|
||||||
[<JsonIgnore>]
|
|
||||||
member this.ToDisplay =
|
|
||||||
let classAndLabel =
|
|
||||||
dict [
|
|
||||||
Level.Error, ("danger", Strings.get "Error")
|
|
||||||
Level.Warning, ("warning", Strings.get "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 Strings.get "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 (+)
|
|
||||||
|
|
||||||
|
|
||||||
/// Helpers to format local date/time using NodaTime
|
|
||||||
module FormatDateTime =
|
|
||||||
|
|
||||||
/// Convert ticks to a zoned date/time
|
|
||||||
let zonedTime timeZone ticks = Instant.FromUnixTimeTicks(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) as this =
|
|
||||||
|
|
||||||
/// Get the messages from the session
|
|
||||||
let getMessages () =
|
|
||||||
let msg = ctx.Request.PersistableSession.GetOrDefault<UserMessage list> (Keys.Messages, [])
|
|
||||||
match List.length msg with
|
|
||||||
| 0 -> ()
|
|
||||||
| _ -> ctx.Request.Session.Delete Keys.Messages
|
|
||||||
msg
|
|
||||||
|
|
||||||
/// Generate a footer logo with the given scheme
|
|
||||||
let footerLogo scheme =
|
|
||||||
seq {
|
|
||||||
yield sprintf "<img src=\"/content/logo-%s.png\" alt=\"myWebLog\" title=\"" scheme
|
|
||||||
yield sprintf "%s %s • " (Strings.get "PoweredBy") this.Generator
|
|
||||||
yield Strings.get "LoadedIn"
|
|
||||||
yield " "
|
|
||||||
yield TimeSpan(System.DateTime.Now.Ticks - this.RequestStart).TotalSeconds.ToString "f3"
|
|
||||||
yield " "
|
|
||||||
yield (Strings.get "Seconds").ToLower ()
|
|
||||||
yield "\" height=\"30\" />"
|
|
||||||
}
|
|
||||||
|> Seq.reduce (+)
|
|
||||||
|
|
||||||
/// The web log for this request
|
|
||||||
member this.WebLog = webLog
|
|
||||||
/// The subtitle for the webLog (SSVE can't do IsSome that deep)
|
|
||||||
member this.WebLogSubtitle = defaultArg this.WebLog.Subtitle ""
|
|
||||||
/// User messages
|
|
||||||
member val Messages = getMessages () with get, set
|
|
||||||
/// The currently logged in user
|
|
||||||
member this.User = ctx.Request.PersistableSession.GetOrDefault<User> (Keys.User, User.Empty)
|
|
||||||
/// The title of the page
|
|
||||||
member val PageTitle = "" with get, set
|
|
||||||
/// The name and version of the application
|
|
||||||
member this.Generator = sprintf "myWebLog %s" (ctx.Items.[Keys.Version].ToString ())
|
|
||||||
/// The request start time
|
|
||||||
member this.RequestStart = ctx.Items.[Keys.RequestStart] :?> int64
|
|
||||||
/// Is a user authenticated for this request?
|
|
||||||
member this.IsAuthenticated = "" <> this.User.Id
|
|
||||||
/// Add a message to the output
|
|
||||||
member this.AddMessage message = this.Messages <- message :: this.Messages
|
|
||||||
|
|
||||||
/// Display a long date
|
|
||||||
member this.DisplayLongDate ticks = FormatDateTime.longDate this.WebLog.TimeZone ticks
|
|
||||||
/// Display a short date
|
|
||||||
member this.DisplayShortDate ticks = FormatDateTime.shortDate this.WebLog.TimeZone ticks
|
|
||||||
/// Display the time
|
|
||||||
member this.DisplayTime ticks = FormatDateTime.time this.WebLog.TimeZone ticks
|
|
||||||
/// The page title with the web log name appended
|
|
||||||
member this.DisplayPageTitle =
|
|
||||||
match this.PageTitle with
|
|
||||||
| "" ->
|
|
||||||
match this.WebLog.Subtitle with
|
|
||||||
| Some st -> sprintf "%s | %s" this.WebLog.Name st
|
|
||||||
| None -> this.WebLog.Name
|
|
||||||
| pt -> sprintf "%s | %s" pt this.WebLog.Name
|
|
||||||
|
|
||||||
/// An image with the version and load time in the tool tip (using light text)
|
|
||||||
member this.FooterLogoLight = footerLogo "light"
|
|
||||||
|
|
||||||
/// An image with the version and load time in the tool tip (using dark text)
|
|
||||||
member this.FooterLogoDark = footerLogo "dark"
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Admin models ----
|
|
||||||
|
|
||||||
/// Admin Dashboard view model
|
|
||||||
type DashboardModel (ctx, webLog, counts : DashboardCounts) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The number of posts for the current web log
|
|
||||||
member val Posts = counts.Posts with get, set
|
|
||||||
/// The number of pages for the current web log
|
|
||||||
member val Pages = counts.Pages with get, set
|
|
||||||
/// The number of categories for the current web log
|
|
||||||
member val Categories = counts.Categories with get, set
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Category models ----
|
|
||||||
|
|
||||||
type IndentedCategory =
|
|
||||||
{ Category : Category
|
|
||||||
Indent : int
|
|
||||||
Selected : bool }
|
|
||||||
with
|
|
||||||
/// Create an indented category
|
|
||||||
static member Create cat isSelected =
|
|
||||||
{ Category = fst cat
|
|
||||||
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
|
|
||||||
/// Display for this category as an option within a select box
|
|
||||||
member this.Option =
|
|
||||||
seq {
|
|
||||||
yield sprintf "<option value=\"%s\"" this.Category.Id
|
|
||||||
yield (match this.Selected with | true -> """ selected="selected">""" | _ -> ">")
|
|
||||||
yield String.replicate this.Indent " "
|
|
||||||
yield this.Category.Name
|
|
||||||
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) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The categories
|
|
||||||
member this.Categories : IndentedCategory list = categories
|
|
||||||
|
|
||||||
|
|
||||||
/// Form for editing a category
|
|
||||||
type CategoryForm (category : Category) =
|
|
||||||
new() = CategoryForm (Category.Empty)
|
|
||||||
/// The name of the category
|
|
||||||
member val Name = category.Name with get, set
|
|
||||||
/// The slug of the category (used in category URLs)
|
|
||||||
member val Slug = category.Slug with get, set
|
|
||||||
/// The description of the category
|
|
||||||
member val Description = defaultArg category.Description "" with get, set
|
|
||||||
/// The parent category for this one
|
|
||||||
member val ParentId = defaultArg category.ParentId "" with get, set
|
|
||||||
|
|
||||||
/// Model for editing a category
|
|
||||||
type CategoryEditModel (ctx, webLog, category) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The form with the category information
|
|
||||||
member val Form = CategoryForm (category) with get, set
|
|
||||||
/// The category being edited
|
|
||||||
member val Category = category
|
|
||||||
/// The categories
|
|
||||||
member val Categories : IndentedCategory list = [] with get, set
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Page models ----
|
|
||||||
|
|
||||||
/// Model for page display
|
|
||||||
type PageModel (ctx, webLog, page) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The page to be displayed
|
|
||||||
member this.Page : Page = page
|
|
||||||
|
|
||||||
|
|
||||||
/// Wrapper for a page with additional properties
|
|
||||||
type PageForDisplay (webLog, page) =
|
|
||||||
/// The page
|
|
||||||
member this.Page : Page = page
|
|
||||||
/// The time zone of the web log
|
|
||||||
member this.TimeZone = webLog.TimeZone
|
|
||||||
/// The date the page was last updated
|
|
||||||
member this.UpdatedDate = FormatDateTime.longDate this.TimeZone page.UpdatedOn
|
|
||||||
/// The time the page was last updated
|
|
||||||
member this.UpdatedTime = FormatDateTime.time this.TimeZone page.UpdatedOn
|
|
||||||
|
|
||||||
|
|
||||||
/// Model for page list display
|
|
||||||
type PagesModel (ctx, webLog, pages) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The pages
|
|
||||||
member this.Pages : PageForDisplay list = pages
|
|
||||||
|
|
||||||
|
|
||||||
/// Form used to edit a page
|
|
||||||
type EditPageForm() =
|
|
||||||
/// The title of the page
|
|
||||||
member val Title = "" with get, set
|
|
||||||
/// The link for the page
|
|
||||||
member val Permalink = "" with get, set
|
|
||||||
/// The source type of the revision
|
|
||||||
member val Source = "" with get, set
|
|
||||||
/// The text of the revision
|
|
||||||
member val Text = "" with get, set
|
|
||||||
/// Whether to show the page in the web log's page list
|
|
||||||
member val ShowInPageList = false with get, set
|
|
||||||
|
|
||||||
/// Fill the form with applicable values from a page
|
|
||||||
member this.ForPage (page : Page) =
|
|
||||||
this.Title <- page.Title
|
|
||||||
this.Permalink <- page.Permalink
|
|
||||||
this.ShowInPageList <- page.ShowInPageList
|
|
||||||
this
|
|
||||||
|
|
||||||
/// Fill the form with applicable values from a revision
|
|
||||||
member this.ForRevision rev =
|
|
||||||
this.Source <- rev.SourceType
|
|
||||||
this.Text <- rev.Text
|
|
||||||
this
|
|
||||||
|
|
||||||
|
|
||||||
/// Model for the edit page page
|
|
||||||
type EditPageModel (ctx, webLog, page, revision) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The page edit form
|
|
||||||
member val Form = EditPageForm().ForPage(page).ForRevision(revision)
|
|
||||||
/// The page itself
|
|
||||||
member this.Page = page
|
|
||||||
/// The page's published date
|
|
||||||
member this.PublishedDate = this.DisplayLongDate page.PublishedOn
|
|
||||||
/// The page's published time
|
|
||||||
member this.PublishedTime = this.DisplayTime page.PublishedOn
|
|
||||||
/// The page's last updated date
|
|
||||||
member this.LastUpdatedDate = this.DisplayLongDate page.UpdatedOn
|
|
||||||
/// The page's last updated time
|
|
||||||
member this.LastUpdatedTime = this.DisplayTime page.UpdatedOn
|
|
||||||
/// Is this a new page?
|
|
||||||
member this.IsNew = "new" = page.Id
|
|
||||||
/// Generate a checked attribute if this page shows in the page list
|
|
||||||
member this.PageListChecked = match page.ShowInPageList with true -> "checked=\"checked\"" | _ -> ""
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Post models ----
|
|
||||||
|
|
||||||
/// Formatter for comment information
|
|
||||||
type CommentForDisplay (comment : Comment, tz) =
|
|
||||||
/// The comment on which this model is based
|
|
||||||
member this.Comment = comment
|
|
||||||
/// The commentor (linked with a URL if there is one)
|
|
||||||
member this.Commentor =
|
|
||||||
match comment.Url with Some url -> sprintf "<a href=\"%s\">%s</a>" url comment.Name | _ -> comment.Name
|
|
||||||
/// The date/time this comment was posted
|
|
||||||
member this.CommentedOn =
|
|
||||||
sprintf "%s / %s" (FormatDateTime.longDate tz comment.PostedOn) (FormatDateTime.time tz comment.PostedOn)
|
|
||||||
|
|
||||||
/// Model for single post display
|
|
||||||
type PostModel (ctx, webLog, post) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The post being displayed
|
|
||||||
member this.Post : Post = post
|
|
||||||
/// The next newer post
|
|
||||||
member val NewerPost : Post option = None with get, set
|
|
||||||
/// The next older post
|
|
||||||
member val OlderPost : Post option = None with get, set
|
|
||||||
/// The date the post was published
|
|
||||||
member this.PublishedDate = this.DisplayLongDate this.Post.PublishedOn
|
|
||||||
/// The time the post was published
|
|
||||||
member this.PublishedTime = this.DisplayTime this.Post.PublishedOn
|
|
||||||
/// The number of comments
|
|
||||||
member this.CommentCount =
|
|
||||||
match post.Comments |> List.length with
|
|
||||||
| 0 -> Strings.get "NoComments"
|
|
||||||
| 1 -> Strings.get "OneComment"
|
|
||||||
| x -> String.Format (Strings.get "XComments", x)
|
|
||||||
/// The comments for display
|
|
||||||
member this.Comments = post.Comments
|
|
||||||
|> List.filter (fun c -> c.Status = CommentStatus.Approved)
|
|
||||||
|> List.map (fun c -> CommentForDisplay (c, webLog.TimeZone))
|
|
||||||
|
|
||||||
/// Does the post have tags?
|
|
||||||
member this.HasTags = not <| List.isEmpty post.Tags
|
|
||||||
/// Get the tags sorted
|
|
||||||
member this.Tags = post.Tags
|
|
||||||
|> List.sort
|
|
||||||
|> List.map (fun tag -> tag, tag.Replace(' ', '+'))
|
|
||||||
/// Does this post have a newer post?
|
|
||||||
member this.HasNewer = this.NewerPost.IsSome
|
|
||||||
/// Does this post have an older post?
|
|
||||||
member this.HasOlder = this.OlderPost.IsSome
|
|
||||||
|
|
||||||
|
|
||||||
/// 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 =
|
|
||||||
match this.Post.Status with
|
|
||||||
| PostStatus.Published -> FormatDateTime.longDate this.TimeZone this.Post.PublishedOn
|
|
||||||
| _ -> FormatDateTime.longDate this.TimeZone this.Post.UpdatedOn
|
|
||||||
/// The time the post was published
|
|
||||||
member this.PublishedTime =
|
|
||||||
match this.Post.Status with
|
|
||||||
| PostStatus.Published -> FormatDateTime.time this.TimeZone this.Post.PublishedOn
|
|
||||||
| _ -> FormatDateTime.time this.TimeZone this.Post.UpdatedOn
|
|
||||||
/// The number of comments
|
|
||||||
member this.CommentCount =
|
|
||||||
match post.Comments |> List.length with
|
|
||||||
| 0 -> Strings.get "NoComments"
|
|
||||||
| 1 -> Strings.get "OneComment"
|
|
||||||
| x -> String.Format (Strings.get "XComments", x)
|
|
||||||
/// 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(Strings.get "andXMore", count - 3))
|
|
||||||
|
|
||||||
|
|
||||||
/// Model for all page-of-posts pages
|
|
||||||
type PostsModel (ctx, webLog) =
|
|
||||||
inherit MyWebLogModel (ctx, webLog)
|
|
||||||
/// The subtitle for the page
|
|
||||||
member val Subtitle : string option = None with get, set
|
|
||||||
/// The posts to display
|
|
||||||
member val Posts : PostForDisplay list = [] with get, set
|
|
||||||
/// The page number of the post list
|
|
||||||
member val PageNbr = 0 with get, set
|
|
||||||
/// Whether there is a newer page of posts for the list
|
|
||||||
member val HasNewer = false with get, set
|
|
||||||
/// Whether there is an older page of posts for the list
|
|
||||||
member val HasOlder = true with get, set
|
|
||||||
/// The prefix for the next/prior links
|
|
||||||
member val UrlPrefix = "" with get, set
|
|
||||||
|
|
||||||
/// The link for the next newer page of posts
|
|
||||||
member this.NewerLink =
|
|
||||||
match this.UrlPrefix = "/posts" && this.PageNbr = 2 && this.WebLog.DefaultPage = "posts" with
|
|
||||||
| true -> "/"
|
|
||||||
| _ -> sprintf "%s/page/%i" this.UrlPrefix (this.PageNbr - 1)
|
|
||||||
|
|
||||||
/// The link for the prior (older) page of posts
|
|
||||||
member this.OlderLink = sprintf "%s/page/%i" this.UrlPrefix (this.PageNbr + 1)
|
|
||||||
|
|
||||||
|
|
||||||
/// 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 : string[] = [||] with get, set
|
|
||||||
/// Whether the post should be published
|
|
||||||
member val PublishNow = false with get, set
|
|
||||||
|
|
||||||
/// Fill the form with applicable values from a post
|
|
||||||
member this.ForPost (post : Post) =
|
|
||||||
this.Title <- post.Title
|
|
||||||
this.Permalink <- post.Permalink
|
|
||||||
this.Tags <- match List.isEmpty post.Tags with
|
|
||||||
| true -> ""
|
|
||||||
| _ -> List.reduce (fun acc x -> sprintf "%s, %s" acc x) post.Tags
|
|
||||||
this.Categories <- List.toArray post.CategoryIds
|
|
||||||
this.PublishNow <- post.Status = PostStatus.Published || "new" = post.Id
|
|
||||||
this
|
|
||||||
|
|
||||||
/// Fill the form with applicable values from a revision
|
|
||||||
member this.ForRevision rev =
|
|
||||||
this.Source <- rev.SourceType
|
|
||||||
this.Text <- rev.Text
|
|
||||||
this
|
|
||||||
|
|
||||||
/// Category information for display
|
|
||||||
type DisplayCategory = {
|
|
||||||
Id : string
|
|
||||||
Indent : string
|
|
||||||
Name : string
|
|
||||||
Description : string
|
|
||||||
IsChecked : bool
|
|
||||||
}
|
|
||||||
with
|
|
||||||
/// Create a display category
|
|
||||||
static member Create (cat : Category, indent) isChecked =
|
|
||||||
{ Id = cat.Id
|
|
||||||
Indent = String.replicate indent " "
|
|
||||||
Name = WebUtility.HtmlEncode cat.Name
|
|
||||||
IsChecked = isChecked
|
|
||||||
Description = WebUtility.HtmlEncode (match cat.Description with Some d -> d | _ -> cat.Name)
|
|
||||||
}
|
|
||||||
/// The "checked" attribute for this category
|
|
||||||
member this.CheckedAttr
|
|
||||||
with get() = match this.IsChecked with true -> "checked=\"checked\"" | _ -> ""
|
|
||||||
|
|
||||||
/// 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 : DisplayCategory list = [] 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
|
|
||||||
/// The "checked" attribute for the Publish Now box
|
|
||||||
member this.PublishNowCheckedAttr = match this.Form.PublishNow with true -> "checked=\"checked\"" | _ -> ""
|
|
||||||
|
|
||||||
|
|
||||||
// ---- User models ----
|
|
||||||
|
|
||||||
/// 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
|
|
|
@ -1,83 +0,0 @@
|
||||||
{
|
|
||||||
"Action": "Action",
|
|
||||||
"Added": "Added",
|
|
||||||
"AddNew": "Add New",
|
|
||||||
"AddNewCategory": "Add New Category",
|
|
||||||
"AddNewPage": "Add New Page",
|
|
||||||
"AddNewPost": "Add New Post",
|
|
||||||
"Admin": "Admin",
|
|
||||||
"AndPublished": " and Published",
|
|
||||||
"andXMore": "and {0} more...",
|
|
||||||
"at": "at",
|
|
||||||
"BackToCategoryList": "Back to Category List",
|
|
||||||
"BackToPageList": "Back to Page List",
|
|
||||||
"BackToPostList": "Back to Post List",
|
|
||||||
"Categories": "Categories",
|
|
||||||
"Category": "Category",
|
|
||||||
"CategoryDeleteWarning": "Are you sure you wish to delete the category",
|
|
||||||
"Close": "Close",
|
|
||||||
"Comments": "Comments",
|
|
||||||
"Dashboard": "Dashboard",
|
|
||||||
"Date": "Date",
|
|
||||||
"Delete": "Delete",
|
|
||||||
"Description": "Description",
|
|
||||||
"Edit": "Edit",
|
|
||||||
"EditCategory": "Edit Category",
|
|
||||||
"EditPage": "Edit Page",
|
|
||||||
"EditPost": "Edit Post",
|
|
||||||
"EmailAddress": "E-mail Address",
|
|
||||||
"ErrBadAppConfig": "Could not convert config.json to myWebLog configuration",
|
|
||||||
"ErrBadLogOnAttempt": "Invalid e-mail address or password",
|
|
||||||
"ErrDataConfig": "Could not convert data-config.json to RethinkDB connection",
|
|
||||||
"ErrNotConfigured": "is not properly configured for myWebLog",
|
|
||||||
"Error": "Error",
|
|
||||||
"LastUpdated": "Last Updated",
|
|
||||||
"LastUpdatedDate": "Last Updated Date",
|
|
||||||
"ListAll": "List All",
|
|
||||||
"LoadedIn": "Loaded in",
|
|
||||||
"LogOff": "Log Off",
|
|
||||||
"LogOn": "Log On",
|
|
||||||
"MsgCategoryDeleted": "Deleted category {0} successfully",
|
|
||||||
"MsgCategoryEditSuccess": "{0} category successfully",
|
|
||||||
"MsgLogOffSuccess": "Log off successful | Have a nice day!",
|
|
||||||
"MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!",
|
|
||||||
"MsgPageDeleted": "Deleted page successfully",
|
|
||||||
"MsgPageEditSuccess": "{0} page successfully",
|
|
||||||
"MsgPostEditSuccess": "{0}{1} post successfully",
|
|
||||||
"Name": "Name",
|
|
||||||
"NewerPosts": "Newer Posts",
|
|
||||||
"NextPost": "Next Post",
|
|
||||||
"NoComments": "No Comments",
|
|
||||||
"NoParent": "No Parent",
|
|
||||||
"OlderPosts": "Older Posts",
|
|
||||||
"OneComment": "1 Comment",
|
|
||||||
"PageDeleteWarning": "Are you sure you wish to delete the page",
|
|
||||||
"PageDetails": "Page Details",
|
|
||||||
"PageHash": "Page #",
|
|
||||||
"Pages": "Pages",
|
|
||||||
"ParentCategory": "Parent Category",
|
|
||||||
"Password": "Password",
|
|
||||||
"Permalink": "Permalink",
|
|
||||||
"PermanentLinkTo": "Permanent Link to",
|
|
||||||
"PostDetails": "Post Details",
|
|
||||||
"Posts": "Posts",
|
|
||||||
"PostsTagged": "Posts Tagged",
|
|
||||||
"PostStatus": "Post Status",
|
|
||||||
"PoweredBy": "Powered by",
|
|
||||||
"PreviousPost": "Previous Post",
|
|
||||||
"PublishedDate": "Published Date",
|
|
||||||
"PublishThisPost": "Publish This Post",
|
|
||||||
"Save": "Save",
|
|
||||||
"Seconds": "Seconds",
|
|
||||||
"ShowInPageList": "Show in Page List",
|
|
||||||
"Slug": "Slug",
|
|
||||||
"startingWith": "starting with",
|
|
||||||
"Status": "Status",
|
|
||||||
"Tags": "Tags",
|
|
||||||
"Time": "Time",
|
|
||||||
"Title": "Title",
|
|
||||||
"Updated": "Updated",
|
|
||||||
"View": "View",
|
|
||||||
"Warning": "Warning",
|
|
||||||
"XComments": "{0} Comments"
|
|
||||||
}
|
|
|
@ -1,64 +0,0 @@
|
||||||
{
|
|
||||||
"buildOptions": {
|
|
||||||
"compilerName": "fsc",
|
|
||||||
"compile": {
|
|
||||||
"includeFiles": [
|
|
||||||
"AssemblyInfo.fs",
|
|
||||||
"Entities/Entities.fs",
|
|
||||||
"Entities/IMyWebLogData.fs",
|
|
||||||
"Data/Extensions.fs",
|
|
||||||
"Data/Table.fs",
|
|
||||||
"Data/DataConfig.fs",
|
|
||||||
"Data/Category.fs",
|
|
||||||
"Data/Page.fs",
|
|
||||||
"Data/Post.fs",
|
|
||||||
"Data/User.fs",
|
|
||||||
"Data/WebLog.fs",
|
|
||||||
"Data/SetUp.fs",
|
|
||||||
"Data/RethinkMyWebLogData.fs",
|
|
||||||
"Logic/Category.fs",
|
|
||||||
"Logic/Page.fs",
|
|
||||||
"Logic/Post.fs",
|
|
||||||
"Logic/User.fs",
|
|
||||||
"Logic/WebLog.fs",
|
|
||||||
"Keys.fs",
|
|
||||||
"AppConfig.fs",
|
|
||||||
"Strings.fs",
|
|
||||||
"ViewModels.fs",
|
|
||||||
"Modules/ModuleExtensions.fs",
|
|
||||||
"Modules/AdminModule.fs",
|
|
||||||
"Modules/CategoryModule.fs",
|
|
||||||
"Modules/PageModule.fs",
|
|
||||||
"Modules/PostModule.fs",
|
|
||||||
"Modules/UserModule.fs",
|
|
||||||
"App.fs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"embed": {
|
|
||||||
"include": [ "en-US.json" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"Nancy": "2.0.0-barneyrubble",
|
|
||||||
"Nancy.Authentication.Forms": "2.0.0-barneyrubble",
|
|
||||||
"Nancy.Session.Persistable": "0.9.1-pre",
|
|
||||||
"Nancy.Session.RethinkDB": "0.9.1-pre",
|
|
||||||
"Newtonsoft.Json": "9.0.1",
|
|
||||||
"NodaTime": "2.0.0-alpha20160729",
|
|
||||||
"RethinkDb.Driver": "2.3.15",
|
|
||||||
"Suave": "2.0.0-rc2"
|
|
||||||
},
|
|
||||||
"frameworks": {
|
|
||||||
"netstandard1.6": {
|
|
||||||
"imports": "dnxcore50",
|
|
||||||
"dependencies": {
|
|
||||||
"Microsoft.FSharp.Core.netcore": "1.0.0-alpha-161111",
|
|
||||||
"NETStandard.Library": "1.6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"dotnet-compile-fsc": "1.0.0-preview2-*"
|
|
||||||
},
|
|
||||||
"version": "0.9.2"
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<PropertyGroup>
|
|
||||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
|
||||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
|
||||||
<PropertyGroup Label="Globals">
|
|
||||||
<ProjectGuid>B9F6DB52-65A1-4C2A-8C97-739E08A1D4FB</ProjectGuid>
|
|
||||||
<RootNamespace>MyWebLog</RootNamespace>
|
|
||||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
|
||||||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
|
||||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<SchemaVersion>2.0</SchemaVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
|
||||||
</Project>
|
|
|
@ -1,10 +0,0 @@
|
||||||
namespace MyWebLog
|
|
||||||
{
|
|
||||||
class Program
|
|
||||||
{
|
|
||||||
static void Main(string[] args)
|
|
||||||
{
|
|
||||||
App.Run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
[assembly: AssemblyTitle("MyWebLog")]
|
|
||||||
[assembly: AssemblyDescription("A lightweight blogging platform built on Nancy, and RethinkDB")]
|
|
||||||
[assembly: AssemblyConfiguration("")]
|
|
||||||
[assembly: AssemblyCompany("")]
|
|
||||||
[assembly: AssemblyProduct("MyWebLog")]
|
|
||||||
[assembly: AssemblyCopyright("Copyright © 2016")]
|
|
||||||
[assembly: AssemblyTrademark("")]
|
|
||||||
[assembly: AssemblyCulture("")]
|
|
||||||
[assembly: ComVisible(false)]
|
|
||||||
[assembly: Guid("b9f6db52-65a1-4c2a-8c97-739e08a1d4fb")]
|
|
||||||
[assembly: AssemblyVersion("0.9.2.0")]
|
|
||||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
// https://www.grc.com/passwords.htm is a great source of high-entropy passwords for these first 4 settings.
|
|
||||||
// Although what is there looks strong, keep in mind that it's what's in source control, so all instances of myWebLog
|
|
||||||
// could be using these values; that severly decreases their usefulness. :)
|
|
||||||
//
|
|
||||||
// WARNING: Changing this first one will render every single user's login inaccessible, including yours. Only do
|
|
||||||
// this if you are editing this file before setting up an instance, or if that is what you intend to do.
|
|
||||||
"password-salt": "3RVkw1jESpLFHr8F3WTThSbFnO3tFrMIckQsKzc9dymzEEXUoUS7nurF4rGpJ8Z",
|
|
||||||
// Changing any of these next 3 will render all current logins invalid, and the user will be force to reauthenticate.
|
|
||||||
"auth-salt": "2TweL5wcyGWg5CmMqZSZMonbe9xqQ2Q4vDNeysFRaUgVs4BpFZL85Iew79tn2IJ",
|
|
||||||
"encryption-passphrase": "jZjY6XyqUZypBcT0NaDXjEKc8xUjB4eb4V9EDHDedadRLuRUeRvIQx67yhx6bQP",
|
|
||||||
"hmac-passphrase": "42dzKb93X8YUkK8ms8JldjtkEvCKnPQGWCkO2yFaZ7lkNwECGCX00xzrx5ZSElO",
|
|
||||||
"data": {
|
|
||||||
"database": "myWebLog",
|
|
||||||
"hostname": "localhost"
|
|
||||||
}
|
|
||||||
}
|
|
Binary file not shown.
Binary file not shown.
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"buildOptions": {
|
|
||||||
"emitEntryPoint": true,
|
|
||||||
"copyToOutput": {
|
|
||||||
"include": [ "views", "content", "config.json" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"MyWebLog.App": "0.9.2",
|
|
||||||
},
|
|
||||||
"frameworks": {
|
|
||||||
"netcoreapp1.0": {
|
|
||||||
"dependencies": {
|
|
||||||
"Microsoft.NETCore.App": {
|
|
||||||
"type": "platform",
|
|
||||||
"version": "1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"imports": "dnxcore50"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"publishOptions": {
|
|
||||||
"include": [ "views", "content", "config.json" ]
|
|
||||||
},
|
|
||||||
"version": "0.9.2"
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<title>@Model.PageTitle | @Translate.Admin | @Model.WebLog.Name</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.css" />
|
|
||||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootswatch/3.3.4/cosmo/bootstrap.min.css" />
|
|
||||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" />
|
|
||||||
<link rel="stylesheet" type="text/css" href="/admin/content/admin.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav class="navbar navbar-inverse">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="navbar-header">
|
|
||||||
<a class="navbar-brand" href="/">@Model.WebLog.Name</a>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-left">
|
|
||||||
<p class="navbar-text">@Model.PageTitle</p>
|
|
||||||
</div>
|
|
||||||
<ul class="nav navbar-nav navbar-right">
|
|
||||||
@If.IsAuthenticated
|
|
||||||
<li><a href="/admin">@Translate.Dashboard</a></li>
|
|
||||||
<li><a href="/user/log-off">@Translate.LogOff</a></li>
|
|
||||||
@EndIf
|
|
||||||
@IfNot.IsAuthenticated
|
|
||||||
<li><a href="/user/log-on">@Translate.LogOn</a></li>
|
|
||||||
@EndIf
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="container">
|
|
||||||
@Each.Messages
|
|
||||||
@Current.ToDisplay
|
|
||||||
@EndEach
|
|
||||||
@Section['Content'];
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12 text-right">@Model.FooterLogoLight </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
<script type="text/javascript" src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.3.min.js"></script>
|
|
||||||
<script type="text/javascript" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
|
||||||
<script type="text/javascript" src="//cdn.tinymce.com/4/tinymce.min.js"></script>
|
|
||||||
@Section['Scripts'];
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,55 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<form action="/category/@Model.Category.Id/edit" method="post">
|
|
||||||
@AntiForgeryToken
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12">
|
|
||||||
<a href="/categories" class="btn btn-default">
|
|
||||||
<i class="fa fa-list-ul"></i> @Translate.BackToCategoryList
|
|
||||||
</a>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label" for="Name">@Translate.Name</label>
|
|
||||||
<input type="text" class="form-control" id="Name" name="Name" value="@Model.Form.Name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-8">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label" for="Slug">@Translate.Slug</label>
|
|
||||||
<input type="text" class="form-control" id="Slug" name="Slug" value="@Model.Form.Slug" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label" for="Description">@Translate.Description</label>
|
|
||||||
<textarea class="form-control" rows="4" id="Description" name="Description">@Model.Form.Description</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-4">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label" for="ParentId">@Translate.ParentCategory</label>
|
|
||||||
<select class="form-control" id="ParentId" name="ParentId">
|
|
||||||
<option value="">— @Translate.NoParent —</option>
|
|
||||||
@Each.Categories
|
|
||||||
@Current.Option
|
|
||||||
@EndEach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<p class="text-center">
|
|
||||||
<button class="btn btn-primary" type="submit">
|
|
||||||
<i class="fa fa-floppy-o"></i> @Translate.Save
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Scripts']
|
|
||||||
<script type="text/javascript">
|
|
||||||
/* <![CDATA[ */
|
|
||||||
$(document).ready(function () { $("#Name").focus() })
|
|
||||||
/* ]] */
|
|
||||||
</script>
|
|
||||||
@EndSection
|
|
|
@ -1,51 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@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>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<tr>
|
|
||||||
<th>@Translate.Action</th>
|
|
||||||
<th>@Translate.Category</th>
|
|
||||||
<th>@Translate.Description</th>
|
|
||||||
</tr>
|
|
||||||
@Each.Categories
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="/category/@Current.Category.Id/edit">@Translate.Edit</a>
|
|
||||||
<a href="javascript:void(0)" onclick="deleteCategory('@Current.Category.Id', '@Current.Category.Name')">
|
|
||||||
@Translate.Delete
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>@Current.ListName</td>
|
|
||||||
<td>
|
|
||||||
@If.HasDescription
|
|
||||||
@Current.Category.Description.Value
|
|
||||||
@EndIf
|
|
||||||
@IfNot.HasDescription
|
|
||||||
|
|
||||||
@EndIf
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@EndEach
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<form method="post" id="deleteForm">
|
|
||||||
@AntiForgeryToken
|
|
||||||
</form>
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Scripts']
|
|
||||||
<script type="text/javascript">
|
|
||||||
/* <![CDATA[ */
|
|
||||||
function deleteCategory(id, title) {
|
|
||||||
if (confirm('@Translate.CategoryDeleteWarning "' + title + '"?')) {
|
|
||||||
document.getElementById("deleteForm").action = "/category/" + id + "/delete"
|
|
||||||
document.getElementById("deleteForm").submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* ]] */
|
|
||||||
</script>
|
|
||||||
@EndSection
|
|
|
@ -1,5 +0,0 @@
|
||||||
footer {
|
|
||||||
background-color: #808080;
|
|
||||||
border-top: solid 1px black;
|
|
||||||
color: white;
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
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"
|
|
||||||
})
|
|
|
@ -1,31 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-4 text-center">
|
|
||||||
<h3>@Translate.Posts <span class="badge">@Model.Posts</span></h3>
|
|
||||||
<p>
|
|
||||||
<a href="/posts/list"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
|
||||||
|
|
||||||
<a href="/post/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-4 text-center">
|
|
||||||
<h3>@Translate.Pages <span class="badge">@Model.Pages</span></h3>
|
|
||||||
<p>
|
|
||||||
<a href="/pages"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
|
|
||||||
|
|
||||||
<a href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-4 text-center">
|
|
||||||
<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="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
@EndSection
|
|
|
@ -1,61 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<form action="/page/@Model.Page.Id/edit" method="post">
|
|
||||||
@AntiForgeryToken
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<a href="/pages" class="btn btn-default">
|
|
||||||
<i class="fa fa-list-ul"></i> @Translate.BackToPageList
|
|
||||||
</a>
|
|
||||||
<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" />
|
|
||||||
<p class="form-hint"><em>@Translate.startingWith</em> //@Model.WebLog.UrlBase/</p>
|
|
||||||
</div>
|
|
||||||
<!-- // TODO: Markdown / HTML choice -->
|
|
||||||
<input type="hidden" name="Source" value="html" />
|
|
||||||
<div class="form-group">
|
|
||||||
<textarea name="Text" id="Text" rows="15" class="form-control">@Model.Form.Text</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-3">
|
|
||||||
<div class="panel panel-default">
|
|
||||||
<div class="panel-heading">@Translate.PageDetails</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
@IfNot.isNew
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">@Translate.PublishedDate</label>
|
|
||||||
<p class="static-control">@Model.PublishedDate<br />@Model.PublishedTime</p>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="control-label">@Translate.LastUpdatedDate</label>
|
|
||||||
<p class="static-control">@Model.LastUpdatedDate<br />@Model.LastUpdatedTime</p>
|
|
||||||
</div>
|
|
||||||
@EndIf
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="checkbox" name="ShowInPageList" id="ShowInPageList" value="true" @Model.PageListChecked />
|
|
||||||
<label for="ShowInPageList">@Translate.ShowInPageList</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p><button class="btn btn-primary" type="submit"><i class="fa fa-floppy-o"></i> @Translate.Save</button></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Scripts']
|
|
||||||
<script type="text/javascript" src="/admin/content/tinymce-init.js"></script>
|
|
||||||
<script type="text/javascript">
|
|
||||||
/* <![CDATA[ */
|
|
||||||
$(document).ready(function () { $("#Title").focus() })
|
|
||||||
/* ]]> */
|
|
||||||
</script>
|
|
||||||
@EndSection
|
|
|
@ -1,42 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<div class="row">
|
|
||||||
<p><a class="btn btn-primary" href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<tr>
|
|
||||||
<th>@Translate.Title</th>
|
|
||||||
<th>@Translate.LastUpdated</th>
|
|
||||||
</tr>
|
|
||||||
@Each.Pages
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
@Current.Page.Title<br />
|
|
||||||
<a href="/@Current.Page.Permalink">@Translate.View</a>
|
|
||||||
<a href="/page/@Current.Page.Id/edit">@Translate.Edit</a>
|
|
||||||
<a href="javascript:void(0)" onclick="deletePage('@Current.Page.Id', '@Current.Page.Title')">@Translate.Delete</a>
|
|
||||||
</td>
|
|
||||||
<td>@Current.UpdatedDate<br />@Translate.at @Current.UpdatedTime</td>
|
|
||||||
</tr>
|
|
||||||
@EndEach
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<form method="delete" id="deleteForm">
|
|
||||||
@AntiForgeryToken
|
|
||||||
</form>
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Scripts']
|
|
||||||
<script type="text/javascript">
|
|
||||||
/* <![CDATA[ */
|
|
||||||
function deletePage(id, title) {
|
|
||||||
if (confirm('@Translate.PageDeleteWarning "' + title + '"?')) {
|
|
||||||
document.getElementById("deleteForm").action = "/page/" + id + "/delete"
|
|
||||||
document.getElementById("deleteForm").submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* ]] */
|
|
||||||
</script>
|
|
||||||
@EndSection
|
|
|
@ -1,90 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<form action='/post/@Model.Post.Id/edit' method="post">
|
|
||||||
@AntiForgeryToken
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<a href="/posts/list" class="btn btn-default">
|
|
||||||
<i class="fa fa-list-ul"></i> @Translate.BackToPostList
|
|
||||||
</a>
|
|
||||||
<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" />
|
|
||||||
<p class="form-hint"><em>@Translate.startingWith</em> //@Model.WebLog.UrlBase/ </p>
|
|
||||||
</div>
|
|
||||||
<!-- // TODO: Markdown / HTML choice -->
|
|
||||||
<input type="hidden" name="Source" value="html" />
|
|
||||||
<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;">
|
|
||||||
@Each.Categories
|
|
||||||
@Current.Indent
|
|
||||||
<input type="checkbox" id="Category-@Current.Id" name="Categories" value="@Current.Id" @Current.CheckedAttr />
|
|
||||||
|
|
||||||
<label for="Category-@Current.Id" title="@Current.Description">@Current.Name</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" @Model.PublishNowCheckedAttr />
|
|
||||||
<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="/admin/content/tinymce-init.js"></script>
|
|
||||||
<script type="text/javascript">
|
|
||||||
/** <![CDATA[ */
|
|
||||||
$(document).ready(function () { $("#Title").focus() })
|
|
||||||
/** ]]> */
|
|
||||||
</script>
|
|
||||||
@EndSection
|
|
|
@ -1,49 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<div class="row">
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-primary" href="/post/new/edit">
|
|
||||||
<i class="fa fa-plus"></i> @Translate.AddNew
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<tr>
|
|
||||||
<th>@Translate.Date</th>
|
|
||||||
<th>@Translate.Title</th>
|
|
||||||
<th>@Translate.Status</th>
|
|
||||||
<th>@Translate.Tags</th>
|
|
||||||
</tr>
|
|
||||||
@Each.Posts
|
|
||||||
<tr>
|
|
||||||
<td style="white-space:nowrap;">
|
|
||||||
@Current.PublishedDate<br />
|
|
||||||
@Translate.at @Current.PublishedTime
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@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>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-3 col-xs-offset-2">
|
|
||||||
@If.HasNewer
|
|
||||||
<p><a class="btn btn-default" href="@Model.NewerLink">« @Translate.NewerPosts</a></p>
|
|
||||||
@EndIf
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-3 col-xs-offset-1 text-right">
|
|
||||||
@If.HasOlder
|
|
||||||
<p><a class="btn btn-default" href="@Model.OlderLink">@Translate.OlderPosts »</a></p>
|
|
||||||
@EndIf
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@EndSection
|
|
|
@ -1,41 +0,0 @@
|
||||||
@Master['admin/admin-layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
<form action="/user/log-on" method="post">
|
|
||||||
@AntiForgeryToken
|
|
||||||
<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">
|
|
||||||
<span class="input-group-addon" title="@Translate.EmailAddress"><i class="fa fa-envelope"></i></span>
|
|
||||||
<input type="text" name="Email" id="Email" class="form-control" placeholder="@Translate.EmailAddress" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-offset-1 col-sm-8 col-md-offset-3 col-md-6">
|
|
||||||
<br />
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-addon" title="@Translate.Password"><i class="fa fa-key"></i></span>
|
|
||||||
<input type="password" name="Password" class="form-control" placeholder="@Translate.Password" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12 text-center">
|
|
||||||
<p>
|
|
||||||
<br />
|
|
||||||
<button class="btn btn-primary"><i class="fa fa-sign-in"></i> @Translate.LogOn</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Scripts']
|
|
||||||
<script type="text/javascript">
|
|
||||||
/* <![CDATA[ */
|
|
||||||
$(document).ready(function () { $("#Email").focus() })
|
|
||||||
/* ]]> */
|
|
||||||
</script>
|
|
||||||
@EndSection
|
|
|
@ -1,4 +0,0 @@
|
||||||
<h4>
|
|
||||||
@Model.Commentor <small>@Model.CommentedOn</small>
|
|
||||||
</h4>
|
|
||||||
@Model.Comment.Text
|
|
|
@ -1,476 +0,0 @@
|
||||||
/*!
|
|
||||||
* Bootstrap v3.3.4 (http://getbootstrap.com)
|
|
||||||
* Copyright 2011-2015 Twitter, Inc.
|
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
|
||||||
*/
|
|
||||||
|
|
||||||
.btn-default,
|
|
||||||
.btn-primary,
|
|
||||||
.btn-success,
|
|
||||||
.btn-info,
|
|
||||||
.btn-warning,
|
|
||||||
.btn-danger {
|
|
||||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
|
|
||||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.btn-default:active,
|
|
||||||
.btn-primary:active,
|
|
||||||
.btn-success:active,
|
|
||||||
.btn-info:active,
|
|
||||||
.btn-warning:active,
|
|
||||||
.btn-danger:active,
|
|
||||||
.btn-default.active,
|
|
||||||
.btn-primary.active,
|
|
||||||
.btn-success.active,
|
|
||||||
.btn-info.active,
|
|
||||||
.btn-warning.active,
|
|
||||||
.btn-danger.active {
|
|
||||||
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
|
||||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
|
|
||||||
}
|
|
||||||
.btn-default .badge,
|
|
||||||
.btn-primary .badge,
|
|
||||||
.btn-success .badge,
|
|
||||||
.btn-info .badge,
|
|
||||||
.btn-warning .badge,
|
|
||||||
.btn-danger .badge {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
.btn:active,
|
|
||||||
.btn.active {
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-default {
|
|
||||||
text-shadow: 0 1px 0 #fff;
|
|
||||||
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
|
|
||||||
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #dbdbdb;
|
|
||||||
border-color: #ccc;
|
|
||||||
}
|
|
||||||
.btn-default:hover,
|
|
||||||
.btn-default:focus {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-default:active,
|
|
||||||
.btn-default.active {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
border-color: #dbdbdb;
|
|
||||||
}
|
|
||||||
.btn-default.disabled,
|
|
||||||
.btn-default:disabled,
|
|
||||||
.btn-default[disabled] {
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-primary {
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #245580;
|
|
||||||
}
|
|
||||||
.btn-primary:hover,
|
|
||||||
.btn-primary:focus {
|
|
||||||
background-color: #265a88;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-primary:active,
|
|
||||||
.btn-primary.active {
|
|
||||||
background-color: #265a88;
|
|
||||||
border-color: #245580;
|
|
||||||
}
|
|
||||||
.btn-primary.disabled,
|
|
||||||
.btn-primary:disabled,
|
|
||||||
.btn-primary[disabled] {
|
|
||||||
background-color: #265a88;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-success {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
|
|
||||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #3e8f3e;
|
|
||||||
}
|
|
||||||
.btn-success:hover,
|
|
||||||
.btn-success:focus {
|
|
||||||
background-color: #419641;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-success:active,
|
|
||||||
.btn-success.active {
|
|
||||||
background-color: #419641;
|
|
||||||
border-color: #3e8f3e;
|
|
||||||
}
|
|
||||||
.btn-success.disabled,
|
|
||||||
.btn-success:disabled,
|
|
||||||
.btn-success[disabled] {
|
|
||||||
background-color: #419641;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-info {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
|
|
||||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #28a4c9;
|
|
||||||
}
|
|
||||||
.btn-info:hover,
|
|
||||||
.btn-info:focus {
|
|
||||||
background-color: #2aabd2;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-info:active,
|
|
||||||
.btn-info.active {
|
|
||||||
background-color: #2aabd2;
|
|
||||||
border-color: #28a4c9;
|
|
||||||
}
|
|
||||||
.btn-info.disabled,
|
|
||||||
.btn-info:disabled,
|
|
||||||
.btn-info[disabled] {
|
|
||||||
background-color: #2aabd2;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-warning {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
|
|
||||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #e38d13;
|
|
||||||
}
|
|
||||||
.btn-warning:hover,
|
|
||||||
.btn-warning:focus {
|
|
||||||
background-color: #eb9316;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-warning:active,
|
|
||||||
.btn-warning.active {
|
|
||||||
background-color: #eb9316;
|
|
||||||
border-color: #e38d13;
|
|
||||||
}
|
|
||||||
.btn-warning.disabled,
|
|
||||||
.btn-warning:disabled,
|
|
||||||
.btn-warning[disabled] {
|
|
||||||
background-color: #eb9316;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.btn-danger {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
|
|
||||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #b92c28;
|
|
||||||
}
|
|
||||||
.btn-danger:hover,
|
|
||||||
.btn-danger:focus {
|
|
||||||
background-color: #c12e2a;
|
|
||||||
background-position: 0 -15px;
|
|
||||||
}
|
|
||||||
.btn-danger:active,
|
|
||||||
.btn-danger.active {
|
|
||||||
background-color: #c12e2a;
|
|
||||||
border-color: #b92c28;
|
|
||||||
}
|
|
||||||
.btn-danger.disabled,
|
|
||||||
.btn-danger:disabled,
|
|
||||||
.btn-danger[disabled] {
|
|
||||||
background-color: #c12e2a;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
.thumbnail,
|
|
||||||
.img-thumbnail {
|
|
||||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.dropdown-menu > li > a:hover,
|
|
||||||
.dropdown-menu > li > a:focus {
|
|
||||||
background-color: #e8e8e8;
|
|
||||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
|
||||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.dropdown-menu > .active > a,
|
|
||||||
.dropdown-menu > .active > a:hover,
|
|
||||||
.dropdown-menu > .active > a:focus {
|
|
||||||
background-color: #2e6da4;
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.navbar-default {
|
|
||||||
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
|
|
||||||
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-radius: 4px;
|
|
||||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.navbar-default .navbar-nav > .open > a,
|
|
||||||
.navbar-default .navbar-nav > .active > a {
|
|
||||||
background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
|
|
||||||
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.navbar-brand,
|
|
||||||
.navbar-nav > li > a {
|
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
|
|
||||||
}
|
|
||||||
.navbar-inverse {
|
|
||||||
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
|
|
||||||
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.navbar-inverse .navbar-nav > .open > a,
|
|
||||||
.navbar-inverse .navbar-nav > .active > a {
|
|
||||||
background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
|
|
||||||
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
|
||||||
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
|
|
||||||
}
|
|
||||||
.navbar-inverse .navbar-brand,
|
|
||||||
.navbar-inverse .navbar-nav > li > a {
|
|
||||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
|
|
||||||
}
|
|
||||||
.navbar-static-top,
|
|
||||||
.navbar-fixed-top,
|
|
||||||
.navbar-fixed-bottom {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.navbar .navbar-nav .open .dropdown-menu > .active > a,
|
|
||||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
|
|
||||||
.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
|
|
||||||
color: #fff;
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert {
|
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
|
|
||||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
}
|
|
||||||
.alert-success {
|
|
||||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
|
|
||||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #b2dba1;
|
|
||||||
}
|
|
||||||
.alert-info {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
|
|
||||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #9acfea;
|
|
||||||
}
|
|
||||||
.alert-warning {
|
|
||||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
|
|
||||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #f5e79e;
|
|
||||||
}
|
|
||||||
.alert-danger {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
|
|
||||||
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #dca7a7;
|
|
||||||
}
|
|
||||||
.progress {
|
|
||||||
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
|
|
||||||
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-success {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
|
|
||||||
background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-info {
|
|
||||||
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
|
|
||||||
background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-warning {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
|
|
||||||
background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-danger {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
|
|
||||||
background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.progress-bar-striped {
|
|
||||||
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
|
||||||
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
|
||||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
|
|
||||||
}
|
|
||||||
.list-group {
|
|
||||||
border-radius: 4px;
|
|
||||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
|
|
||||||
}
|
|
||||||
.list-group-item.active,
|
|
||||||
.list-group-item.active:hover,
|
|
||||||
.list-group-item.active:focus {
|
|
||||||
text-shadow: 0 -1px 0 #286090;
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #2b669a;
|
|
||||||
}
|
|
||||||
.list-group-item.active .badge,
|
|
||||||
.list-group-item.active:hover .badge,
|
|
||||||
.list-group-item.active:focus .badge {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
|
|
||||||
}
|
|
||||||
.panel-default > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
|
|
||||||
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-primary > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
|
|
||||||
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-success > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
|
|
||||||
background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-info > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
|
|
||||||
background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-warning > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
|
|
||||||
background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.panel-danger > .panel-heading {
|
|
||||||
background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
|
|
||||||
background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
}
|
|
||||||
.well {
|
|
||||||
background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
|
||||||
background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
|
|
||||||
background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
|
|
||||||
background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
|
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
|
|
||||||
background-repeat: repeat-x;
|
|
||||||
border-color: #dcdcdc;
|
|
||||||
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
|
|
||||||
}
|
|
||||||
/*# sourceMappingURL=bootstrap-theme.css.map */
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,10 +0,0 @@
|
||||||
<footer>
|
|
||||||
<hr />
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12 text-right">
|
|
||||||
@Model.FooterLogoDark
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
|
@ -1,43 +0,0 @@
|
||||||
@Each.Messages
|
|
||||||
@Current.ToDisplay
|
|
||||||
@EndEach
|
|
||||||
@If.SubTitle.IsSome
|
|
||||||
<h2>
|
|
||||||
<span class="label label-info">@Model.SubTitle</span>
|
|
||||||
</h2>
|
|
||||||
@EndIf
|
|
||||||
@Each.Posts
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12">
|
|
||||||
<article>
|
|
||||||
<h1>
|
|
||||||
<a href="/@Current.Post.Permalink"
|
|
||||||
title="@Translate.PermanentLinkTo "@Current.Post.Title"">@Current.Post.Title</a>
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
<i class="fa fa-calendar" title="@Translate.Date"></i> @Current.PublishedDate
|
|
||||||
<i class="fa fa-clock-o" title="@Translate.Time"></i> @Current.PublishedTime
|
|
||||||
<i class="fa fa-comments-o" title="@Translate.Comments"></i> @Current.CommentCount
|
|
||||||
</p>
|
|
||||||
@Current.Post.Text
|
|
||||||
</article>
|
|
||||||
<hr />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@EndEach
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-3 col-xs-offset-3">
|
|
||||||
@If.HasNewer
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-primary" href="@Model.NewerLink">@Translate.NewerPosts</a>
|
|
||||||
</p>
|
|
||||||
@EndIf
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-3 text-right">
|
|
||||||
@If.HasOlder
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-primary" href="@Model.OlderLink">@Translate.OlderPosts</a>
|
|
||||||
</p>
|
|
||||||
@EndIf
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,9 +0,0 @@
|
||||||
@Master['themes/default/layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
@Partial['themes/default/index-content', Model]
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Footer']
|
|
||||||
@Partial['themes/default/footer', Model]
|
|
||||||
@EndSection
|
|
|
@ -1,48 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta name="generator" content="@Model.Generator" />
|
|
||||||
<title>@Model.DisplayPageTitle</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" />
|
|
||||||
<link rel="stylesheet" type="text/css" href="/default/bootstrap-theme.min.css" />
|
|
||||||
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css" />
|
|
||||||
<link rel="alternate" type="application/atom+xml" href="//@Model.WebLog.UrlBase/feed?format=atom" />
|
|
||||||
<link rel="alternate" type="application/rss+xml" href="//@Model.WebLog.UrlBase/feed" />
|
|
||||||
@Section['Head'];
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav class="navbar navbar-default">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="navbar-header">
|
|
||||||
<a class="navbar-brand" href="/">@Model.WebLog.Name</a>
|
|
||||||
</div>
|
|
||||||
<p class="navbar-text">@Model.WebLogSubtitle</p>
|
|
||||||
<ul class="nav navbar-nav navbar-left">
|
|
||||||
@Each.WebLog.PageList
|
|
||||||
<li><a href="/@Current.Permalink">@Current.Title</a></li>
|
|
||||||
@EndEach
|
|
||||||
</ul>
|
|
||||||
<ul class="nav navbar-nav navbar-right">
|
|
||||||
@If.IsAuthenticated
|
|
||||||
<li><a href="/admin">@Translate.Dashboard</a></li>
|
|
||||||
<li><a href="/user/log-off">@Translate.LogOff</a></li>
|
|
||||||
@EndIf
|
|
||||||
@IfNot.IsAuthenticated
|
|
||||||
<li><a href="/user/log-on">@Translate.LogOn</a></li>
|
|
||||||
@EndIf
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="container">
|
|
||||||
@Section['Content'];
|
|
||||||
</div>
|
|
||||||
@Section['Footer'];
|
|
||||||
<script type="text/javascript" src="//ajax.aspnetcdn.com/ajax/jQuery/jquery-2.1.3.min.js"></script>
|
|
||||||
<script type="text/javascript" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
|
|
||||||
@Section['Scripts'];
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,4 +0,0 @@
|
||||||
<article>
|
|
||||||
<h1>@Model.Page.Title</h1>
|
|
||||||
@Model.Page.Text
|
|
||||||
</article>
|
|
|
@ -1,9 +0,0 @@
|
||||||
@Master['themes/default/layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
@Partial['themes/default/page-content', Model]
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Footer']
|
|
||||||
@Partial['themes/default/footer', Model]
|
|
||||||
@EndSection
|
|
|
@ -1,67 +0,0 @@
|
||||||
<article>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12"><h1>@Model.Post.Title</h1></div>
|
|
||||||
</div>
|
|
||||||
<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-comments-o" title="@Translate.Comments"></i> @Model.CommentCount
|
|
||||||
@Each.Post.Categories
|
|
||||||
<span style="white-space:nowrap;">
|
|
||||||
<i class="fa fa-folder-open-o" title="@Translate.Category"></i>
|
|
||||||
<a href="/category/@Current.Slug" title="@Translate.CategorizedUnder @Current.Name">@Current.Name</a>
|
|
||||||
|
|
||||||
</span>
|
|
||||||
@EndEach
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12">@Model.Post.Text</div>
|
|
||||||
</div>
|
|
||||||
@If.HasTags
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12">
|
|
||||||
@Each.Tags
|
|
||||||
<span style="white-space:nowrap;">
|
|
||||||
<a href="/tag/@Current.Item2" title="@Translate.PostsTagged "@Current.Item1"">
|
|
||||||
<i class="fa fa-tag"></i> @Current.Item1
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
@EndEach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@EndIf
|
|
||||||
</article>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12"><hr /></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12">
|
|
||||||
@Each.Comments
|
|
||||||
@Partial['themes/default/comment', @Current]
|
|
||||||
@EndEach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-12"><hr /></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-6">
|
|
||||||
@If.HasNewer
|
|
||||||
<a href="/@Model.NewerPost.Value.Permalink" title="@Translate.NextPost - "@Model.NewerPost.Value.Title"">
|
|
||||||
« @Model.NewerPost.Value.Title
|
|
||||||
</a>
|
|
||||||
@EndIf
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-6 text-right">
|
|
||||||
@If.HasOlder
|
|
||||||
<a href="/@Model.OlderPost.Value.Permalink"
|
|
||||||
title="@Translate.PreviousPost - "@Model.OlderPost.Value.Title"">
|
|
||||||
@Model.OlderPost.Value.Title »
|
|
||||||
</a>
|
|
||||||
@EndIf
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,9 +0,0 @@
|
||||||
@Master['themes/default/layout']
|
|
||||||
|
|
||||||
@Section['Content']
|
|
||||||
@Partial['themes/default/single-content', Model]
|
|
||||||
@EndSection
|
|
||||||
|
|
||||||
@Section['Footer']
|
|
||||||
@Partial['themes/default/footer', Model]
|
|
||||||
@EndSection
|
|
|
@ -1,4 +0,0 @@
|
||||||
namespace MyWebLog.Web
|
|
||||||
|
|
||||||
type Web() =
|
|
||||||
member this.X = "F#"
|
|
|
@ -1,70 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
|
||||||
<PropertyGroup>
|
|
||||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
|
||||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
|
||||||
<SchemaVersion>2.0</SchemaVersion>
|
|
||||||
<ProjectGuid>07e60874-6cf5-4d53-aee0-f17ef28228dd</ProjectGuid>
|
|
||||||
<OutputType>Library</OutputType>
|
|
||||||
<RootNamespace>MyWebLog.Tests</RootNamespace>
|
|
||||||
<AssemblyName>MyWebLog.Tests</AssemblyName>
|
|
||||||
<TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
|
|
||||||
<TargetFSharpCoreVersion>4.4.0.0</TargetFSharpCoreVersion>
|
|
||||||
<Name>MyWebLog.Tests</Name>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<DebugType>full</DebugType>
|
|
||||||
<Optimize>false</Optimize>
|
|
||||||
<Tailcalls>false</Tailcalls>
|
|
||||||
<OutputPath>bin\Debug\</OutputPath>
|
|
||||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
|
||||||
<WarningLevel>3</WarningLevel>
|
|
||||||
<DocumentationFile>bin\Debug\MyWebLog.Tests.xml</DocumentationFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
|
||||||
<DebugType>pdbonly</DebugType>
|
|
||||||
<Optimize>true</Optimize>
|
|
||||||
<Tailcalls>true</Tailcalls>
|
|
||||||
<OutputPath>bin\Release\</OutputPath>
|
|
||||||
<DefineConstants>TRACE</DefineConstants>
|
|
||||||
<WarningLevel>3</WarningLevel>
|
|
||||||
<DocumentationFile>bin\Release\MyWebLog.Tests.xml</DocumentationFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="mscorlib" />
|
|
||||||
<Reference Include="FSharp.Core, Version=$(TargetFSharpCoreVersion), Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
|
||||||
<Private>True</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="System" />
|
|
||||||
<Reference Include="System.Core" />
|
|
||||||
<Reference Include="System.Numerics" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="MyWebLog.Tests.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
<PropertyGroup>
|
|
||||||
<MinimumVisualStudioVersion Condition="'$(MinimumVisualStudioVersion)' == ''">11</MinimumVisualStudioVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
<Choose>
|
|
||||||
<When Condition="'$(VisualStudioVersion)' == '11.0'">
|
|
||||||
<PropertyGroup Condition="Exists('$(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\3.0\Framework\v4.0\Microsoft.FSharp.Targets')">
|
|
||||||
<FSharpTargetsPath>$(MSBuildExtensionsPath32)\..\Microsoft SDKs\F#\3.0\Framework\v4.0\Microsoft.FSharp.Targets</FSharpTargetsPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
</When>
|
|
||||||
<Otherwise>
|
|
||||||
<PropertyGroup Condition="Exists('$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets')">
|
|
||||||
<FSharpTargetsPath>$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\FSharp\Microsoft.FSharp.Targets</FSharpTargetsPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Otherwise>
|
|
||||||
</Choose>
|
|
||||||
<Import Project="$(FSharpTargetsPath)" Condition="Exists('$(FSharpTargetsPath)')" />
|
|
||||||
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
|
|
||||||
Other similar extension points exist, see Microsoft.Common.targets.
|
|
||||||
<Target Name="BeforeBuild">
|
|
||||||
</Target>
|
|
||||||
<Target Name="AfterBuild">
|
|
||||||
</Target>
|
|
||||||
-->
|
|
||||||
</Project>
|
|
Loading…
Reference in New Issue
Block a user