Version 3 (#40)

Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
2023-02-02 18:47:28 -05:00
committed by GitHub
parent 323ea83594
commit f3a7b9ea93
126 changed files with 7136 additions and 29577 deletions

View File

@@ -0,0 +1,24 @@
/// Route handlers for Giraffe endpoints
module JobsJobsJobs.Api.Handlers
open System.IO
open Giraffe
open JobsJobsJobs.Common.Handlers
open JobsJobsJobs.Domain
// POST: /api/markdown-preview
let markdownPreview : HttpHandler = requireUser >=> fun next ctx -> task {
let _ = ctx.Request.Body.Seek(0L, SeekOrigin.Begin)
use reader = new StreamReader (ctx.Request.Body)
let! preview = reader.ReadToEndAsync ()
return! htmlString (MarkdownString.toHtml (Text preview)) next ctx
}
open Giraffe.EndpointRouting
/// All API endpoints
let endpoints =
subRoute "/api" [
POST [ route "/markdown-preview" markdownPreview ]
]

View File

@@ -0,0 +1,85 @@
/// The main web server application for Jobs, Jobs, Jobs
module JobsJobsJobs.App
open System
open System.Text
open Giraffe
open Giraffe.EndpointRouting
open JobsJobsJobs.Common.Data
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.HttpOverrides
open Microsoft.Extensions.Caching.Distributed
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open NodaTime
/// Enable buffering on the request body
type BufferedBodyMiddleware (next : RequestDelegate) =
member _.InvokeAsync (ctx : HttpContext) = task {
ctx.Request.EnableBuffering ()
return! next.Invoke ctx
}
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder args
let svc = builder.Services
let _ = svc.AddGiraffe ()
let _ = svc.AddSingleton<IClock> SystemClock.Instance
let _ = svc.AddLogging ()
let _ = svc.AddCors ()
let _ = svc.AddSingleton<Json.ISerializer> (SystemTextJson.Serializer Json.options)
let _ = svc.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
let _ = svc.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(fun o ->
o.ExpireTimeSpan <- TimeSpan.FromMinutes 60
o.SlidingExpiration <- true
o.AccessDeniedPath <- "/error/not-authorized"
o.ClaimsIssuer <- "https://noagendacareers.com")
let _ = svc.AddAuthorization ()
let _ = svc.AddAntiforgery ()
// Set up the data store
let cfg = svc.BuildServiceProvider().GetRequiredService<IConfiguration> ()
let _ = setUp cfg |> Async.AwaitTask |> Async.RunSynchronously
let _ = svc.AddSingleton<IDistributedCache> (fun _ -> DistributedCache () :> IDistributedCache)
let _ = svc.AddSession(fun opts ->
opts.IdleTimeout <- TimeSpan.FromMinutes 60
opts.Cookie.HttpOnly <- true
opts.Cookie.IsEssential <- true)
let app = builder.Build ()
// Unify the endpoints from all features
let endpoints = [
Citizens.Handlers.endpoints
yield! Home.Handlers.endpoints
yield! Listings.Handlers.endpoints
Profiles.Handlers.endpoints
SuccessStories.Handlers.endpoints
Api.Handlers.endpoints
]
let _ = app.UseForwardedHeaders ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseStaticFiles ()
let _ = app.UseRouting ()
let _ = app.UseMiddleware<BufferedBodyMiddleware> ()
let _ = app.UseAuthentication ()
let _ = app.UseAuthorization ()
let _ = app.UseSession ()
let _ = app.UseGiraffeErrorHandler Common.Handlers.Error.unexpectedError
let _ = app.UseEndpoints (fun e -> e.MapGiraffeEndpoints endpoints |> ignore)
app.Run ()
0

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained>
<WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup>
<ItemGroup>
<Compile Include="ApiHandlers.fs" />
<Compile Include="App.fs" />
</ItemGroup>
<ItemGroup>
<Folder Include=".\wwwroot" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Citizens\JobsJobsJobs.Citizens.fsproj" />
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
<ProjectReference Include="..\Home\JobsJobsJobs.Home.fsproj" />
<ProjectReference Include="..\Listings\JobsJobsJobs.Listings.fsproj" />
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"JobsJobsJobs.Api.Handlers.Citizen": "Information",
"Microsoft.AspNetCore.StaticFiles": "Warning"
}
}
}

View File

@@ -0,0 +1,303 @@
/** Script for Jobs, Jobs, Jobs */
this.jjj = {
/**
* Play an audio file
* @param {HTMLElement} elt The element which was clicked
*/
playFile(elt) {
elt.querySelector("audio").play()
},
/**
* Hide the offcanvas menu if it displayed
*/
hideMenu() {
/** @type {HTMLElement} */
const menu = document.querySelector(".jjj-mobile-menu")
if (menu.style.display !== "none") bootstrap.Offcanvas.getOrCreateInstance(menu).hide()
},
/**
* Show a message via an alert
* @param {string} message The message to show
*/
showAlert (message) {
const [level, msg] = message.split("|||")
/** @type {HTMLTemplateElement} */
const alertTemplate = document.getElementById("alertTemplate")
/** @type {HTMLDivElement} */
const alert = alertTemplate.content.firstElementChild.cloneNode(true)
alert.classList.add(`alert-${level === "error" ? "danger" : level}`)
const prefix = level === "success" ? "" : `<strong>${level.toUpperCase()}: </strong>`
alert.querySelector("p").innerHTML = `${prefix}${msg}`
const alerts = document.getElementById("alerts")
alerts.appendChild(alert)
alerts.scrollIntoView()
},
/**
* The time zone of the current browser
* @type {string}
*/
timeZone: undefined,
/**
* Derive the time zone from the current browser
*/
deriveTimeZone () {
try {
this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone
} catch (_) { }
},
/**
* Set up the onClick event for the preview button
* @param {string} editorId The ID of the editor to wire up
*/
markdownOnLoad(editorId) {
document.getElementById(`${editorId}PreviewButton`).addEventListener("click", () => { this.showPreview(editorId) })
},
/**
* Show a preview of the Markdown in the given editor
* @param {string} editorId The ID of the Markdown editor whose preview should be shown
*/
async showPreview(editorId) {
/** @type {HTMLButtonElement} */
const editBtn = document.getElementById(`${editorId}EditButton`)
/** @type {HTMLDivElement} */
const editDiv = document.getElementById(`${editorId}Edit`)
/** @type {HTMLTextAreaElement} */
const editor = document.getElementById(editorId)
/** @type {HTMLButtonElement} */
const previewBtn = document.getElementById(`${editorId}PreviewButton`)
/** @type {HTMLDivElement} */
const previewDiv = document.getElementById(`${editorId}Preview`)
editBtn.classList.remove("btn-primary")
editBtn.classList.add("btn-outline-secondary")
editBtn.addEventListener("click", () => { this.showEditor(editorId) })
previewBtn.classList.remove("btn-outline-secondary")
previewBtn.classList.add("btn-primary")
previewBtn.removeEventListener("click", () => { this.showPreview(editorId) })
const preview = await fetch("/api/markdown-preview", { method: "POST", body: editor.value })
let text
if (preview.ok) {
text = await preview.text()
} else {
text = `<p class="text-danger"><strong> ERROR ${preview.status}</strong> &ndash; ${preview.statusText}`
}
previewDiv.innerHTML = text
editDiv.classList.remove("jjj-shown")
editDiv.classList.add("jjj-not-shown")
previewDiv.classList.remove("jjj-not-shown")
previewDiv.classList.add("jjj-shown")
},
/**
* Show the Markdown editor (hides preview)
* @param {string} editorId The ID of the Markdown editor to show
*/
showEditor(editorId) {
/** @type {HTMLButtonElement} */
const editBtn = document.getElementById(`${editorId}EditButton`)
/** @type {HTMLDivElement} */
const editDiv = document.getElementById(`${editorId}Edit`)
/** @type {HTMLTextAreaElement} */
const editor = document.getElementById(editorId)
/** @type {HTMLButtonElement} */
const previewBtn = document.getElementById(`${editorId}PreviewButton`)
/** @type {HTMLDivElement} */
const previewDiv = document.getElementById(`${editorId}Preview`)
previewBtn.classList.remove("btn-primary")
previewBtn.classList.add("btn-outline-secondary")
this.markdownOnLoad(editorId)
editBtn.classList.remove("btn-outline-secondary")
editBtn.classList.add("btn-primary")
editBtn.removeEventListener("click", () => { this.showEditor(editorId) })
previewDiv.classList.remove("jjj-shown")
previewDiv.classList.add("jjj-not-shown")
previewDiv.innerHTML = ""
editDiv.classList.remove("jjj-not-shown")
editDiv.classList.add("jjj-shown")
},
citizen: {
/**
* The next index for a newly-added contact
* @type {number}
*/
nextIndex: 0,
/**
* Add a contact to the account form
*/
addContact() {
const next = this.nextIndex
/** @type {HTMLTemplateElement} */
const newContactTemplate = document.getElementById("newContact")
/** @type {HTMLDivElement} */
const newContact = newContactTemplate.content.firstElementChild.cloneNode(true)
newContact.setAttribute("id", `contactRow${next}`)
const cols = newContact.children
// Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.citizen.removeContact(${next})`)
// Contact Type column
const typeField = cols[1].querySelector("select")
typeField.setAttribute("id", `contactType${next}`)
typeField.setAttribute("name", `Contacts[${this.nextIndex}].ContactType`)
cols[1].querySelector("label").setAttribute("for", `contactType${next}`)
// Name column
const nameField = cols[2].querySelector("input")
nameField.setAttribute("id", `contactName${next}`)
nameField.setAttribute("name", `Contacts[${this.nextIndex}].Name`)
cols[2].querySelector("label").setAttribute("for", `contactName${next}`)
if (next > 0) cols[2].querySelector("div.form-text").remove()
// Value column
const valueField = cols[3].querySelector("input")
valueField.setAttribute("id", `contactValue${next}`)
valueField.setAttribute("name", `Contacts[${this.nextIndex}].Value`)
cols[3].querySelector("label").setAttribute("for", `contactName${next}`)
if (next > 0) cols[3].querySelector("div.form-text").remove()
// Is Public column
const isPublicField = cols[4].querySelector("input")
isPublicField.setAttribute("id", `contactIsPublic${next}`)
isPublicField.setAttribute("name", `Contacts[${this.nextIndex}].IsPublic`)
cols[4].querySelector("label").setAttribute("for", `contactIsPublic${next}`)
// Add the row
const contacts = document.querySelectorAll("div[id^=contactRow]")
const sibling = contacts.length > 0 ? contacts[contacts.length - 1] : newContactTemplate
sibling.insertAdjacentElement('afterend', newContact)
this.nextIndex++
},
/**
* Remove a contact row from the profile form
* @param {number} idx The index of the contact row to remove
*/
removeContact(idx) {
document.getElementById(`contactRow${idx}`).remove()
},
/**
* Register a comparison validation between a password and a "confirm password" field
* @param {string} pwId The ID for the password field
* @param {string} confirmId The ID for the "confirm password" field
* @param {boolean} isRequired Whether these fields are required
*/
validatePasswords(pwId, confirmId, isRequired) {
const pw = document.getElementById(pwId)
const pwConfirm = document.getElementById(confirmId)
pwConfirm.addEventListener("input", () => {
if (!pw.validity.valid) {
pwConfirm.setCustomValidity("")
} else if ((!pwConfirm.validity.valueMissing || !isRequired) && pw.value !== pwConfirm.value) {
pwConfirm.setCustomValidity("Confirmation password does not match")
} else {
pwConfirm.setCustomValidity("")
}
})
}
},
/**
* Script for listing pages
*/
listing: {
/**
* Show or hide the success story prompt based on whether a job was filled here
*/
toggleFromHere() {
/** @type {HTMLInputElement} */
const isFromHere = document.getElementById("FromHere")
const display = isFromHere.checked ? "unset" : "none"
document.getElementById("successRow").style.display = display
document.getElementById("SuccessStoryEditRow").style.display = display
}
},
/**
* Script for profile pages
*/
profile: {
/**
* The next index for a newly-added skill
* @type {number}
*/
nextIndex: 0,
/**
* Add a skill to the profile form
*/
addSkill() {
const next = this.nextIndex
/** @type {HTMLTemplateElement} */
const newSkillTemplate = document.getElementById("newSkill")
/** @type {HTMLDivElement} */
const newSkill = newSkillTemplate.content.firstElementChild.cloneNode(true)
newSkill.setAttribute("id", `skillRow${next}`)
const cols = newSkill.children
// Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill(${next})`)
// Skill column
const skillField = cols[1].querySelector("input")
skillField.setAttribute("id", `skillDesc${next}`)
skillField.setAttribute("name", `Skills[${this.nextIndex}].Description`)
cols[1].querySelector("label").setAttribute("for", `skillDesc${next}`)
if (this.nextIndex > 0) cols[1].querySelector("div.form-text").remove()
// Notes column
const notesField = cols[2].querySelector("input")
notesField.setAttribute("id", `skillNotes${next}`)
notesField.setAttribute("name", `Skills[${this.nextIndex}].Notes`)
cols[2].querySelector("label").setAttribute("for", `skillNotes${next}`)
if (this.nextIndex > 0) cols[2].querySelector("div.form-text").remove()
// Add the row
const skills = document.querySelectorAll("div[id^=skillRow]")
const sibling = skills.length > 0 ? skills[skills.length - 1] : newSkillTemplate
sibling.insertAdjacentElement('afterend', newSkill)
this.nextIndex++
},
/**
* Remove a skill row from the profile form
* @param {number} idx The index of the skill row to remove
*/
removeSkill(idx) {
document.getElementById(`skillRow${idx}`).remove()
}
}
}
htmx.on("htmx:configRequest", function (evt) {
// Send the user's current time zone so that we can display local time
if (jjj.timeZone) {
evt.detail.headers["X-Time-Zone"] = jjj.timeZone
}
})
htmx.on("htmx:responseError", function (evt) {
/** @type {XMLHttpRequest} */
const xhr = evt.detail.xhr
jjj.showAlert(`error|||${xhr.status}: ${xhr.statusText}`)
})
jjj.deriveTimeZone()

View File

@@ -0,0 +1,248 @@
/* Overall styling */
html {
scroll-behavior: smooth;
}
a:link,
a:visited {
text-decoration: none;
}
a:not(.btn):hover {
text-decoration: underline;
}
label.jjj-required::after {
color: red;
content: ' *';
}
label[for]:hover {
cursor: pointer;
}
.jjj-heading-label {
display: inline-block;
font-size: 1rem;
text-transform: uppercase;
}
.material-icons {
vertical-align: bottom;
}
@media print {
.jjj-hide-from-printer {
display: none;
}
}
/* Material Design Icon / Bootstrap styling */
.mdi::before {
font-size: 24px;
line-height: 14px;
}
.btn .mdi::before {
position: relative;
top: 4px;
}
.btn-xs .mdi::before {
font-size: 18px;
top: 3px;
}
.btn-sm .mdi::before {
font-size: 18px;
top: 3px;
}
.dropdown-menu .mdi {
width: 18px;
}
.dropdown-menu .mdi::before {
position: relative;
top: 4px;
left: -8px;
}
.nav .mdi::before {
position: relative;
top: 4px;
}
.navbar .navbar-toggle .mdi::before {
position: relative;
top: 4px;
color: #FFF;
}
.breadcrumb .mdi::before {
position: relative;
top: 4px;
}
.breadcrumb a:hover {
text-decoration: none;
}
.breadcrumb a:hover span {
text-decoration: underline;
}
.alert .mdi::before {
position: relative;
top: 4px;
margin-right: 2px;
}
.input-group-addon .mdi::before {
position: relative;
top: 3px;
}
.navbar-brand .mdi::before {
position: relative;
top: 2px;
margin-right: 2px;
}
.list-group-item .mdi::before {
position: relative;
top: 3px;
left: -3px
}
.mdi-sm::before {
font-size: 1rem;
line-height: unset;
}
/* Layout styling */
.jjj-app {
display: flex;
flex-direction: row;
}
.jjj-main {
flex-grow: 1;
display: flex;
flex-flow: column;
min-height: 100vh;
}
.jjj-content {
flex-grow: 2;
}
/* Menu styling */
.jjj-full-menu,
.jjj-mobile-menu {
background-image: linear-gradient(180deg, darkgreen 0%, green 70%);
color: white;
font-size: 1.2rem;
}
.jjj-full-menu {
min-height: 100vh;
width: 250px;
min-width: 250px;
position: sticky;
top: 0;
display: none;
}
.jjj-full-menu .home-link {
font-size: 1.2rem;
text-align: center;
background-color: rgba(0, 0, 0, .4);
margin: -1rem;
padding: 1rem;
}
.jjj-full-menu a:link,
.jjj-full-menu a:visited {
text-decoration: none;
color: white;
}
#jjjMenu {
flex-direction: column;
flex-grow: 1;
}
@media (min-width: 768px) {
.jjj-full-menu {
display: unset;
}
.jjj-mobile-menu {
display: none;
}
.navbar-expand-md .navbar-nav {
flex-direction: column;
}
}
.jjj-nav a:link,
.jjj-nav a:visited {
text-decoration: none;
color: white;
}
nav.jjj-nav > a {
display: block;
width: 100%;
border-radius: .25rem;
padding: .5rem;
margin: .5rem 0;
font-size: 1rem;
}
nav.jjj-nav > a > i {
vertical-align: top;
margin-right: .25rem;
}
nav.jjj-nav > a > i.mdi::before {
line-height: 24px;
}
nav.jjj-nav > a.jjj-current-page {
background-color: rgba(255, 255, 255, .2);
}
nav.jjj-nav > a:hover {
background-color: rgba(255, 255, 255, .5);
color: black;
text-decoration: none;
}
nav.jjj-nav > div.separator {
border-bottom: solid 1px rgba(255, 255, 255, .75);
height: 1px;
}
/* Title bar styling */
.jjj-main .navbar-dark {
background-image: linear-gradient(0deg, green 0%, darkgreen 70%);
padding-left: 1rem;
padding-right: 1rem;
}
.jjj-main .navbar-dark button {
padding: 0;
}
.jjj-main .navbar-dark .navbar-text {
font-weight: bold;
color: white;
}
.jjj-main .navbar-light .navbar-text {
font-style: italic;
padding: 0 1rem 0 0;
}
/* Audio Clip styling */
.jjj-audio-clip audio {
display: none;
}
span.jjj-audio-clip {
border-bottom: dotted 1px lightgray;
}
span.jjj-audio-clip:hover {
cursor: pointer;
}
/* Markdown Editor styling */
.jjj-not-shown {
display: none;
}
.jjj-shown {
display: inherit;
}
.jjj-markdown-editor {
width: 100%;
/* When wrapping this with Bootstrap's floating label, it shrinks the input down to what a normal one-line input
would be; this overrides that for this textarea specifically */
height: inherit !important;
}
.jjj-markdown-preview {
border: solid 1px lightgray;
border-radius: .5rem;
}
/* Collapse Panel styling */
a[data-bs-toggle] {
color: var(--bs-body-color);
}
a[data-bs-toggle]:hover {
text-decoration: none;
}
/* Footer styling */
footer {
display: flex;
flex-direction: row-reverse;
}
footer p {
padding-top: 2rem;
padding-right: .5rem;
font-style: italic;
font-size: .8rem;
}