Switched to Elm / Suave

It's like F# on the client side - sweet!
This commit is contained in:
Daniel J. Summers 2016-12-11 19:39:06 -06:00
parent d3a80b9ceb
commit bde45c8554
45 changed files with 10698 additions and 1216 deletions

4
.gitignore vendored
View File

@ -252,5 +252,5 @@ paket-files/
.idea/
*.sln.iml
# wwwroot/lib
src/MyPrayerJournal/wwwroot/lib
# Elm temporary files
src/elm-stuff

View File

@ -1,3 +0,0 @@
{
"directory": "wwwroot/lib"
}

View File

@ -1,234 +0,0 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Microsoft Azure ApplicationInsights config file
ApplicationInsights.config
# Windows Store app package directory
AppPackages/
BundleArtifacts/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
# FAKE - F# Make
.fake/

View File

@ -1,92 +0,0 @@
module MyPrayerJournal.App
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Localization
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.Extensions.Options
open RethinkDB.DistributedCache
open System
open System.IO
/// Startup class for myPrayerJournal
type Startup(env : IHostingEnvironment) =
/// Configuration for this application
member this.Configuration =
let builder =
ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional = true, reloadOnChange = true)
.AddJsonFile(sprintf "appsettings.%s.json" env.EnvironmentName, optional = true)
// For more details on using the user secret store see https://go.microsoft.com/fwlink/?LinkID=532709
match env.IsDevelopment () with true -> ignore <| builder.AddUserSecrets () | _ -> ()
ignore <| builder.AddEnvironmentVariables ()
builder.Build ()
// This method gets called by the runtime. Use this method to add services to the container.
member this.ConfigureServices (services : IServiceCollection) =
services.AddOptions () |> ignore
services.Configure<AppConfig> (this.Configuration.GetSection "MyPrayerJournal") |> ignore
services.AddLocalization (fun opt -> opt.ResourcesPath <- "Resources") |> ignore
services.AddMvc () |> ignore
//ignore <| services.AddDistributedMemoryCache ()
// RethinkDB connection
async {
let cfg = services.BuildServiceProvider().GetService<IOptions<AppConfig>>().Value
let! conn = DataConfig.Connect cfg.DataConfig
do! conn.EstablishEnvironment cfg
services.AddSingleton conn |> ignore
services.AddDistributedRethinkDBCache (fun options ->
options.Database <- match cfg.DataConfig.Database with null -> "" | db -> db
options.TableName <- "Session") |> ignore
services.AddSession () |> ignore
} |> Async.RunSynchronously
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
member this.Configure (app : IApplicationBuilder, env : IHostingEnvironment, loggerFactory : ILoggerFactory) =
loggerFactory.AddConsole(this.Configuration.GetSection "Logging") |> ignore
loggerFactory.AddDebug () |> ignore
match env.IsDevelopment () with
| true -> app.UseDeveloperExceptionPage () |> ignore
app.UseBrowserLink () |> ignore
| _ -> app.UseExceptionHandler "/error" |> ignore
app.UseStaticFiles () |> ignore
app.UseCookieAuthentication(
CookieAuthenticationOptions(
AuthenticationScheme = Keys.Authentication,
LoginPath = PathString "/user/log-on",
AutomaticAuthenticate = true,
AutomaticChallenge = true,
ExpireTimeSpan = TimeSpan (2, 0, 0),
SlidingExpiration = true)) |> ignore
app.UseMvc(fun routes ->
routes.MapRoute(name = "default", template = "{controller=Home}/{action=Index}/{id?}") |> ignore) |> ignore
/// Default to Development environment
let defaults = seq { yield WebHostDefaults.EnvironmentKey, "Development" }
|> dict
[<EntryPoint>]
let main argv =
let cfg =
ConfigurationBuilder()
.AddInMemoryCollection(defaults)
.AddEnvironmentVariables("ASPNETCORE_")
.AddCommandLine(argv)
.Build()
use host =
WebHostBuilder()
.UseConfiguration(cfg)
.UseKestrel()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseStartup<Startup>()
.Build()
host.Run()
0

View File

@ -1,45 +0,0 @@
namespace MyPrayerJournal
open RethinkDb.Driver
open RethinkDb.Driver.Net
open System
open System.Text
/// Data configuration
type DataConfig() =
/// The hostname for the RethinkDB server
member val Hostname = "" with get, set
/// The port for the RethinkDB server
member val Port = 0 with get, set
/// The authorization key to use when connecting to the server
member val AuthKey = "" with get, set
/// How long an attempt to connect to the server should wait before giving up
member val Timeout = 0 with get, set
/// The name of the default database to use on the connection
member val Database = "" with get, set
/// Use RethinkDB defaults for non-provided options, and connect to the server
static member Connect (cfg : DataConfig) =
async {
let builder =
seq<Connection.Builder -> Connection.Builder> {
yield fun b -> if String.IsNullOrEmpty cfg.Hostname then b else b.Hostname cfg.Hostname
yield fun b -> if String.IsNullOrEmpty cfg.AuthKey then b else b.AuthKey cfg.AuthKey
yield fun b -> if String.IsNullOrEmpty cfg.Database then b else b.Db cfg.Database
yield fun b -> if 0 = cfg.Port then b else b.Port cfg.Port
yield fun b -> if 0 = cfg.Timeout then b else b.Timeout cfg.Timeout
}
|> Seq.fold (fun curr block -> block curr) (RethinkDB.R.Connection())
let! conn = builder.ConnectAsync()
return conn :> IConnection
}
/// Application configuration
type AppConfig() =
/// The text from which to derive salt to use for passwords
member val PasswordSalt = "" with get, set
/// The data configuration
member val DataConfig = DataConfig() with get, set
/// The salt to use for passwords
member this.PasswordSaltBytes = Encoding.UTF8.GetBytes this.PasswordSalt

View File

@ -1,15 +0,0 @@
/// Magic strings? Look behind the curtain...
[<RequireQualifiedAccess>]
module MyPrayerJournal.Keys
/// Instance name for cookie authentication
let Authentication = "mpj-authentication"
/// The current user
let CurrentUser = "mpj-user"
/// The page generator
let Generator = "mpj-generator"
/// The request start ticks
let RequestTimer = "mpj-request-timer"

View File

@ -1,30 +0,0 @@
namespace MyPrayerJournal.Controllers
open Microsoft.AspNetCore.Mvc
open Microsoft.AspNetCore.Mvc.Filters
open Microsoft.Extensions.Localization
open MyPrayerJournal
open RethinkDb.Driver.Net
open System
open System.Reflection
/// Base controller for all myPrayerJournal controllers
type ApplicationController(data : IConnection) =
inherit Controller()
let version =
let v = typeof<ApplicationController>.GetType().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"
/// Fill common items for every request
override this.OnActionExecuting (context : ActionExecutingContext) =
let sw = System.Diagnostics.Stopwatch()
sw.Start()
base.OnActionExecuting context
this.ViewData.[Keys.CurrentUser] <- Option<User>.None
this.ViewData.[Keys.Generator] <- sprintf "myPrayerJournal %s" version
this.ViewData.[Keys.RequestTimer] <- sw

View File

@ -1,24 +0,0 @@
namespace MyPrayerJournal.Controllers
open Microsoft.AspNetCore.Authorization
open Microsoft.AspNetCore.Mvc
open Microsoft.Extensions.Logging
open MyPrayerJournal
open RethinkDb.Driver.Net
/// Home controller
[<Authorize>]
[<Route("")>]
type HomeController(data : IConnection, logger : ILogger<HomeController>) =
inherit ApplicationController(data)
[<AllowAnonymous>]
[<HttpGet("")>]
member this.Index() =
logger.LogDebug(Newtonsoft.Json.JsonConvert.SerializeObject this.HttpContext.User)
async {
match this.HttpContext.User with
| :? AppUser as user -> return this.View "Dashboard" :> IActionResult
| _ -> return upcast this.View ()
}
|> Async.StartAsTask

View File

@ -1,49 +0,0 @@
namespace MyPrayerJournal.Controllers
open Microsoft.AspNetCore.Authorization
open Microsoft.AspNetCore.Mvc
open Microsoft.Extensions.Options
open MyPrayerJournal
open MyPrayerJournal.ViewModels
open RethinkDb.Driver.Net
/// Controller for all /user URLs
[<Authorize>]
[<Route("user")>]
type UserController(data : IConnection, cfg : IOptions<AppConfig>) =
inherit ApplicationController(data)
[<AllowAnonymous>]
[<HttpGet("log-on")>]
member this.ShowLogOn () =
this.View(LogOnViewModel())
[<AllowAnonymous>]
[<HttpPost("log-on")>]
[<ValidateAntiForgeryToken>]
member this.DoLogOn (form : LogOnViewModel) =
async {
let! user = data.LogOnUser form.Email (User.HashPassword form.Password cfg.Value.PasswordSaltBytes)
match user with
| Some usr -> do! this.HttpContext.Authentication.SignInAsync (Keys.Authentication, AppUser user)
// TODO: welcome message
(* this.Session.[Keys.User] <- usr
{ UserMessage.Empty with Level = Level.Info
Message = Strings.get "LogOnSuccess" }
|> model.AddMessage *)
return this.Redirect "/" :> IActionResult
| _ -> (*{ UserMessage.Empty with Level = Level.Error
Message = Strings.get "LogOnFailure" }
|> model.AddMessage
return this.Redirect "/user/log-on" model *)
return upcast this.RedirectToAction "ShowLogOn"
}
|> Async.StartAsTask
[<HttpGet("log-off")>]
member this.LogOff () =
async {
do! this.HttpContext.Authentication.SignOutAsync Keys.Authentication
// TODO: goodbye message
return this.LocalRedirect "/"
} |> Async.StartAsTask

View File

@ -1,106 +0,0 @@
[<AutoOpen>]
module MyPrayerJournal.Data
open Newtonsoft.Json
open RethinkDb.Driver
open RethinkDb.Driver.Ast
open RethinkDb.Driver.Net
open System
let private r = RethinkDB.R
/// Tables for data storage
module DataTable =
/// The table for prayer requests
[<Literal>]
let Request = "Request"
/// The table for users
[<Literal>]
let User = "User"
/// Extensions for the RethinkDB connection
type IConnection with
/// Log on a user
member this.LogOnUser (email : string) (passwordHash : string) =
async {
let! user = r.Table(DataTable.User)
.GetAll(email).OptArg("index", "Email")
.Filter(ReqlFunction1(fun usr -> upcast usr.["PasswordHash"].Eq(passwordHash)))
.RunResultAsync<User list>(this)
return user |> List.tryHead
}
/// Set up the environment for MyPrayerJournal
member this.EstablishEnvironment (cfg : AppConfig) =
/// Shorthand for the database
let db () = r.Db("MyPrayerJournal")
// Be chatty about what we're doing
let mkStep step = sprintf "[MyPrayerJournal] %s" step
let logStep step = mkStep step |> Console.WriteLine
let logStepStart step = mkStep step |> Console.Write
let logStepEnd () = Console.WriteLine " done"
/// Ensure the database exists
let checkDatabase () =
async {
logStep "|> Checking database"
let! dbList = r.DbList().RunResultAsync<string list>(this)
match dbList |> List.contains "MyPrayerJournal" with
| true -> ()
| _ -> logStepStart " Database not found - creating..."
do! r.DbCreate("MyPrayerJournal").RunResultAsync(this)
logStepEnd ()
}
/// Ensure all tables exit
let checkTables () =
async {
logStep "|> Checking tables"
let! tables = db().TableList().RunResultAsync<string list>(this)
[ DataTable.Request; DataTable.User ]
|> List.filter (fun tbl -> not (tables |> List.contains tbl))
|> List.map (fun tbl ->
async {
logStepStart <| sprintf " %s table not found - creating..." tbl
do! db().TableCreate(tbl).RunResultAsync(this)
logStepEnd()
})
|> List.iter Async.RunSynchronously
// Seed the user table if it is empty
let! userCount = db().Table(DataTable.User).Count().RunResultAsync<int64>(this)
match int64 0 = userCount with
| true -> logStepStart " No users found - seeding..."
do! db().Table(DataTable.User).Insert(
{ User.Empty with
Id = Guid.NewGuid().ToString ()
Email = "test@example.com"
PasswordHash = User.HashPassword "password" cfg.PasswordSaltBytes
Name = "Default User"
TimeZone = "America/Chicago"
}).RunResultAsync(this)
logStepEnd ()
| _ -> ()
}
/// Ensure the proper indexes exist
let checkIndexes () =
async {
logStep "|> Checking indexes"
let! reqIdx = db().Table(DataTable.Request).IndexList().RunResultAsync<string list>(this)
match reqIdx |> List.contains "UserId" with
| true -> ()
| _ -> logStepStart <| sprintf " %s.UserId index not found - creating..." DataTable.Request
do! db().Table(DataTable.Request).IndexCreate("UserId").RunResultAsync(this)
logStepEnd ()
let! usrIdx = db().Table(DataTable.User).IndexList().RunResultAsync<string list>(this)
match usrIdx |> List.contains "Email" with
| true -> ()
| _ -> logStepStart <| sprintf " %s.Email index not found - creating..." DataTable.User
do! db().Table(DataTable.User).IndexCreate("Email").RunResultAsync(this)
logStepEnd ()
}
async {
logStep "Database checks starting"
do! checkDatabase ()
do! checkTables ()
do! checkIndexes ()
logStep "Database checks complete"
}

View File

@ -1,92 +0,0 @@
namespace MyPrayerJournal
open Newtonsoft.Json
open System.Security.Claims
open System.Security.Cryptography
/// A user
type User = {
/// The Id of the user
[<JsonProperty("id")>]
Id : string
/// The user's e-mail address
Email : string
/// A hash of the user's password
PasswordHash : string
/// The user's name
Name : string
/// The time zone in which the user resides
TimeZone : string
/// The last time the user logged on
LastSeenOn : int64
}
with
/// An empty User
static member Empty =
{ Id = ""
Email = ""
PasswordHash = ""
Name = ""
TimeZone = ""
LastSeenOn = int64 0 }
/// Hash a user's password
static member HashPassword (pw : string) (salt : byte[]) =
use hash = new Rfc2898DeriveBytes(pw, salt, 4096)
hash.GetBytes 512
|> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) ""
/// Request history entry
type History = {
/// The instant at which the update was made
AsOf : int64
/// The action that was taken on the request
Action : string list
/// The status of the request (filled if it changed)
Status : string option
/// The text of the request (filled if it changed)
Text : string option
}
/// A prayer request
type Request = {
/// The Id of the request
[<JsonProperty("id")>]
Id : string
/// The Id of the user to whom this request belongs
UserId : string
/// The instant this request was entered
EnteredOn : int64
/// The history for this request
History : History list
}
with
/// The current status of the prayer request
member this.Status =
this.History
|> List.sortBy (fun item -> -item.AsOf)
|> List.map (fun item -> item.Status)
|> List.filter Option.isSome
|> List.map Option.get
|> List.head
/// The current text of the prayer request
member this.Text =
this.History
|> List.sortBy (fun item -> -item.AsOf)
|> List.map (fun item -> item.Text)
|> List.filter Option.isSome
|> List.map Option.get
|> List.head
member this.LastActionOn =
this.History
|> List.sortBy (fun item -> -item.AsOf)
|> List.map (fun item -> item.AsOf)
|> List.head
/// The user for use with identity
[<AllowNullLiteral>]
type AppUser(user : User option) =
inherit ClaimsPrincipal()
/// The current user
member val User = user with get

View File

@ -1,19 +0,0 @@
[<AutoOpen>]
module MyPrayerJournal.Extensions
open Microsoft.FSharp.Control
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)
/// Object to which common IStringLocalizer calls cal be made
type I18N() =
member this.X = ()

View File

@ -1,49 +0,0 @@
namespace MyPrayerJournal.ViewModels
//open MyPrayerJournal
/// Parent view model for all myPrayerJournal view models
type AppViewModel() =
member this.Q = "X"
(*
/// User messages
member val Messages = getMessages () with get, set
/// The currently logged in user
member val User = Option<User>.None with get, set
/// The title of the page
member val PageTitle = "" with get, set
/// The name and version of the application
member this.Generator = sprintf "myPrayerJournal %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.User.TimeZone ticks
/// Display a short date
member this.DisplayShortDate ticks = FormatDateTime.shortDate this.User.TimeZone ticks
/// Display the time
member this.DisplayTime ticks = FormatDateTime.time this.User.TimeZone ticks
/// The page title with the application name appended
member this.DisplayPageTitle =
match this.PageTitle with
| "" -> Strings.get "myPrayerJournal"
| pt -> sprintf "%s | %s" pt (Strings.get "myPrayerJournal")
/// An image with the version and load time in the tool tip
member this.FooterLogo =
seq {
yield "<span title=\""
yield sprintf "%s %s &bull; " (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 "\"><span style=\"font-weight:100;\">my</span><span style=\"font-weight:600;\">Prayer</span><span style=\"font-weight:700;\">Journal</span></span>"
}
|> Seq.reduce (+)
*)

View File

@ -1,16 +0,0 @@
namespace MyPrayerJournal.ViewModels
open System.ComponentModel.DataAnnotations
type LogOnViewModel() =
inherit AppViewModel()
[<Required>]
[<DataType(DataType.EmailAddress)>]
[<Display(Name = "E-mail Address")>]
member val Email = "" with get, set
[<Required>]
[<DataType(DataType.Password)>]
[<Display(Name = "Password")>]
member val Password = "" with get, set

View File

@ -1,14 +0,0 @@
@{
ViewData["Title"] = "";
}
<div class="row">
<div class="col-xs-12">
<p>&nbsp;</p>
<p>myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them,
update them as God moves in the situation, and record a final answer received on that request.&nbsp; It will also
allow individuals to review their answered prayers.</p>
<p>This site is currently in very limited alpha, as it is being developed with a core group of test
users.&nbsp; If this is something you are interested in using, check back around mid-November 2016 to check on the
development progress.</p>
</div>
</div>

View File

@ -1,14 +0,0 @@
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
</p>

View File

@ -1,80 +0,0 @@
@{
var title = ViewData["Title"] as string;
var pageTitle = string.IsNullOrEmpty(title) ? "myPrayerJournal" : String.Format("{0} - myPrayerJournal", title);
var sw = ViewData[Keys.RequestTimer] as System.Diagnostics.Stopwatch;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="@ViewData[Keys.Generator]" />
<title>@pageTitle</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<environment names="Development">
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
<link rel="stylesheet" href="~/css/site.css" />
</environment>
<environment names="Staging,Production">
<link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/css/bootstrap.min.css"
asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" />
<link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
</environment>
</head>
<body>
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a asp-controller="Home" asp-action="Index" class="navbar-brand"><span style="font-weight:100;">my</span><span style="font-weight:600;">Prayer</span><span style="font-weight:700;">Journal</span></a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
@if (OptionModule.IsSome(ViewData[Keys.CurrentUser] as FSharpOption<User>)) {
<li><a asp-controller="User" asp-action="ShowChangePassword">Change Your Password</a></li>
<li><a asp-controller="User" asp-action="LogOff">Log Off</a></li>
}
else {
<li><a asp-controller="User" asp-action="ShowLogOn">Log On</a></li>
}
</ul>
</div>
</div>
</div>
<div class="container body-content">
@if (!string.IsNullOrEmpty(ViewData["Title"] as string)) {
<h1 class="mpj-page-title">@ViewData["Title"]</h1>
}
@RenderBody()
<footer class="mpj-footer">
<p class="text-right" title="Loaded in @sw.Elapsed.ToString(@"s\.fff") seconds">@ViewData[Keys.Generator]</p>
</footer>
</div>
<environment names="Development">
<script src="~/lib/jquery/dist/jquery.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
</environment>
<environment names="Staging,Production">
<script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-2.2.3.min.js"
asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
asp-fallback-test="window.jQuery">
</script>
<script src="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/bootstrap.min.js"
asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js"
asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal">
</script>
<script src="~/js/site.min.js" asp-append-version="true"></script>
</environment>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

@ -1,14 +0,0 @@
<environment names="Development">
<script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
</environment>
<environment names="Staging,Production">
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validate/1.15.0/jquery.validate.min.js"
asp-fallback-src="~/lib/jquery-validation/dist/jquery.validate.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator">
</script>
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validation.unobtrusive/3.2.6/jquery.validate.unobtrusive.min.js"
asp-fallback-src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive">
</script>
</environment>

View File

@ -1,40 +0,0 @@
@model LogOnViewModel
@{
ViewData["Title"] = "Log On";
}
<form asp-action="DoLogOn" asp-controller="User" method="post">
<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="E-mail Address"><i class="material-icons md-18">email</i></span>
<input asp-for="Email" class="form-control" placeholder="E-mail Address" />
</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="Password"><i class="material-icons md-18">security</i></span>
<input asp-for="Password" class="form-control" placeholder="Password" />
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 text-center">
<p>
<br />
<button class="btn btn-primary"><i class="material-icons md-18">verified_user</i> Log On</button>
</p>
</div>
</div>
</form>
@section Scripts
{
<script type="text/javascript">
/* <![CDATA[ */
$(document).ready(function () { $("#Email").focus() })
/* ]]> */
</script>
}

View File

@ -1,5 +0,0 @@
@using Microsoft.Extensions.Localization
@using Microsoft.FSharp.Core
@using MyPrayerJournal
@using MyPrayerJournal.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View File

@ -1,35 +0,0 @@
{
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
"MyPrayerJournal": {
/*
* myPrayerJournal Configuration
*
* *** SECURITY OPTIONS ***
*
* https://www.grc.com/passwords.htm is a great source of high-entropy passwords. Although what is here looks
* strong, keep in mind that it's what's in source control, so all instances of myPrayerJournal could be using this
* value; that severly decreases its usefulness. :)
*
* WARNING: Changing this will render every single user's login inaccessible, including yours. Do not change it
* once you have started this instance for the first time.
*/
"PasswordSalt": "oIvatPlrBh5DjeBVWvX3vvePHAgbbzUm7BazZM2IKlUsTtDuPJFbF3KvIiQPdLt",
/*
* *** DATA OPTIONS ***
*
* Configure RethinkDB options here; any options not specified will take the driver's default. Available options are
* Hostname (string), Port (int), Database (string), AuthKey (string), and Timeout (int).
*/
"DataConfig": {
"Database": "MyPrayerJournal",
"Hostname": "severus-server"
}
}
}

View File

@ -1,10 +0,0 @@
{
"name": "myprayerjournal",
"private": true,
"dependencies": {
"bootstrap": "3.3.6",
"jquery": "2.2.3",
"jquery-validation": "1.15.0",
"jquery-validation-unobtrusive": "3.2.6"
}
}

View File

@ -1,45 +0,0 @@
/// <binding Clean='clean' />
"use strict";
var gulp = require("gulp"),
rimraf = require("rimraf"),
concat = require("gulp-concat"),
cssmin = require("gulp-cssmin"),
uglify = require("gulp-uglify");
var webroot = "./wwwroot/";
var paths = {
js: webroot + "js/**/*.js",
minJs: webroot + "js/**/*.min.js",
css: webroot + "css/**/*.css",
minCss: webroot + "css/**/*.min.css",
concatJsDest: webroot + "js/site.min.js",
concatCssDest: webroot + "css/site.min.css"
};
gulp.task("clean:js", function (cb) {
rimraf(paths.concatJsDest, cb);
});
gulp.task("clean:css", function (cb) {
rimraf(paths.concatCssDest, cb);
});
gulp.task("clean", ["clean:js", "clean:css"]);
gulp.task("min:js", function () {
return gulp.src([paths.js, "!" + paths.minJs], { base: "." })
.pipe(concat(paths.concatJsDest))
.pipe(uglify())
.pipe(gulp.dest("."));
});
gulp.task("min:css", function () {
return gulp.src([paths.css, "!" + paths.minCss])
.pipe(concat(paths.concatCssDest))
.pipe(cssmin())
.pipe(gulp.dest("."));
});
gulp.task("min", ["min:js", "min:css"]);

View File

@ -1,12 +0,0 @@
{
"name": "myprayerjournal",
"version": "0.8.0",
"private": true,
"devDependencies": {
"gulp": "3.9.1",
"gulp-concat": "2.6.0",
"gulp-cssmin": "0.1.7",
"gulp-uglify": "1.5.3",
"rimraf": "2.5.2"
}
}

View File

@ -1,119 +0,0 @@
{
"userSecretsId": "aspnet-WebApplication-0799fe3e-6eaf-4c5f-b40e-7c6bfd5dfa9a",
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.0.1",
"type": "platform"
},
"Microsoft.AspNetCore.Authentication.Cookies": "1.0.0",
"Microsoft.AspNetCore.Diagnostics": "1.0.0",
"Microsoft.AspNetCore.Mvc": "1.0.1",
"Microsoft.AspNetCore.Razor.Tools": {
"version": "1.0.0-preview2-final",
"type": "build"
},
"Microsoft.AspNetCore.Routing": "1.0.1",
"Microsoft.AspNetCore.Server.Kestrel": "1.0.1",
"Microsoft.AspNetCore.Session": "1.0.0",
"Microsoft.AspNetCore.StaticFiles": "1.0.0",
"Microsoft.Extensions.Configuration.CommandLine": "1.0.0",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0",
"Microsoft.Extensions.Configuration.Json": "1.0.0",
"Microsoft.Extensions.Configuration.UserSecrets": "1.0.0",
"Microsoft.Extensions.Logging": "1.0.0",
"Microsoft.Extensions.Logging.Console": "1.0.0",
"Microsoft.Extensions.Logging.Debug": "1.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0",
"Microsoft.FSharp.Core.netcore": "1.0.0-alpha-160831",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0",
"Microsoft.VisualStudio.Web.CodeGeneration.Tools": {
"version": "1.0.0-preview2-update1",
"type": "build"
},
"Microsoft.VisualStudio.Web.CodeGenerators.Mvc": {
"version": "1.0.0-preview2-update1",
"type": "build"
},
"Newtonsoft.Json": "9.0.1",
"NodaTime": "2.0.0-alpha20160729",
"RethinkDB.DistributedCache": "0.9.0-alpha01",
"RethinkDb.Driver": "2.3.15"
},
"tools": {
"dotnet-compile-fsc":"1.0.0-preview2-*",
"Microsoft.AspNetCore.Razor.Tools": {
"version": "1.0.0-preview2-final",
"imports": "portable-net45+win8+dnxcore50"
},
"Microsoft.Extensions.SecretManager.Tools": {
"version": "1.0.0-preview2-final",
"imports": "portable-net45+win8+dnxcore50"
},
"Microsoft.VisualStudio.Web.CodeGeneration.Tools": {
"version": "1.0.0-preview2-final",
"imports": [
"portable-net45+win8+dnxcore50",
"portable-net45+win8"
]
}
},
"frameworks": {
"netcoreapp1.0": {
"imports": [
"dotnet5.6",
"dnxcore50",
"portable-net45+win8"
]
}
},
"buildOptions": {
"compile": {
"exclude": "**/*",
"includeFiles": [
"Helpers/Extensions.fs",
"Configuration/Configuration.fs",
"Configuration/Keys.fs",
"Data/Entities.fs",
"Data/Data.fs",
"Models/ViewModels/AppViewModel.fs",
"Models/ViewModels/User/LogOnViewModel.fs",
"Controllers/ApplicationController.fs",
"Controllers/HomeController.fs",
"Controllers/UserController.fs",
"App.fs"
]
},
"compilerName": "fsc",
"debugType": "portable",
"emitEntryPoint": true,
"preserveCompilationContext": true
},
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
},
"publishOptions": {
"include": [
"wwwroot",
"**/*.cshtml",
"appsettings.json",
"web.config"
]
},
"scripts": {
"prepublish": [ "npm install", "bower install", "gulp clean", "gulp min" ],
"postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ]
},
"tooling": {
"defaultNamespace": "MyPrayerJournal"
}
}

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
Configure your application settings in appsettings.json. Learn more at https://go.microsoft.com/fwlink/?LinkId=786380
-->
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified"/>
</handlers>
<aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" forwardWindowsAuthToken="false"/>
</system.webServer>
</configuration>

View File

@ -1 +0,0 @@
body{padding-top:50px;padding-bottom:20px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}.body-content{padding-left:15px;padding-right:15px}input,select,textarea{max-width:280px}.carousel-caption p{font-size:20px;line-height:1.4}.btn-bracketed::before{display:inline-block;content:"[";padding-right:.5em}.btn-bracketed::after{display:inline-block;content:"]";padding-left:.5em}@media screen and (max-width:767px){.carousel-caption{display:none}}.material-icons.md-18{font-size:18px}.material-icons.md-24{font-size:24px}.material-icons.md-36{font-size:36px}.material-icons.md-48{font-size:48px}.material-icons{vertical-align:middle}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1 +0,0 @@
// Write your Javascript code.

22
src/Program.fs Normal file
View File

@ -0,0 +1,22 @@
// Learn more about F# at http://fsharp.org
open System.IO
open Suave
open Suave.Filters
open Suave.Operators
let app : WebPart =
choose [
//GET >=> path "/" >=> Files.file "index.html"
//GET >=> path "" >=> Files.file "index.html"
GET >=> Files.browseHome
GET >=> Files.browseFileHome "index.html"
RequestErrors.NOT_FOUND "Page not found."
]
[<EntryPoint>]
let main argv =
let config =
{ defaultConfig with homeFolder = Some (Path.GetFullPath "./wwwroot/") }
startWebServer config app
0 // return an integer exit code

17
src/elm-package.json Normal file
View File

@ -0,0 +1,17 @@
{
"version": "0.8.1",
"summary": "A place to record requests, prayers, and answers",
"repository": "https://github.com/user/project.git",
"license": "MIT",
"source-directories": [
"wwwroot"
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "5.0.0 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/navigation": "2.0.1 <= v < 3.0.0",
"evancz/url-parser": "2.0.1 <= v < 3.0.0"
},
"elm-version": "0.18.0 <= v < 0.19.0"
}

30
src/project.json Normal file
View File

@ -0,0 +1,30 @@
{
"version": "1.0.0-*",
"buildOptions": {
"debugType": "portable",
"emitEntryPoint": true,
"compilerName": "fsc",
"compile": {
"includeFiles": [
"Program.fs"
]
}
},
"dependencies": {
"Suave": "2.0.0-rc2"
},
"tools": {
"dotnet-compile-fsc": "1.0.0-preview2.1-*"
},
"frameworks": {
"netcoreapp1.1": {
"dependencies": {
"Microsoft.NETCore.App": {
"type": "platform",
"version": "1.1.0"
},
"Microsoft.FSharp.Core.netcore": "1.0.0-alpha-161111"
}
}
}
}

26
src/wwwroot/App.elm Normal file
View File

@ -0,0 +1,26 @@
module App exposing (..)
import Messages exposing (..)
import Models exposing (Model, initialModel)
import Navigation exposing (Location)
import Routing exposing (Route(..), parseLocation)
import Update exposing (update)
import View exposing (view)
init : Location -> (Model, Cmd Msg)
init location =
let
currentRoute = Home --parseLocation location
in
(initialModel currentRoute, Cmd.none)
main : Program Never Model Msg
main =
Navigation.program OnLocationChange
{ init = init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
}

View File

@ -0,0 +1,19 @@
module Home.Public exposing (view)
import Html exposing (Html, p, text)
import Messages exposing (Msg(..))
import Models exposing (Model)
import Utils.View exposing (fullRow)
view : Model -> List (Html Msg)
view model =
let
paragraphs =
[ " "
, "myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, update them as God moves in the situation, and record a final answer received on that request. It will also allow individuals to review their answered prayers."
, "This site is currently in very limited alpha, as it is being developed with a core group of test users. If this is something you are interested in using, check back around mid-November 2016 to check on the development progress."
]
|> List.map (\para -> p [] [ text para ])
in
[ fullRow paragraphs ]

9
src/wwwroot/Messages.elm Normal file
View File

@ -0,0 +1,9 @@
module Messages exposing (..)
import Navigation exposing (Location)
type Msg
= OnLocationChange Location
| NavTo String
| UpdateTitle String

16
src/wwwroot/Models.elm Normal file
View File

@ -0,0 +1,16 @@
module Models exposing (..)
import Routing exposing (Route(..))
type alias Model =
{ route : Route
, title : String
}
initialModel : Route -> Model
initialModel route =
{ route = route
, title = "Index"
}

31
src/wwwroot/Routing.elm Normal file
View File

@ -0,0 +1,31 @@
module Routing exposing (..)
import Navigation exposing (Location)
import UrlParser exposing (..)
type Route
= Home
| ChangePassword
| LogOff
| LogOn
| NotFound
findRoute : Parser (Route -> a) a
findRoute =
oneOf
[ map Home top
, map LogOn (s "user" </> s "log-on")
, map LogOff (s "user" </> s "log-off")
, map ChangePassword (s "user" </> s "password" </> s "change")
]
parseLocation : Location -> Route
parseLocation location =
case (parsePath findRoute location) of
Just route ->
route
Nothing ->
NotFound

21
src/wwwroot/Update.elm Normal file
View File

@ -0,0 +1,21 @@
module Update exposing (..)
import Models exposing (Model)
import Messages exposing (Msg(..))
import Navigation exposing (newUrl)
import Routing exposing (parseLocation)
import Utils.View exposing (documentTitle)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
OnLocationChange location ->
let
newRoute = parseLocation location
in
({model | route = newRoute}, Cmd.none)
NavTo url ->
(model, newUrl url)
UpdateTitle newTitle ->
(model, documentTitle newTitle)

View File

@ -0,0 +1,47 @@
port module Utils.View exposing (..)
import Html exposing (..)
import Html.Attributes exposing (class, href, style, title)
import Html.Events exposing (defaultOptions, onWithOptions)
import Json.Decode as Json
import Messages exposing (Msg(..))
-- Set the document title
port documentTitle : String -> Cmd a
-- Wrap the given content in a row
row : List (Html Msg) -> Html Msg
row columns =
div [ class "row "] columns
-- Display the given content in a full row
fullRow : List (Html Msg) -> Html Msg
fullRow content =
row
[ div
[ class "col-xs-12" ]
content
]
-- Create a navigation link
navLink : String -> String -> List (Attribute Msg) -> Html Msg
navLink url linkText attrs =
let
attributes =
List.concat
[
[ title linkText
, onWithOptions
"click" { defaultOptions | preventDefault = True }
<| Json.succeed
<| NavTo url
, href url
]
, attrs
]
in
a attributes [ text linkText ]

104
src/wwwroot/View.elm Normal file
View File

@ -0,0 +1,104 @@
module View exposing (view)
import Html exposing (..)
import Html.Attributes exposing (attribute, class)
import Messages exposing (Msg(..))
import Models exposing (..)
import Routing exposing (Route(..))
import Utils.View exposing (documentTitle, navLink)
import Home.Public
-- Layout functions
navigation : List (Html Msg)
navigation =
[ navLink "/user/password/change" "Change Your Password" []
, navLink "/user/log-off" "Log Off" []
, navLink "/user/log-on" "Log On" []
]
|> List.map (\anchor -> li [] [ anchor ])
pageHeader : Html Msg
pageHeader =
div
[ class "navbar navbar-inverse navbar-fixed-top" ]
[ div
[ class "container" ]
[ div
[ class "navbar-header" ]
[ button
[ class "navbar-toggle"
, attribute "data-toggle" "collapse"
, attribute "data-target" ".navbar-collapse"
]
[ span [ class "sr-only" ] [ text "Toggle navigation" ]
, span [ class "icon-bar" ] []
, span [ class "icon-bar" ] []
, span [ class "icon-bar" ] []
]
, navLink "/" "myPrayerJournal" [ class "navbar-brand" ]
]
, div
[ class "navbar-collapse collapse" ]
[ ul
[ class "nav navbar-nav navbar-right" ]
navigation
]
]
]
pageTitle : String -> Html Msg
pageTitle title =
let
x = documentTitle <| title ++ " | myPrayerJournal"
in
h2 [ class "page-title" ] [ text title ]
pageFooter : Html Msg
pageFooter =
footer
[ class "mpj-footer" ]
[ p
[ class "text-right" ]
[ text "myPrayerJournal v0.8.1" ]
]
layout : Model -> String -> List (Html Msg) -> Html Msg
layout model pgTitle contents =
let
pageContent =
[ [ pageTitle pgTitle ]
, contents
, [ pageFooter ]
]
|> List.concat
in
div []
[ pageHeader
, div
[ class "container body-content" ]
pageContent
]
-- View functions
view : Model -> Html Msg
view model =
case model.route of
ChangePassword ->
layout model "Change Your Password" [ text "password change page goes here" ]
Home ->
layout model "Welcome" (Home.Public.view model)
LogOff ->
layout model "Log Off" [ text "Log off page goes hwere" ]
LogOn ->
layout model "Log On" [ text "Log On page goes here" ]
NotFound ->
layout model "Page Not Found" [ text "404, dude" ]

10330
src/wwwroot/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,39 +10,6 @@ body {
padding-left: 15px;
padding-right: 15px;
}
/* Set widths on the form inputs since otherwise they're 100% wide */
/*input,
select,
textarea {
max-width: 280px;
}*/
/* Carousel */
.carousel-caption p {
font-size: 20px;
line-height: 1.4;
}
/* buttons and links extension to use brackets: [ click me ] */
.btn-bracketed::before {
display:inline-block;
content: "[";
padding-right: 0.5em;
}
.btn-bracketed::after {
display:inline-block;
content: "]";
padding-left: 0.5em;
}
/* Hide/rearrange for smaller screens */
@media screen and (max-width: 767px) {
/* Hide captions */
.carousel-caption {
display: none
}
}
.material-icons.md-18 {
font-size: 18px;
}

24
src/wwwroot/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<title>myPrayerJournal</title>
<base href="/">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="/content/styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<script src="/app.js"></script>
</head>
<body>
<div id="app"></div>
<script>
var app = Elm.App.embed(document.getElementById('app'))
app.ports.documentTitle.subscribe(function (title)
{
alert("Setting title to " + title)
document.title = title
})
</script>
</body>
</html>