Version 3 (#40)
Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
24
src/JobsJobsJobs/Application/ApiHandlers.fs
Normal file
24
src/JobsJobsJobs/Application/ApiHandlers.fs
Normal 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 ]
|
||||
]
|
||||
85
src/JobsJobsJobs/Application/App.fs
Normal file
85
src/JobsJobsJobs/Application/App.fs
Normal 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
|
||||
28
src/JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj
Normal file
28
src/JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj
Normal 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>
|
||||
8
src/JobsJobsJobs/Application/appsettings.json
Normal file
8
src/JobsJobsJobs/Application/appsettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"JobsJobsJobs.Api.Handlers.Citizen": "Information",
|
||||
"Microsoft.AspNetCore.StaticFiles": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/JobsJobsJobs/Application/wwwroot/audio/pelosi-jobs.mp3
Normal file
BIN
src/JobsJobsJobs/Application/wwwroot/audio/pelosi-jobs.mp3
Normal file
Binary file not shown.
BIN
src/JobsJobsJobs/Application/wwwroot/audio/thats-true.mp3
Normal file
BIN
src/JobsJobsJobs/Application/wwwroot/audio/thats-true.mp3
Normal file
Binary file not shown.
303
src/JobsJobsJobs/Application/wwwroot/script.js
Normal file
303
src/JobsJobsJobs/Application/wwwroot/script.js
Normal 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> – ${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()
|
||||
248
src/JobsJobsJobs/Application/wwwroot/style.css
Normal file
248
src/JobsJobsJobs/Application/wwwroot/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user