WIP on messages
This commit is contained in:
@@ -36,13 +36,48 @@ module Error =
|
||||
setStatusCode 404 >=> text "Not found"
|
||||
|
||||
|
||||
open System.Text.Json
|
||||
|
||||
/// Session extensions to get and set objects
|
||||
type ISession with
|
||||
|
||||
/// Set an item in the session
|
||||
member this.Set<'T> (key, item : 'T) =
|
||||
this.SetString (key, JsonSerializer.Serialize item)
|
||||
|
||||
/// Get an item from the session
|
||||
member this.Get<'T> key =
|
||||
match this.GetString key with
|
||||
| null -> None
|
||||
| item -> Some (JsonSerializer.Deserialize<'T> item)
|
||||
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Markdig
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open System.Security.Claims
|
||||
open System.IO
|
||||
|
||||
/// Add a message to the user's session
|
||||
let addMessage (ctx : HttpContext) message = task {
|
||||
do! ctx.Session.LoadAsync ()
|
||||
let msg = match ctx.Session.Get<UserMessage list> "messages" with Some it -> it | None -> []
|
||||
ctx.Session.Set ("messages", message :: msg)
|
||||
}
|
||||
|
||||
/// Get any messages from the user's session, removing them in the process
|
||||
let messages (ctx : HttpContext) = task {
|
||||
do! ctx.Session.LoadAsync ()
|
||||
match ctx.Session.Get<UserMessage list> "messages" with
|
||||
| Some msg ->
|
||||
ctx.Session.Remove "messages"
|
||||
return msg |> (List.rev >> Array.ofList)
|
||||
| None -> return [||]
|
||||
}
|
||||
|
||||
/// Either get the web log from the hash, or get it from the cache and add it to the hash
|
||||
let private deriveWebLogFromHash (hash : Hash) ctx =
|
||||
@@ -57,9 +92,11 @@ module private Helpers =
|
||||
let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
|
||||
// Don't need the web log, but this adds it to the hash if the function is called directly
|
||||
let _ = deriveWebLogFromHash hash ctx
|
||||
let! messages = messages ctx
|
||||
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
|
||||
hash.Add ("page_list", PageListCache.get ctx)
|
||||
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
|
||||
hash.Add ("messages", messages)
|
||||
|
||||
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a two-pass
|
||||
// render; the net effect is a "layout" capability similar to Razor or Pug
|
||||
@@ -105,16 +142,20 @@ module private Helpers =
|
||||
/// Require a user to be logged on
|
||||
let requireUser = requiresAuthentication Error.notAuthorized
|
||||
|
||||
/// Pipeline with most extensions enabled
|
||||
let mdPipeline =
|
||||
MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().Build ()
|
||||
/// Get the templates available for the current web log's theme (in a key/value pair list)
|
||||
let templatesForTheme ctx (typ : string) =
|
||||
seq {
|
||||
KeyValuePair.Create ("", $"- Default (single-{typ}) -")
|
||||
yield!
|
||||
Directory.EnumerateFiles $"themes/{(WebLogCache.get ctx).themePath}/"
|
||||
|> Seq.filter (fun it -> it.EndsWith $"{typ}.liquid")
|
||||
|> Seq.map (fun it ->
|
||||
let parts = it.Split Path.DirectorySeparatorChar
|
||||
let template = parts[parts.Length - 1].Replace (".liquid", "")
|
||||
KeyValuePair.Create (template, template))
|
||||
}
|
||||
|> Array.ofSeq
|
||||
|
||||
/// Get the HTML representation of the text of a revision
|
||||
let revisionToHtml (rev : Revision) =
|
||||
match rev.sourceType with Html -> rev.text | Markdown -> Markdown.ToHtml (rev.text, mdPipeline)
|
||||
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
/// Handlers to manipulate admin functions
|
||||
module Admin =
|
||||
@@ -191,8 +232,7 @@ module Admin =
|
||||
// Update cache
|
||||
WebLogCache.set ctx updated
|
||||
|
||||
// TODO: confirmation message
|
||||
|
||||
do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" }
|
||||
return! redirectTo false "/admin" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
@@ -216,28 +256,24 @@ module Page =
|
||||
|
||||
// GET /page/{id}/edit
|
||||
let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! hash = task {
|
||||
let! result = task {
|
||||
match pgId with
|
||||
| "new" ->
|
||||
return
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditPageModel.fromPage { Page.empty with id = PageId "new" }
|
||||
page_title = "Add a New Page"
|
||||
|} |> Some
|
||||
| "new" -> return Some ("Add a New Page", { Page.empty with id = PageId "new" })
|
||||
| _ ->
|
||||
match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with
|
||||
| Some page ->
|
||||
return
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditPageModel.fromPage page
|
||||
page_title = "Edit Page"
|
||||
|} |> Some
|
||||
| Some page -> return Some ("Edit Page", page)
|
||||
| None -> return None
|
||||
}
|
||||
match hash with
|
||||
| Some h -> return! viewForTheme "admin" "page-edit" next ctx h
|
||||
match result with
|
||||
| Some (title, page) ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditPageModel.fromPage page
|
||||
page_title = title
|
||||
templates = templatesForTheme ctx "page"
|
||||
|}
|
||||
|> viewForTheme "admin" "page-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
@@ -262,7 +298,7 @@ module Page =
|
||||
match pg with
|
||||
| Some page ->
|
||||
let updateList = page.showInPageList <> model.isShownInPageList
|
||||
let revision = { asOf = now; sourceType = RevisionSource.ofString model.source; text = model.text }
|
||||
let revision = { asOf = now; text = MarkupText.parse $"{model.source}: {model.text}" }
|
||||
// Detect a permalink change, and add the prior one to the prior list
|
||||
let page =
|
||||
match Permalink.toString page.permalink with
|
||||
@@ -275,12 +311,13 @@ module Page =
|
||||
permalink = Permalink model.permalink
|
||||
updatedOn = now
|
||||
showInPageList = model.isShownInPageList
|
||||
text = revisionToHtml revision
|
||||
template = match model.template with "" -> None | tmpl -> Some tmpl
|
||||
text = MarkupText.toHtml revision.text
|
||||
revisions = revision :: page.revisions
|
||||
}
|
||||
do! (match model.pageId with "new" -> Data.Page.add | _ -> Data.Page.update) page conn
|
||||
if updateList then do! PageListCache.update ctx
|
||||
// TODO: confirmation
|
||||
do! addMessage ctx { UserMessage.success with message = "Page saved successfully" }
|
||||
return! redirectTo false $"/page/{PageId.toString page.id}/edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
@@ -372,8 +409,9 @@ module User =
|
||||
|
||||
// POST /user/log-on
|
||||
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||
match! Data.WebLogUser.findByEmail model.emailAddress (webLogId ctx) (conn ctx) with
|
||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||
let webLog = WebLogCache.get ctx
|
||||
match! Data.WebLogUser.findByEmail model.emailAddress webLog.id (conn ctx) with
|
||||
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
||||
let claims = seq {
|
||||
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id)
|
||||
@@ -385,20 +423,21 @@ module User =
|
||||
|
||||
do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity,
|
||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||
|
||||
// TODO: confirmation message
|
||||
|
||||
do! addMessage ctx
|
||||
{ UserMessage.success with
|
||||
message = "Logged on successfully"
|
||||
detail = Some $"Welcome to {webLog.name}!"
|
||||
}
|
||||
return! redirectTo false "/admin" next ctx
|
||||
| _ ->
|
||||
// TODO: make error, not 404
|
||||
return! Error.notFound next ctx
|
||||
do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" }
|
||||
return! logOn next ctx
|
||||
}
|
||||
|
||||
// GET /user/log-off
|
||||
let logOff : HttpHandler = fun next ctx -> task {
|
||||
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||
|
||||
// TODO: confirmation message
|
||||
|
||||
do! addMessage ctx { UserMessage.info with message = "Log off successful" }
|
||||
return! redirectTo false "/" next ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotLiquid" Version="2.2.610" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Markdig" Version="0.28.1" />
|
||||
<PackageReference Include="RethinkDB.DistributedCache" Version="0.9.0-alpha02" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -60,80 +60,82 @@ module DotLiquidBespoke =
|
||||
|> Seq.iter result.WriteLine
|
||||
|
||||
|
||||
/// Initialize a new database
|
||||
let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
|
||||
/// Create the default information for a new web log
|
||||
module NewWebLog =
|
||||
|
||||
let conn = sp.GetRequiredService<IConnection> ()
|
||||
|
||||
let timeZone =
|
||||
let local = TimeZoneInfo.Local.Id
|
||||
match TimeZoneInfo.Local.HasIanaId with
|
||||
| true -> local
|
||||
| false ->
|
||||
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
|
||||
| true, ianaId -> ianaId
|
||||
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
|
||||
|
||||
// Create the web log
|
||||
let webLogId = WebLogId.create ()
|
||||
let userId = WebLogUserId.create ()
|
||||
let homePageId = PageId.create ()
|
||||
|
||||
do! Data.WebLog.add
|
||||
{ WebLog.empty with
|
||||
id = webLogId
|
||||
name = args[2]
|
||||
urlBase = args[1]
|
||||
defaultPage = PageId.toString homePageId
|
||||
timeZone = timeZone
|
||||
} conn
|
||||
|
||||
// Create the admin user
|
||||
let salt = Guid.NewGuid ()
|
||||
|
||||
do! Data.WebLogUser.add
|
||||
{ WebLogUser.empty with
|
||||
id = userId
|
||||
webLogId = webLogId
|
||||
userName = args[3]
|
||||
firstName = "Admin"
|
||||
lastName = "User"
|
||||
preferredName = "Admin"
|
||||
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
|
||||
salt = salt
|
||||
authorizationLevel = Administrator
|
||||
} conn
|
||||
/// Create the web log information
|
||||
let private createWebLog (args : string[]) (sp : IServiceProvider) = task {
|
||||
|
||||
let conn = sp.GetRequiredService<IConnection> ()
|
||||
|
||||
let timeZone =
|
||||
let local = TimeZoneInfo.Local.Id
|
||||
match TimeZoneInfo.Local.HasIanaId with
|
||||
| true -> local
|
||||
| false ->
|
||||
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
|
||||
| true, ianaId -> ianaId
|
||||
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
|
||||
|
||||
// Create the web log
|
||||
let webLogId = WebLogId.create ()
|
||||
let userId = WebLogUserId.create ()
|
||||
let homePageId = PageId.create ()
|
||||
|
||||
do! Data.WebLog.add
|
||||
{ WebLog.empty with
|
||||
id = webLogId
|
||||
name = args[2]
|
||||
urlBase = args[1]
|
||||
defaultPage = PageId.toString homePageId
|
||||
timeZone = timeZone
|
||||
} conn
|
||||
|
||||
// Create the admin user
|
||||
let salt = Guid.NewGuid ()
|
||||
|
||||
do! Data.WebLogUser.add
|
||||
{ WebLogUser.empty with
|
||||
id = userId
|
||||
webLogId = webLogId
|
||||
userName = args[3]
|
||||
firstName = "Admin"
|
||||
lastName = "User"
|
||||
preferredName = "Admin"
|
||||
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
|
||||
salt = salt
|
||||
authorizationLevel = Administrator
|
||||
} conn
|
||||
|
||||
// Create the default home page
|
||||
do! Data.Page.add
|
||||
{ Page.empty with
|
||||
id = homePageId
|
||||
webLogId = webLogId
|
||||
authorId = userId
|
||||
title = "Welcome to myWebLog!"
|
||||
permalink = Permalink "welcome-to-myweblog.html"
|
||||
publishedOn = DateTime.UtcNow
|
||||
updatedOn = DateTime.UtcNow
|
||||
text = "<p>This is your default home page.</p>"
|
||||
revisions = [
|
||||
{ asOf = DateTime.UtcNow
|
||||
sourceType = Html
|
||||
text = "<p>This is your default home page.</p>"
|
||||
}
|
||||
]
|
||||
} conn
|
||||
// Create the default home page
|
||||
do! Data.Page.add
|
||||
{ Page.empty with
|
||||
id = homePageId
|
||||
webLogId = webLogId
|
||||
authorId = userId
|
||||
title = "Welcome to myWebLog!"
|
||||
permalink = Permalink "welcome-to-myweblog.html"
|
||||
publishedOn = DateTime.UtcNow
|
||||
updatedOn = DateTime.UtcNow
|
||||
text = "<p>This is your default home page.</p>"
|
||||
revisions = [
|
||||
{ asOf = DateTime.UtcNow
|
||||
text = Html "<p>This is your default home page.</p>"
|
||||
}
|
||||
]
|
||||
} conn
|
||||
|
||||
Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}");
|
||||
}
|
||||
Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}");
|
||||
}
|
||||
|
||||
/// Initialize a new database
|
||||
let initDb args sp = task {
|
||||
match args |> Array.length with
|
||||
| 5 -> return! initDbValidated args sp
|
||||
| _ ->
|
||||
Console.WriteLine "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
|
||||
return! System.Threading.Tasks.Task.CompletedTask
|
||||
}
|
||||
/// Create a new web log
|
||||
let create args sp = task {
|
||||
match args |> Array.length with
|
||||
| 5 -> return! createWebLog args sp
|
||||
| _ ->
|
||||
Console.WriteLine "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
|
||||
return! System.Threading.Tasks.Task.CompletedTask
|
||||
}
|
||||
|
||||
|
||||
open DotLiquid
|
||||
@@ -145,6 +147,7 @@ open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog.ViewModels
|
||||
open RethinkDB.DistributedCache
|
||||
open RethinkDb.Driver.FSharp
|
||||
|
||||
[<EntryPoint>]
|
||||
@@ -177,6 +180,14 @@ let main args =
|
||||
} |> Async.AwaitTask |> Async.RunSynchronously
|
||||
let _ = builder.Services.AddSingleton<IConnection> conn
|
||||
|
||||
let _ = builder.Services.AddDistributedRethinkDBCache (fun opts ->
|
||||
opts.Database <- rethinkCfg.Database
|
||||
opts.Connection <- conn)
|
||||
let _ = builder.Services.AddSession(fun opts ->
|
||||
opts.IdleTimeout <- TimeSpan.FromMinutes 30
|
||||
opts.Cookie.HttpOnly <- true
|
||||
opts.Cookie.IsEssential <- true)
|
||||
|
||||
// Set up DotLiquid
|
||||
Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter>
|
||||
Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links"
|
||||
@@ -189,6 +200,7 @@ let main args =
|
||||
Template.RegisterSafeType (typeof<DisplayPage>, all)
|
||||
Template.RegisterSafeType (typeof<SettingsModel>, all)
|
||||
Template.RegisterSafeType (typeof<EditPageModel>, all)
|
||||
Template.RegisterSafeType (typeof<UserMessage>, all)
|
||||
|
||||
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
|
||||
Template.RegisterSafeType (typeof<string option>, all)
|
||||
@@ -197,13 +209,14 @@ let main args =
|
||||
let app = builder.Build ()
|
||||
|
||||
match args |> Array.tryHead with
|
||||
| Some it when it = "init" -> initDb args app.Services |> Async.AwaitTask |> Async.RunSynchronously
|
||||
| Some it when it = "init" -> NewWebLog.create args app.Services |> Async.AwaitTask |> Async.RunSynchronously
|
||||
| _ ->
|
||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseMiddleware<WebLogMiddleware> ()
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseSession ()
|
||||
let _ = app.UseGiraffe Handlers.endpoints
|
||||
|
||||
app.Run()
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
"RethinkDB": {
|
||||
"hostname": "data02.bitbadger.solutions",
|
||||
"database": "myWebLog-dev"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"RethinkDB.DistributedCache": "Debug",
|
||||
"RethinkDb.Driver": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2 class="py-3">{{ web_log.name }} • Dashboard</h2>
|
||||
<h2 class="my-3">{{ web_log.name }} • Dashboard</h2>
|
||||
<article class="container">
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
|
||||
@@ -37,6 +37,20 @@
|
||||
</nav>
|
||||
</header>
|
||||
<main class="mx-3">
|
||||
{% if messages %}
|
||||
<div class="messages mt-2">
|
||||
{% for msg in messages %}
|
||||
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
|
||||
{{ msg.message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
{% if msg.detail %}
|
||||
<hr>
|
||||
<p>{{ msg.detail.value }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<h2 class="py-3">Log On to {{ web_log.name }}</h2>
|
||||
<article class="pb-3">
|
||||
<h2 class="my-3">Log On to {{ web_log.name }}</h2>
|
||||
<article class="py-3">
|
||||
<form action="/user/log-on" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="container">
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
<h2 class="py-3">{{ page_title }}</h2>
|
||||
<h2 class="my-3">{{ page_title }}</h2>
|
||||
<article>
|
||||
<form action="/page/save" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<input type="hidden" name="pageId" value="{{ model.page_id }}">
|
||||
<div class="container">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<div class="col-9">
|
||||
<div class="form-floating pb-3">
|
||||
<input type="text" name="title" id="title" class="form-control" autofocus required
|
||||
value="{{ model.title }}">
|
||||
<label for="title">Title</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-9">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="permalink" id="permalink" class="form-control" required
|
||||
value="{{ model.permalink }}">
|
||||
<label for="permalink">Permalink</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3 align-self-center">
|
||||
<div class="col-3">
|
||||
<div class="form-floating pb-3">
|
||||
<select name="template" id="template" class="form-control">
|
||||
{% for tmpl in templates -%}
|
||||
<option value="{{ tmpl[0] }}"{% if model.template == tmpl %} selected="selected"{% endif %}>
|
||||
{{ tmpl[1] }}
|
||||
</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="template">Page Template</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" name="isShownInPageList" id="showList" class="form-check-input" value="true"
|
||||
{%- if model.is_shown_in_page_list %} checked="checked"{% endif %}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2 class="py-3">{{ page_title }}</h2>
|
||||
<h2 class="my-3">{{ page_title }}</h2>
|
||||
<article class="container">
|
||||
<a href="/page/new/edit" class="btn btn-primary btn-sm my-3">Create a New Page</a>
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2 class="py-3">{{ web_log.name }} Settings</h2>
|
||||
<h2 class="my-3">{{ web_log.name }} Settings</h2>
|
||||
<article>
|
||||
<form action="/admin/settings" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
|
||||
@@ -35,6 +35,20 @@
|
||||
</nav>
|
||||
</header>
|
||||
<main class="mx-3">
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for msg in messages %}
|
||||
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
|
||||
{{ msg.message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
{% if msg.detail %}
|
||||
<hr>
|
||||
<p>{{ msg.detail.value }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer>
|
||||
@@ -43,5 +57,8 @@
|
||||
<img src="/img/logo-dark.png" alt="myWebLog">
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
border-top: solid 1px black;
|
||||
color: white;
|
||||
}
|
||||
.messages {
|
||||
max-width: 60rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
4
src/MyWebLog/wwwroot/themes/default/style.css
Normal file
4
src/MyWebLog/wwwroot/themes/default/style.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.messages {
|
||||
max-width: 60rem;
|
||||
margin: auto;
|
||||
}
|
||||
Reference in New Issue
Block a user