Version 3 (#40)
Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
235
src/JobsJobsJobs/Common/Cache.fs
Normal file
235
src/JobsJobsJobs/Common/Cache.fs
Normal file
@@ -0,0 +1,235 @@
|
||||
namespace JobsJobsJobs
|
||||
|
||||
open NodaTime
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Helper types and functions for the cache
|
||||
[<AutoOpen>]
|
||||
module private CacheHelpers =
|
||||
|
||||
open System
|
||||
open System.Threading.Tasks
|
||||
open Npgsql
|
||||
|
||||
/// The cache entry
|
||||
type Entry =
|
||||
{ /// The ID of the cache entry
|
||||
Id : string
|
||||
|
||||
/// The value to be cached
|
||||
Payload : byte[]
|
||||
|
||||
/// When this entry will expire
|
||||
ExpireAt : Instant
|
||||
|
||||
/// The duration by which the expiration should be pushed out when being refreshed
|
||||
SlidingExpiration : Duration option
|
||||
|
||||
/// The must-expire-by date/time for the cache entry
|
||||
AbsoluteExpiration : Instant option
|
||||
}
|
||||
|
||||
/// Run a task synchronously
|
||||
let sync<'T> (it : Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
|
||||
|
||||
/// Get the current instant
|
||||
let getNow () = SystemClock.Instance.GetCurrentInstant ()
|
||||
|
||||
/// Get the first result of the given query
|
||||
let tryHead<'T> (query : Task<'T list>) = backgroundTask {
|
||||
let! results = query
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Create a parameter for a non-standard type
|
||||
let typedParam<'T> name (it : 'T) =
|
||||
$"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it))
|
||||
|
||||
/// Create a parameter for a possibly-missing non-standard type
|
||||
let optParam<'T> name (it : 'T option) =
|
||||
let p = NpgsqlParameter ($"@%s{name}", if Option.isSome it then box it.Value else DBNull.Value)
|
||||
p.ParameterName, Sql.parameter p
|
||||
|
||||
/// Create a parameter for the expire-at time
|
||||
let expireParam =
|
||||
typedParam "expireAt"
|
||||
|
||||
|
||||
open System.Threading
|
||||
open JobsJobsJobs.Common.Data
|
||||
open Microsoft.Extensions.Caching.Distributed
|
||||
|
||||
// getEntry isn't resumable
|
||||
#nowarn "3511"
|
||||
|
||||
/// A distributed cache implementation in PostgreSQL used to handle sessions for Jobs, Jobs, Jobs
|
||||
type DistributedCache () =
|
||||
|
||||
// ~~~ INITIALIZATION ~~~
|
||||
|
||||
do
|
||||
task {
|
||||
let dataSource = dataSource ()
|
||||
let! exists =
|
||||
dataSource
|
||||
|> Sql.query $"
|
||||
SELECT EXISTS
|
||||
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
|
||||
AS does_exist"
|
||||
|> Sql.executeRowAsync (fun row -> row.bool "does_exist")
|
||||
if not exists then
|
||||
let! _ =
|
||||
dataSource
|
||||
|> Sql.query
|
||||
"CREATE TABLE session (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
payload BYTEA NOT NULL,
|
||||
expire_at TIMESTAMPTZ NOT NULL,
|
||||
sliding_expiration INTERVAL,
|
||||
absolute_expiration TIMESTAMPTZ);
|
||||
CREATE INDEX idx_session_expiration ON session (expire_at)"
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
} |> sync
|
||||
|
||||
// ~~~ SUPPORT FUNCTIONS ~~~
|
||||
|
||||
/// Get an entry, updating it for sliding expiration
|
||||
let getEntry key = backgroundTask {
|
||||
let dataSource = dataSource ()
|
||||
let idParam = "@id", Sql.string key
|
||||
let! tryEntry =
|
||||
dataSource
|
||||
|> Sql.query "SELECT * FROM session WHERE id = @id"
|
||||
|> Sql.parameters [ idParam ]
|
||||
|> Sql.executeAsync (fun row ->
|
||||
{ Id = row.string "id"
|
||||
Payload = row.bytea "payload"
|
||||
ExpireAt = row.fieldValue<Instant> "expire_at"
|
||||
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
|
||||
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|
||||
|> tryHead
|
||||
match tryEntry with
|
||||
| Some entry ->
|
||||
let now = getNow ()
|
||||
let slideExp = defaultArg entry.SlidingExpiration Duration.MinValue
|
||||
let absExp = defaultArg entry.AbsoluteExpiration Instant.MinValue
|
||||
let needsRefresh, item =
|
||||
if entry.ExpireAt = absExp then false, entry
|
||||
elif slideExp = Duration.MinValue && absExp = Instant.MinValue then false, entry
|
||||
elif absExp > Instant.MinValue && entry.ExpireAt.Plus slideExp > absExp then
|
||||
true, { entry with ExpireAt = absExp }
|
||||
else true, { entry with ExpireAt = now.Plus slideExp }
|
||||
if needsRefresh then
|
||||
let! _ =
|
||||
dataSource
|
||||
|> Sql.query "UPDATE session SET expire_at = @expireAt WHERE id = @id"
|
||||
|> Sql.parameters [ expireParam item.ExpireAt; idParam ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
return if item.ExpireAt > now then Some entry else None
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// The last time expired entries were purged (runs every 30 minutes)
|
||||
let mutable lastPurge = Instant.MinValue
|
||||
|
||||
/// Purge expired entries every 30 minutes
|
||||
let purge () = backgroundTask {
|
||||
let now = getNow ()
|
||||
if lastPurge.Plus (Duration.FromMinutes 30L) < now then
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query "DELETE FROM session WHERE expire_at < @expireAt"
|
||||
|> Sql.parameters [ expireParam now ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
lastPurge <- now
|
||||
}
|
||||
|
||||
/// Remove a cache entry
|
||||
let removeEntry key = backgroundTask {
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query "DELETE FROM session WHERE id = @id"
|
||||
|> Sql.parameters [ "@id", Sql.string key ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Save an entry
|
||||
let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask {
|
||||
let now = getNow ()
|
||||
let expireAt, slideExp, absExp =
|
||||
if opts.SlidingExpiration.HasValue then
|
||||
let slide = Duration.FromTimeSpan opts.SlidingExpiration.Value
|
||||
now.Plus slide, Some slide, None
|
||||
elif opts.AbsoluteExpiration.HasValue then
|
||||
let exp = Instant.FromDateTimeOffset opts.AbsoluteExpiration.Value
|
||||
exp, None, Some exp
|
||||
elif opts.AbsoluteExpirationRelativeToNow.HasValue then
|
||||
let exp = now.Plus (Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
|
||||
exp, None, Some exp
|
||||
else
|
||||
// Default to 1 hour sliding expiration
|
||||
let slide = Duration.FromHours 1
|
||||
now.Plus slide, Some slide, None
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query
|
||||
"INSERT INTO session (
|
||||
id, payload, expire_at, sliding_expiration, absolute_expiration
|
||||
) VALUES (
|
||||
@id, @payload, @expireAt, @slideExp, @absExp
|
||||
) ON CONFLICT (id) DO UPDATE
|
||||
SET payload = EXCLUDED.payload,
|
||||
expire_at = EXCLUDED.expire_at,
|
||||
sliding_expiration = EXCLUDED.sliding_expiration,
|
||||
absolute_expiration = EXCLUDED.absolute_expiration"
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.string key
|
||||
"@payload", Sql.bytea payload
|
||||
expireParam expireAt
|
||||
optParam "slideExp" slideExp
|
||||
optParam "absExp" absExp ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
|
||||
|
||||
/// Retrieve the data for a cache entry
|
||||
let get key (_ : CancellationToken) = backgroundTask {
|
||||
match! getEntry key with
|
||||
| Some entry ->
|
||||
do! purge ()
|
||||
return entry.Payload
|
||||
| None -> return null
|
||||
}
|
||||
|
||||
/// Refresh an entry
|
||||
let refresh key (cancelToken : CancellationToken) = backgroundTask {
|
||||
let! _ = get key cancelToken
|
||||
()
|
||||
}
|
||||
|
||||
/// Remove an entry
|
||||
let remove key (_ : CancellationToken) = backgroundTask {
|
||||
do! removeEntry key
|
||||
do! purge ()
|
||||
}
|
||||
|
||||
/// Set an entry
|
||||
let set key value options (_ : CancellationToken) = backgroundTask {
|
||||
do! saveEntry options key value
|
||||
do! purge ()
|
||||
}
|
||||
|
||||
interface IDistributedCache with
|
||||
member _.Get key = get key CancellationToken.None |> sync
|
||||
member _.GetAsync (key, token) = get key token
|
||||
member _.Refresh key = refresh key CancellationToken.None |> sync
|
||||
member _.RefreshAsync (key, token) = refresh key token
|
||||
member _.Remove key = remove key CancellationToken.None |> sync
|
||||
member _.RemoveAsync (key, token) = remove key token
|
||||
member _.Set (key, value, options) = set key value options CancellationToken.None |> sync
|
||||
member _.SetAsync (key, value, options, token) = set key value options token
|
||||
200
src/JobsJobsJobs/Common/Data.fs
Normal file
200
src/JobsJobsJobs/Common/Data.fs
Normal file
@@ -0,0 +1,200 @@
|
||||
module JobsJobsJobs.Common.Data
|
||||
|
||||
/// Constants for tables used by Jobs, Jobs, Jobs
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
|
||||
/// Citizens
|
||||
[<Literal>]
|
||||
let Citizen = "jjj.citizen"
|
||||
|
||||
/// Continents
|
||||
[<Literal>]
|
||||
let Continent = "jjj.continent"
|
||||
|
||||
/// Job Listings
|
||||
[<Literal>]
|
||||
let Listing = "jjj.listing"
|
||||
|
||||
/// Employment Profiles
|
||||
[<Literal>]
|
||||
let Profile = "jjj.profile"
|
||||
|
||||
/// User Security Information
|
||||
[<Literal>]
|
||||
let SecurityInfo = "jjj.security_info"
|
||||
|
||||
/// Success Stories
|
||||
[<Literal>]
|
||||
let Success = "jjj.success"
|
||||
|
||||
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Connection management for the document store
|
||||
[<AutoOpen>]
|
||||
module DataConnection =
|
||||
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Npgsql
|
||||
|
||||
/// The data source for the document store
|
||||
let mutable private theDataSource : NpgsqlDataSource option = None
|
||||
|
||||
/// Get the data source as the start of a SQL statement
|
||||
let dataSource () =
|
||||
match theDataSource with
|
||||
| Some ds -> Sql.fromDataSource ds
|
||||
| None -> invalidOp "DataConnection.setUp() must be called before accessing the database"
|
||||
|
||||
/// Create tables
|
||||
let private createTables () = backgroundTask {
|
||||
let sql = [
|
||||
"CREATE SCHEMA IF NOT EXISTS jjj"
|
||||
// Tables
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Citizen} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Continent} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Listing} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Profile} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
text_search TSVECTOR NOT NULL,
|
||||
CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
|
||||
CONSTRAINT fk_security_info_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)"
|
||||
$"CREATE TABLE IF NOT EXISTS {Table.Success} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)"
|
||||
// Key indexes
|
||||
$"CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'))"
|
||||
$"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'))"
|
||||
// Profile text search index
|
||||
$"CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)"
|
||||
]
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ]))
|
||||
()
|
||||
}
|
||||
|
||||
/// Create functions and triggers required to
|
||||
let createTriggers () = backgroundTask {
|
||||
let! functions =
|
||||
dataSource ()
|
||||
|> Sql.query
|
||||
"SELECT p.proname
|
||||
FROM pg_catalog.pg_proc p
|
||||
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'jjj'"
|
||||
|> Sql.executeAsync (fun row -> row.string "proname")
|
||||
if not (functions |> List.contains "indexable_array_string") then
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query """
|
||||
CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
|
||||
BEGIN
|
||||
RETURN REPLACE(REPLACE(REPLACE(REPLACE(jsonb_path_query_array(target, path)::text,
|
||||
'["', ''), '", "', ' '), '"]', ''), '[]', '');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;"""
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
if not (functions |> List.contains "set_text_search") then
|
||||
let! _ =
|
||||
dataSource ()
|
||||
|> Sql.query $"
|
||||
CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.text_search := to_tsvector('english',
|
||||
COALESCE(NEW.data ->> 'region', '') || ' '
|
||||
|| COALESCE(NEW.data ->> 'biography', '') || ' '
|
||||
|| COALESCE(NEW.data ->> 'experience', '') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.skills[*].description') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.history[*].employer') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.history[*].position') || ' '
|
||||
|| jjj.indexable_array_string(NEW.data, '$.history[*].description'));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER set_text_search BEFORE INSERT OR UPDATE ON {Table.Profile}
|
||||
FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();"
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Set up the data connection from the given configuration
|
||||
let setUp (cfg : IConfiguration) = backgroundTask {
|
||||
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL")
|
||||
let _ = builder.UseNodaTime ()
|
||||
theDataSource <- Some (builder.Build ())
|
||||
do! createTables ()
|
||||
do! createTriggers ()
|
||||
}
|
||||
|
||||
|
||||
open System.Text.Json
|
||||
open System.Threading.Tasks
|
||||
open JobsJobsJobs
|
||||
|
||||
/// Map the data field to the requested document type
|
||||
let toDocumentFrom<'T> fieldName (row : RowReader) =
|
||||
JsonSerializer.Deserialize<'T> (row.string fieldName, Json.options)
|
||||
|
||||
/// Map the data field to the requested document type
|
||||
let toDocument<'T> (row : RowReader) = toDocumentFrom<'T> "data" row
|
||||
|
||||
/// Get a document
|
||||
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
|
||||
let! doc =
|
||||
Sql.query $"SELECT * FROM %s{table} where id = @id" sqlProps
|
||||
|> Sql.parameters [ "@id", Sql.string docId ]
|
||||
|> Sql.executeAsync toDocument
|
||||
return List.tryHead doc
|
||||
}
|
||||
|
||||
/// Serialize a document to JSON
|
||||
let mkDoc<'T> (doc : 'T) =
|
||||
JsonSerializer.Serialize<'T> (doc, Json.options)
|
||||
|
||||
/// Save a document
|
||||
let saveDocument table docId sqlProps doc = backgroundTask {
|
||||
let! _ =
|
||||
Sql.query
|
||||
$"INSERT INTO %s{table} (id, data) VALUES (@id, @data)
|
||||
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
|
||||
sqlProps
|
||||
|> Sql.parameters
|
||||
[ "@id", Sql.string docId
|
||||
"@data", Sql.jsonb doc ]
|
||||
|> Sql.executeNonQueryAsync
|
||||
()
|
||||
}
|
||||
|
||||
/// Create a match-anywhere clause for a LIKE or ILIKE clause
|
||||
let like value =
|
||||
Sql.string $"%%%s{value}%%"
|
||||
|
||||
/// The JSON access operator ->> makes values text; this makes a parameter that will compare the properly
|
||||
let jsonBool value =
|
||||
Sql.string (if value then "true" else "false")
|
||||
|
||||
/// Get the SQL for a search WHERE clause
|
||||
let searchSql criteria =
|
||||
let sql = criteria |> List.map fst |> String.concat " AND "
|
||||
if sql = "" then "" else $"AND {sql}"
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continents =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Retrieve all continents
|
||||
let all () =
|
||||
dataSource ()
|
||||
|> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|
||||
|> Sql.executeAsync toDocument<Continent>
|
||||
|
||||
/// Retrieve a continent by its ID
|
||||
let findById continentId =
|
||||
dataSource () |> getDocument<Continent> Table.Continent (ContinentId.toString continentId)
|
||||
496
src/JobsJobsJobs/Common/Domain.fs
Normal file
496
src/JobsJobsJobs/Common/Domain.fs
Normal file
@@ -0,0 +1,496 @@
|
||||
namespace JobsJobsJobs.Domain
|
||||
|
||||
open System
|
||||
open Giraffe
|
||||
open NodaTime
|
||||
|
||||
// ~~~ SUPPORT TYPES ~~~ //
|
||||
|
||||
/// The ID of a user (a citizen of Gitmo Nation)
|
||||
type CitizenId = CitizenId of Guid
|
||||
|
||||
/// Support functions for citizen IDs
|
||||
module CitizenId =
|
||||
|
||||
/// Create a new citizen ID
|
||||
let create () = (Guid.NewGuid >> CitizenId) ()
|
||||
|
||||
/// A string representation of a citizen ID
|
||||
let toString = function CitizenId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a citizen ID
|
||||
let ofString = ShortGuid.toGuid >> CitizenId
|
||||
|
||||
/// Get the GUID value of a citizen ID
|
||||
let value = function CitizenId guid -> guid
|
||||
|
||||
|
||||
/// Types of contacts supported by Jobs, Jobs, Jobs
|
||||
type ContactType =
|
||||
/// E-mail addresses
|
||||
| Email
|
||||
/// Phone numbers (home, work, cell, etc.)
|
||||
| Phone
|
||||
/// Websites (personal, social, etc.)
|
||||
| Website
|
||||
|
||||
/// Functions to support contact types
|
||||
module ContactType =
|
||||
|
||||
/// Parse a contact type from a string
|
||||
let parse typ =
|
||||
match typ with
|
||||
| "Email" -> Email
|
||||
| "Phone" -> Phone
|
||||
| "Website" -> Website
|
||||
| it -> invalidOp $"{it} is not a valid contact type"
|
||||
|
||||
/// Convert a contact type to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Email -> "Email"
|
||||
| Phone -> "Phone"
|
||||
| Website -> "Website"
|
||||
|
||||
|
||||
/// The ID of a continent
|
||||
type ContinentId = ContinentId of Guid
|
||||
|
||||
/// Support functions for continent IDs
|
||||
module ContinentId =
|
||||
|
||||
/// Create a new continent ID
|
||||
let create () = (Guid.NewGuid >> ContinentId) ()
|
||||
|
||||
/// A string representation of a continent ID
|
||||
let toString = function ContinentId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a continent ID
|
||||
let ofString = ShortGuid.toGuid >> ContinentId
|
||||
|
||||
/// Get the GUID value of a continent ID
|
||||
let value = function ContinentId guid -> guid
|
||||
|
||||
|
||||
/// A string of Markdown text
|
||||
type MarkdownString = Text of string
|
||||
|
||||
/// Support functions for Markdown strings
|
||||
module MarkdownString =
|
||||
|
||||
open Markdig
|
||||
|
||||
/// The Markdown conversion pipeline (enables all advanced features)
|
||||
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
|
||||
|
||||
/// Convert this Markdown string to HTML
|
||||
let toHtml = function Text text -> Markdown.ToHtml (text, pipeline)
|
||||
|
||||
/// Convert a Markdown string to its string representation
|
||||
let toString = function Text text -> text
|
||||
|
||||
|
||||
/// An employment history entry
|
||||
[<NoComparison; NoEquality>]
|
||||
type EmploymentHistory =
|
||||
{ /// The employer for this period of employment
|
||||
Employer : string
|
||||
|
||||
/// The date employment started
|
||||
StartDate : LocalDate
|
||||
|
||||
/// The date employment ended (None implies ongoing employment)
|
||||
EndDate : LocalDate option
|
||||
|
||||
/// The title / position held
|
||||
Position : string option
|
||||
|
||||
/// A description of the duties entailed during this employment
|
||||
Description : MarkdownString option
|
||||
}
|
||||
|
||||
/// Support functions for employment history entries
|
||||
module EmploymentHistory =
|
||||
|
||||
let empty =
|
||||
{ Employer = ""
|
||||
StartDate = LocalDate.FromDateTime DateTime.Today
|
||||
EndDate = None
|
||||
Position = None
|
||||
Description = None
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a job listing
|
||||
type ListingId = ListingId of Guid
|
||||
|
||||
/// Support functions for listing IDs
|
||||
module ListingId =
|
||||
|
||||
/// Create a new job listing ID
|
||||
let create () = (Guid.NewGuid >> ListingId) ()
|
||||
|
||||
/// A string representation of a listing ID
|
||||
let toString = function ListingId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a listing ID
|
||||
let ofString = ShortGuid.toGuid >> ListingId
|
||||
|
||||
/// Get the GUID value of a listing ID
|
||||
let value = function ListingId guid -> guid
|
||||
|
||||
|
||||
/// Another way to contact a citizen from this site
|
||||
[<NoComparison; NoEquality>]
|
||||
type OtherContact =
|
||||
{ /// The type of contact
|
||||
ContactType : ContactType
|
||||
|
||||
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)
|
||||
Name : string option
|
||||
|
||||
/// The value for the contact (e-mail address, user name, URL, etc.)
|
||||
Value : string
|
||||
|
||||
/// Whether this contact is visible in public employment profiles and job listings
|
||||
IsPublic : bool
|
||||
}
|
||||
|
||||
|
||||
/// Visibility options for an employment profile
|
||||
type ProfileVisibility =
|
||||
/// Profile is only visible to the citizen to whom it belongs
|
||||
| Hidden
|
||||
/// Profile is only visible to authenticated users
|
||||
| Private
|
||||
/// Anonymous information is visible to public users
|
||||
| Anonymous
|
||||
/// The full employment profile is visible to public users
|
||||
| Public
|
||||
|
||||
/// Support functions for profile visibility
|
||||
module ProfileVisibility =
|
||||
|
||||
/// Parse a string into a profile visibility
|
||||
let parse viz =
|
||||
match viz with
|
||||
| "Hidden" -> Hidden
|
||||
| "Private" -> Private
|
||||
| "Anonymous" -> Anonymous
|
||||
| "Public" -> Public
|
||||
| it -> invalidOp $"{it} is not a valid profile visibility value"
|
||||
|
||||
/// Convert a profile visibility to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Hidden -> "Hidden"
|
||||
| Private -> "Private"
|
||||
| Anonymous -> "Anonymous"
|
||||
| Public -> "Public"
|
||||
|
||||
|
||||
/// A skill the job seeker possesses
|
||||
[<NoComparison; NoEquality>]
|
||||
type Skill =
|
||||
{ /// A description of the skill
|
||||
Description : string
|
||||
|
||||
/// Notes regarding this skill (level, duration, etc.)
|
||||
Notes : string option
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a success report
|
||||
type SuccessId = SuccessId of Guid
|
||||
|
||||
/// Support functions for success report IDs
|
||||
module SuccessId =
|
||||
|
||||
/// Create a new success report ID
|
||||
let create () = (Guid.NewGuid >> SuccessId) ()
|
||||
|
||||
/// A string representation of a success report ID
|
||||
let toString = function SuccessId it -> ShortGuid.fromGuid it
|
||||
|
||||
/// Parse a string into a success report ID
|
||||
let ofString = ShortGuid.toGuid >> SuccessId
|
||||
|
||||
/// Get the GUID value of a success ID
|
||||
let value = function SuccessId guid -> guid
|
||||
|
||||
// ~~~ DOCUMENT TYPES ~~~ //
|
||||
|
||||
/// A user of Jobs, Jobs, Jobs; a citizen of Gitmo Nation
|
||||
[<NoComparison; NoEquality>]
|
||||
type Citizen =
|
||||
{ /// The ID of the user
|
||||
Id : CitizenId
|
||||
|
||||
/// When the user joined Jobs, Jobs, Jobs
|
||||
JoinedOn : Instant
|
||||
|
||||
/// When the user last logged in
|
||||
LastSeenOn : Instant
|
||||
|
||||
/// The user's e-mail address
|
||||
Email : string
|
||||
|
||||
/// The user's first name
|
||||
FirstName : string
|
||||
|
||||
/// The user's last name
|
||||
LastName : string
|
||||
|
||||
/// The hash of the user's password
|
||||
PasswordHash : string
|
||||
|
||||
/// The name displayed for this user throughout the site
|
||||
DisplayName : string option
|
||||
|
||||
/// The other contacts for this user
|
||||
OtherContacts : OtherContact list
|
||||
|
||||
/// Whether this is a legacy citizen
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for citizens
|
||||
module Citizen =
|
||||
|
||||
/// An empty citizen
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
JoinedOn = Instant.MinValue
|
||||
LastSeenOn = Instant.MinValue
|
||||
Email = ""
|
||||
FirstName = ""
|
||||
LastName = ""
|
||||
PasswordHash = ""
|
||||
DisplayName = None
|
||||
OtherContacts = []
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
/// Get the name of the citizen (either their preferred display name or first/last names)
|
||||
let name x =
|
||||
match x.DisplayName with Some it -> it | None -> $"{x.FirstName} {x.LastName}"
|
||||
|
||||
|
||||
/// A continent
|
||||
[<NoComparison; NoEquality>]
|
||||
type Continent =
|
||||
{ /// The ID of the continent
|
||||
Id : ContinentId
|
||||
|
||||
/// The name of the continent
|
||||
Name : string
|
||||
}
|
||||
|
||||
/// Support functions for continents
|
||||
module Continent =
|
||||
|
||||
/// An empty continent
|
||||
let empty ={
|
||||
Id = ContinentId Guid.Empty
|
||||
Name = ""
|
||||
}
|
||||
|
||||
|
||||
/// A job listing
|
||||
[<NoComparison; NoEquality>]
|
||||
type Listing =
|
||||
{ /// The ID of the job listing
|
||||
Id : ListingId
|
||||
|
||||
/// The ID of the citizen who posted the job listing
|
||||
CitizenId : CitizenId
|
||||
|
||||
/// When this job listing was created
|
||||
CreatedOn : Instant
|
||||
|
||||
/// The short title of the job listing
|
||||
Title : string
|
||||
|
||||
/// The ID of the continent on which the job is located
|
||||
ContinentId : ContinentId
|
||||
|
||||
/// The region in which the job is located
|
||||
Region : string
|
||||
|
||||
/// Whether this listing is for remote work
|
||||
IsRemote : bool
|
||||
|
||||
/// Whether this listing has expired
|
||||
IsExpired : bool
|
||||
|
||||
/// When this listing was last updated
|
||||
UpdatedOn : Instant
|
||||
|
||||
/// The details of this job
|
||||
Text : MarkdownString
|
||||
|
||||
/// When this job needs to be filled
|
||||
NeededBy : LocalDate option
|
||||
|
||||
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
|
||||
WasFilledHere : bool option
|
||||
|
||||
/// Whether this is a legacy listing
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for job listings
|
||||
module Listing =
|
||||
|
||||
/// An empty job listing
|
||||
let empty = {
|
||||
Id = ListingId Guid.Empty
|
||||
CitizenId = CitizenId Guid.Empty
|
||||
CreatedOn = Instant.MinValue
|
||||
Title = ""
|
||||
ContinentId = ContinentId Guid.Empty
|
||||
Region = ""
|
||||
IsRemote = false
|
||||
IsExpired = false
|
||||
UpdatedOn = Instant.MinValue
|
||||
Text = Text ""
|
||||
NeededBy = None
|
||||
WasFilledHere = None
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
/// Security settings for a user
|
||||
[<NoComparison; NoEquality>]
|
||||
type SecurityInfo =
|
||||
{ /// The ID of the citizen to whom these settings apply
|
||||
Id : CitizenId
|
||||
|
||||
/// The number of failed log on attempts (reset to 0 on successful log on)
|
||||
FailedLogOnAttempts : int
|
||||
|
||||
/// Whether the account is locked
|
||||
AccountLocked : bool
|
||||
|
||||
/// The token the user must provide to take their desired action
|
||||
Token : string option
|
||||
|
||||
/// The action to which the token applies
|
||||
TokenUsage : string option
|
||||
|
||||
/// When the token expires
|
||||
TokenExpires : Instant option
|
||||
}
|
||||
|
||||
/// Functions to support security info
|
||||
module SecurityInfo =
|
||||
|
||||
/// An empty set of security info
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
FailedLogOnAttempts = 0
|
||||
AccountLocked = false
|
||||
Token = None
|
||||
TokenUsage = None
|
||||
TokenExpires = None
|
||||
}
|
||||
|
||||
|
||||
/// A job seeker profile
|
||||
[<NoComparison; NoEquality>]
|
||||
type Profile =
|
||||
{ /// The ID of the citizen to whom this profile belongs
|
||||
Id : CitizenId
|
||||
|
||||
/// The ID of the continent on which the citizen resides
|
||||
ContinentId : ContinentId
|
||||
|
||||
/// The region in which the citizen resides
|
||||
Region : string
|
||||
|
||||
/// Whether this citizen is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether the citizen is interested in remote work
|
||||
IsRemote : bool
|
||||
|
||||
/// Whether the citizen is interested in full-time work
|
||||
IsFullTime : bool
|
||||
|
||||
/// The citizen's professional biography
|
||||
Biography : MarkdownString
|
||||
|
||||
/// Skills this citizen possesses
|
||||
Skills : Skill list
|
||||
|
||||
/// The citizen's employment history
|
||||
History : EmploymentHistory list
|
||||
|
||||
/// The citizen's experience (topical / chronological)
|
||||
Experience : MarkdownString option
|
||||
|
||||
/// The visibility of this profile
|
||||
Visibility : ProfileVisibility
|
||||
|
||||
/// When the citizen last updated their profile
|
||||
LastUpdatedOn : Instant
|
||||
|
||||
/// Whether this is a legacy profile
|
||||
IsLegacy : bool
|
||||
}
|
||||
|
||||
/// Support functions for Profiles
|
||||
module Profile =
|
||||
|
||||
// An empty profile
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
ContinentId = ContinentId Guid.Empty
|
||||
Region = ""
|
||||
IsSeekingEmployment = false
|
||||
IsRemote = false
|
||||
IsFullTime = false
|
||||
Biography = Text ""
|
||||
Skills = []
|
||||
History = []
|
||||
Experience = None
|
||||
Visibility = Private
|
||||
LastUpdatedOn = Instant.MinValue
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
/// A record of success finding employment
|
||||
[<NoComparison; NoEquality>]
|
||||
type Success =
|
||||
{ /// The ID of the success report
|
||||
Id : SuccessId
|
||||
|
||||
/// The ID of the citizen who wrote this success report
|
||||
CitizenId : CitizenId
|
||||
|
||||
/// When this success report was recorded
|
||||
RecordedOn : Instant
|
||||
|
||||
/// Whether the success was due, at least in part, to Jobs, Jobs, Jobs
|
||||
IsFromHere : bool
|
||||
|
||||
/// The source of this success (listing or profile)
|
||||
Source : string
|
||||
|
||||
/// The success story
|
||||
Story : MarkdownString option
|
||||
}
|
||||
|
||||
/// Support functions for success stories
|
||||
module Success =
|
||||
|
||||
/// An empty success story
|
||||
let empty = {
|
||||
Id = SuccessId Guid.Empty
|
||||
CitizenId = CitizenId Guid.Empty
|
||||
RecordedOn = Instant.MinValue
|
||||
IsFromHere = false
|
||||
Source = ""
|
||||
Story = None
|
||||
}
|
||||
94
src/JobsJobsJobs/Common/Email.fs
Normal file
94
src/JobsJobsJobs/Common/Email.fs
Normal file
@@ -0,0 +1,94 @@
|
||||
module JobsJobsJobs.Email
|
||||
|
||||
open System.Net
|
||||
open JobsJobsJobs.Domain
|
||||
open MailKit.Net.Smtp
|
||||
open MailKit.Security
|
||||
open MimeKit
|
||||
|
||||
/// Private functions for sending e-mail
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
/// Create an SMTP client
|
||||
let smtpClient () = backgroundTask {
|
||||
let client = new SmtpClient ()
|
||||
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None)
|
||||
return client
|
||||
}
|
||||
|
||||
/// Create a message with to, from, and subject completed
|
||||
let createMessage citizen subject =
|
||||
let msg = new MimeMessage ()
|
||||
msg.From.Add (MailboxAddress ("Jobs, Jobs, Jobs", "daniel@bitbadger.solutions"))
|
||||
msg.To.Add (MailboxAddress (Citizen.name citizen, citizen.Email))
|
||||
msg.Subject <- subject
|
||||
msg
|
||||
|
||||
|
||||
/// Send an account confirmation e-mail
|
||||
let sendAccountConfirmation citizen security = backgroundTask {
|
||||
let token = WebUtility.UrlEncode security.Token.Value
|
||||
use! client = smtpClient ()
|
||||
use msg = createMessage citizen "Account Confirmation Request"
|
||||
|
||||
let text =
|
||||
[ $"ITM, {Citizen.name citizen}!"
|
||||
""
|
||||
"This e-mail address was recently used to establish an account on"
|
||||
"Jobs, Jobs, Jobs (noagendacareers.com). Before this account can be"
|
||||
"used, it needs to be verified. Please click the link below to do so;"
|
||||
"it will work for the next 72 hours (3 days)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/confirm/{token}"
|
||||
""
|
||||
"If you did not take this action, you can do nothing, and the account"
|
||||
"will be deleted at the end of that time. If you wish to delete it"
|
||||
"immediately, use the link below (also valid for 72 hours)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/deny/{token}"
|
||||
""
|
||||
"TYFYC!"
|
||||
""
|
||||
"--"
|
||||
"Jobs, Jobs, Jobs"
|
||||
"https://noagendacareers.com"
|
||||
] |> String.concat "\n"
|
||||
use msgText = new TextPart (Text = text)
|
||||
msg.Body <- msgText
|
||||
|
||||
return! client.SendAsync msg
|
||||
}
|
||||
|
||||
/// Send a password reset link
|
||||
let sendPasswordReset citizen security = backgroundTask {
|
||||
let token = WebUtility.UrlEncode security.Token.Value
|
||||
use! client = smtpClient ()
|
||||
use msg = createMessage citizen "Reset Password for Jobs, Jobs, Jobs"
|
||||
|
||||
let text =
|
||||
[ $"ITM, {Citizen.name citizen}!"
|
||||
""
|
||||
"We recently receive a request to reset the password for your account"
|
||||
"on Jobs, Jobs, Jobs (noagendacareers.com). Use the link below to"
|
||||
"do so; it will work for the next 72 hours (3 days)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/reset-password/{token}"
|
||||
""
|
||||
"If you did not take this action, you can do nothing, and the link"
|
||||
"will expire normally. If you wish to expire the token immediately,"
|
||||
"use the link below (also valid for 72 hours)."
|
||||
""
|
||||
$"https://noagendacareers.com/citizen/cancel-reset/{token}"
|
||||
""
|
||||
"TYFYC!"
|
||||
""
|
||||
"--"
|
||||
"Jobs, Jobs, Jobs"
|
||||
"https://noagendacareers.com"
|
||||
] |> String.concat "\n"
|
||||
use msgText = new TextPart (Text = text)
|
||||
msg.Body <- msgText
|
||||
|
||||
return! client.SendAsync msg
|
||||
}
|
||||
206
src/JobsJobsJobs/Common/Handlers.fs
Normal file
206
src/JobsJobsJobs/Common/Handlers.fs
Normal file
@@ -0,0 +1,206 @@
|
||||
/// Common helper functions for views
|
||||
module JobsJobsJobs.Common.Handlers
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Htmx
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
[<AutoOpen>]
|
||||
module HtmxHelpers =
|
||||
|
||||
/// Is the request from htmx?
|
||||
let isHtmx (ctx : HttpContext) =
|
||||
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||
|
||||
|
||||
/// Handlers for error conditions
|
||||
module Error =
|
||||
|
||||
open System.Net
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler = fun _ ctx ->
|
||||
let fac = ctx.GetService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger "Handler"
|
||||
let path = string ctx.Request.Path
|
||||
log.LogInformation "Returning 404"
|
||||
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" earlyReturn ctx
|
||||
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler = fun next ctx ->
|
||||
if ctx.Request.Method = "GET" then
|
||||
let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
|
||||
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx
|
||||
else redirectTo false redirectUrl next ctx
|
||||
else
|
||||
if isHtmx ctx then
|
||||
(setHttpHeader "X-Toast" $"error|||You are not authorized to access the URL {ctx.Request.Path.Value}"
|
||||
>=> setStatusCode 401) earlyReturn ctx
|
||||
else setStatusCode 401 earlyReturn ctx
|
||||
|
||||
/// Handler to log 500s and return a message we can display in the application
|
||||
let unexpectedError (ex: exn) (log : ILogger) =
|
||||
log.LogError(ex, "An unexpected error occurred")
|
||||
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||
|
||||
|
||||
open System
|
||||
open System.Security.Claims
|
||||
open System.Text.Json
|
||||
open System.Text.RegularExpressions
|
||||
open JobsJobsJobs.Domain
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open NodaTime
|
||||
|
||||
/// Get the NodaTime clock from the request context
|
||||
let now (ctx : HttpContext) = ctx.GetService<IClock>().GetCurrentInstant ()
|
||||
|
||||
/// Get the application configuration from the request context
|
||||
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||
|
||||
/// Get the logger factory from the request context
|
||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||
|
||||
/// `None` if a `string option` is `None`, whitespace, or empty
|
||||
let noneIfBlank (s : string option) =
|
||||
s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
|
||||
|
||||
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
|
||||
let noneIfEmpty = Option.ofObj >> noneIfBlank
|
||||
|
||||
/// Try to get the current user
|
||||
let tryUser (ctx : HttpContext) =
|
||||
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun x -> x.Value)
|
||||
|
||||
/// Get the ID of the currently logged in citizen
|
||||
// NOTE: if no one is logged in, this will raise an exception
|
||||
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
|
||||
|
||||
let antiForgerySvc (ctx : HttpContext) =
|
||||
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
/// Obtain an anti-forgery token set
|
||||
let csrf ctx =
|
||||
(antiForgerySvc ctx).GetAndStoreTokens ctx
|
||||
|
||||
/// Get the time zone from the citizen's browser
|
||||
let timeZone (ctx : HttpContext) =
|
||||
let tz = string ctx.Request.Headers["X-Time-Zone"]
|
||||
defaultArg (noneIfEmpty tz) "Etc/UTC"
|
||||
|
||||
/// The key to use to indicate if we have loaded the session
|
||||
let private sessionLoadedKey = "session-loaded"
|
||||
|
||||
/// Load the session if we have not yet
|
||||
let private loadSession (ctx : HttpContext) = task {
|
||||
if not (ctx.Items.ContainsKey sessionLoadedKey) then
|
||||
do! ctx.Session.LoadAsync ()
|
||||
ctx.Items.Add (sessionLoadedKey, "yes")
|
||||
}
|
||||
|
||||
/// Save the session if we have loaded it
|
||||
let private saveSession (ctx : HttpContext) = task {
|
||||
if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync ()
|
||||
}
|
||||
|
||||
/// Get the messages from the session (destructively)
|
||||
let popMessages ctx = task {
|
||||
do! loadSession ctx
|
||||
let msgs =
|
||||
match ctx.Session.GetString "messages" with
|
||||
| null -> []
|
||||
| m -> JsonSerializer.Deserialize<string list> m
|
||||
if not (List.isEmpty msgs) then ctx.Session.Remove "messages"
|
||||
return List.rev msgs
|
||||
}
|
||||
|
||||
/// Add a message to the response
|
||||
let addMessage (level : string) (msg : string) ctx = task {
|
||||
do! loadSession ctx
|
||||
let! msgs = popMessages ctx
|
||||
ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs))
|
||||
}
|
||||
|
||||
/// Add a success message to the response
|
||||
let addSuccess msg ctx = task {
|
||||
do! addMessage "success" msg ctx
|
||||
}
|
||||
|
||||
/// Add an error message to the response
|
||||
let addError msg ctx = task {
|
||||
do! addMessage "error" msg ctx
|
||||
}
|
||||
|
||||
/// Add a list of errors to the response
|
||||
let addErrors (errors : string list) ctx = task {
|
||||
let errMsg = String.Join ("</li><li>", errors)
|
||||
do! addError $"Please correct the following errors:<ul><li>{errMsg}</li></ul>" ctx
|
||||
}
|
||||
|
||||
open JobsJobsJobs.Common.Views
|
||||
|
||||
/// Create the render context for an HTML response
|
||||
let private createContext (ctx : HttpContext) pageTitle content messages : Layout.PageRenderContext =
|
||||
{ IsLoggedOn = Option.isSome (tryUser ctx)
|
||||
CurrentUrl = ctx.Request.Path.Value
|
||||
PageTitle = pageTitle
|
||||
Content = content
|
||||
Messages = messages
|
||||
}
|
||||
|
||||
/// Render a page-level view
|
||||
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
|
||||
let! messages = popMessages ctx
|
||||
let renderCtx = createContext ctx pageTitle content messages
|
||||
let renderFunc = if isHtmx ctx then Layout.partial else Layout.full
|
||||
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
||||
}
|
||||
|
||||
/// Render a printable view (content with styles, but no layout)
|
||||
let renderPrint pageTitle (_ : HttpFunc) (ctx : HttpContext) content =
|
||||
createContext ctx pageTitle content []
|
||||
|> Layout.print
|
||||
|> ctx.WriteHtmlViewAsync
|
||||
|
||||
/// Render a bare (component) view
|
||||
let renderBare (_ : HttpFunc) (ctx : HttpContext) content =
|
||||
createContext ctx "" content []
|
||||
|> Layout.bare
|
||||
|> ctx.WriteHtmlViewAsync
|
||||
|
||||
/// Render as a composable HttpHandler
|
||||
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
|
||||
render pageTitle next ctx content
|
||||
|
||||
/// Validate the anti cross-site request forgery token in the current request
|
||||
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||
match! (antiForgerySvc ctx).IsRequestValidAsync ctx with
|
||||
| true -> return! next ctx
|
||||
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
|
||||
}
|
||||
|
||||
/// Require a user to be logged on for a route
|
||||
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
||||
|
||||
/// Regular expression to validate that a URL is a local URL
|
||||
let isLocal = Regex """^/[^\/\\].*"""
|
||||
|
||||
/// Redirect to another page, saving the session before redirecting
|
||||
let redirectToGet (url : string) next ctx = task {
|
||||
do! saveSession ctx
|
||||
let action =
|
||||
if Option.isSome (noneIfEmpty url) && (url = "/" || isLocal.IsMatch url) then
|
||||
if isHtmx ctx then withHxRedirect url else redirectTo false url
|
||||
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
|
||||
return! action next ctx
|
||||
}
|
||||
|
||||
/// Shorthand for Error.notFound for use in handler functions
|
||||
let notFound ctx =
|
||||
Error.notFound earlyReturn ctx
|
||||
31
src/JobsJobsJobs/Common/JobsJobsJobs.Common.fsproj
Normal file
31
src/JobsJobsJobs/Common/JobsJobsJobs.Common.fsproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Email.fs" />
|
||||
<Compile Include="Json.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Views.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Cache.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.0.7" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.8.5" />
|
||||
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.5" />
|
||||
<PackageReference Include="MailKit" Version="3.3.0" />
|
||||
<PackageReference Include="Markdig" Version="0.30.4" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||
<PackageReference Include="Npgsql.FSharp" Version="5.6.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
34
src/JobsJobsJobs/Common/Json.fs
Normal file
34
src/JobsJobsJobs/Common/Json.fs
Normal file
@@ -0,0 +1,34 @@
|
||||
/// JSON serializer options
|
||||
module JobsJobsJobs.Json
|
||||
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Convert a wrapped DU to/from its string representation
|
||||
type WrappedJsonConverter<'T> (wrap : string -> 'T, unwrap : 'T -> string) =
|
||||
inherit JsonConverter<'T> ()
|
||||
override _.Read(reader, _, _) =
|
||||
wrap (reader.GetString ())
|
||||
override _.Write(writer, value, _) =
|
||||
writer.WriteStringValue (unwrap value)
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.SystemTextJson
|
||||
|
||||
/// JsonSerializer options that use the custom converters
|
||||
let options =
|
||||
let opts = JsonSerializerOptions ()
|
||||
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
||||
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
||||
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
||||
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
||||
WrappedJsonConverter (Text, MarkdownString.toString)
|
||||
WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
|
||||
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
||||
JsonFSharpConverter ()
|
||||
]
|
||||
|> List.iter opts.Converters.Add
|
||||
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||
opts
|
||||
393
src/JobsJobsJobs/Common/Views.fs
Normal file
393
src/JobsJobsJobs/Common/Views.fs
Normal file
@@ -0,0 +1,393 @@
|
||||
/// Common functions for views
|
||||
module JobsJobsJobs.Common.Views
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Create an audio clip with the specified text node
|
||||
let audioClip clip text =
|
||||
span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [
|
||||
text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ]
|
||||
]
|
||||
|
||||
/// Create an anti-forgery hidden input
|
||||
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
||||
|
||||
/// Alias for rawText
|
||||
let txt = rawText
|
||||
|
||||
/// Create a page with a title displayed on the page
|
||||
let pageWithTitle title content =
|
||||
article [] [
|
||||
h3 [ _class "pb-3" ] [ txt title ]
|
||||
yield! content
|
||||
]
|
||||
|
||||
/// Create a floating-label text input box
|
||||
let textBox attrs name value fieldLabel isRequired =
|
||||
div [ _class "form-floating" ] [
|
||||
List.append attrs [
|
||||
_id name; _name name; _class "form-control"; _placeholder fieldLabel; _value value
|
||||
if isRequired then _required
|
||||
] |> input
|
||||
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt fieldLabel ]
|
||||
]
|
||||
|
||||
/// Create a checkbox that will post "true" if checked
|
||||
let checkBox attrs name isChecked checkLabel =
|
||||
div [ _class "form-check" ] [
|
||||
List.append attrs
|
||||
[ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true"
|
||||
if isChecked then _checked ]
|
||||
|> input
|
||||
label [ _class "form-check-label"; _for name ] [ txt checkLabel ]
|
||||
]
|
||||
|
||||
/// Create a select list of continents
|
||||
let continentList attrs name (continents : Continent list) emptyLabel selectedValue isRequired =
|
||||
div [ _class "form-floating" ] [
|
||||
select (List.append attrs [ _id name; _name name; _class "form-select"; if isRequired then _required ]) (
|
||||
option [ _value ""; if selectedValue = "" then _selected ] [
|
||||
rawText $"""– {defaultArg emptyLabel "Select"} –""" ]
|
||||
:: (continents
|
||||
|> List.map (fun c ->
|
||||
let theId = ContinentId.toString c.Id
|
||||
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
|
||||
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt "Continent" ]
|
||||
]
|
||||
|
||||
/// Create a submit button with the given icon and text
|
||||
let submitButton icon text =
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ i [ _class $"mdi mdi-%s{icon}" ] []; txt $" %s{text}" ]
|
||||
|
||||
/// An empty paragraph
|
||||
let emptyP =
|
||||
p [] [ txt " " ]
|
||||
|
||||
/// Register JavaScript code to run in the DOMContentLoaded event on the page
|
||||
let jsOnLoad js isHtmx =
|
||||
script [] [
|
||||
let (target, event) = if isHtmx then "document.body", "htmx:afterSettle" else "document", "DOMContentLoaded"
|
||||
txt (sprintf """%s.addEventListener("%s", () => { %s }, { once: true })""" target event js)
|
||||
]
|
||||
|
||||
/// Create a Markdown editor
|
||||
let markdownEditor attrs name value editorLabel isHtmx =
|
||||
div [ _class "col-12"; _id $"{name}EditRow" ] [
|
||||
nav [ _class "nav nav-pills pb-1" ] [
|
||||
button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [
|
||||
txt "Markdown"
|
||||
]
|
||||
rawText " "
|
||||
button [ _type "button"; _id $"{name}PreviewButton"
|
||||
_class "btn btn-outline-secondary btn-sm rounded-pill" ] [
|
||||
txt "Preview"
|
||||
]
|
||||
]
|
||||
section [ _id $"{name}Preview"; _class "jjj-not-shown jjj-markdown-preview px-2 pt-2"
|
||||
_ariaLabel "Rendered Markdown preview" ] []
|
||||
div [ _id $"{name}Edit"; _class "form-floating jjj-shown" ] [
|
||||
textarea (List.append attrs
|
||||
[ _id name; _name name; _class "form-control jjj-markdown-editor"; _rows "10" ]) [
|
||||
txt value
|
||||
]
|
||||
label [ _for name ] [ txt editorLabel ]
|
||||
]
|
||||
jsOnLoad $"jjj.markdownOnLoad('{name}')" isHtmx
|
||||
]
|
||||
|
||||
/// Wrap content in a collapsing panel
|
||||
let collapsePanel header isShown content =
|
||||
let showClass = if isShown then " show" else ""
|
||||
div [ _class "card" ] [
|
||||
div [ _class "card-header" ] [
|
||||
h6 [ _class "mb-0 card-title" ] [
|
||||
a [ _href "#jjjCollapse"; _data "bs-toggle" "collapse"; _roleButton; _ariaControls "#jjjCollapse"
|
||||
_ariaExpanded (isShown.ToString().ToLowerInvariant ()) ] [ txt header ]
|
||||
]
|
||||
]
|
||||
div [ _id "jjjCollapse"; _class $"card-body collapse{showClass}" ] content
|
||||
]
|
||||
|
||||
/// "Yes" or "No" based on a boolean value
|
||||
let yesOrNo value =
|
||||
if value then "Yes" else "No"
|
||||
|
||||
/// Markdown as a raw HTML text node
|
||||
let md2html value =
|
||||
(MarkdownString.toHtml >> txt) value
|
||||
|
||||
/// Display a citizen's contact information
|
||||
let contactInfo citizen isPublic =
|
||||
citizen.OtherContacts
|
||||
|> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic)
|
||||
|> List.collect (fun contact ->
|
||||
match contact.ContactType with
|
||||
| Website ->
|
||||
[ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "
|
||||
a [ _href contact.Value; _target "_blank"; _rel "noopener"; _class "me-4" ] [
|
||||
str (defaultArg contact.Name "Website")
|
||||
]
|
||||
]
|
||||
| Email ->
|
||||
[ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "
|
||||
a [ _href $"mailto:{contact.Value}"; _class "me-4" ] [ str (defaultArg contact.Name "E-mail") ]
|
||||
]
|
||||
| Phone ->
|
||||
[ span [ _class "me-4" ] [
|
||||
i [ _class "mdi mdi-sm mdi-phone" ] []; txt " "; str contact.Value
|
||||
match contact.Name with Some name -> str $" ({name})" | None -> ()
|
||||
]
|
||||
])
|
||||
|
||||
/// Display a citizen's contact information
|
||||
let contactInfoPrint citizen isPublic =
|
||||
citizen.OtherContacts
|
||||
|> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic)
|
||||
|> List.collect (fun contact ->
|
||||
match contact.ContactType with
|
||||
| Website ->
|
||||
[ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "; str (defaultArg contact.Name "Website"); txt " – "
|
||||
str contact.Value; br []
|
||||
]
|
||||
| Email ->
|
||||
[ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "; str (defaultArg contact.Name "E-mail")
|
||||
txt " – "; str contact.Value; br []
|
||||
]
|
||||
| Phone ->
|
||||
[ span [ _class "me-4" ] [
|
||||
i [ _class "mdi mdi-sm mdi-phone" ] []; rawText " "
|
||||
match contact.Name with Some name -> str name; txt " – " | None -> ()
|
||||
str contact.Value; br []
|
||||
]
|
||||
])
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
/// Generate a full date in the citizen's local time zone
|
||||
let fullDate (value : Instant) tz =
|
||||
(ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb))
|
||||
.Format(value.InZone DateTimeZoneProviders.Tzdb[tz])
|
||||
|
||||
/// Generate a full date/time in the citizen's local time
|
||||
let fullDateTime (value : Instant) tz =
|
||||
let dtPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy h:mm", DateTimeZoneProviders.Tzdb)
|
||||
let amPmPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("tt", DateTimeZoneProviders.Tzdb)
|
||||
let tzValue = value.InZone DateTimeZoneProviders.Tzdb[tz]
|
||||
$"{dtPattern.Format(tzValue)}{amPmPattern.Format(tzValue).ToLowerInvariant()}"
|
||||
|
||||
|
||||
/// Layout generation functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Layout =
|
||||
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
|
||||
/// Data items needed to render a view
|
||||
type PageRenderContext =
|
||||
{ /// Whether a user is logged on
|
||||
IsLoggedOn : bool
|
||||
|
||||
/// The current URL
|
||||
CurrentUrl : string
|
||||
|
||||
/// The title of this page
|
||||
PageTitle : string
|
||||
|
||||
/// The page content
|
||||
Content : XmlNode
|
||||
|
||||
/// User messages to be displayed
|
||||
Messages : string list
|
||||
}
|
||||
|
||||
/// Append the application name to the page title
|
||||
let private constructTitle ctx =
|
||||
seq {
|
||||
if ctx.PageTitle <> "" then
|
||||
ctx.PageTitle; " | "
|
||||
"Jobs, Jobs, Jobs"
|
||||
}
|
||||
|> Seq.reduce (+)
|
||||
|> str
|
||||
|> List.singleton
|
||||
|> title []
|
||||
|
||||
/// Generate the HTML head tag
|
||||
let private htmlHead ctx =
|
||||
head [] [
|
||||
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||
constructTitle ctx
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css"
|
||||
_rel "stylesheet"
|
||||
_integrity "sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx"
|
||||
_crossorigin "anonymous" ]
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
|
||||
_rel "stylesheet" ]
|
||||
link [ _href "/style.css"; _rel "stylesheet" ]
|
||||
]
|
||||
|
||||
/// Display the links available to the current user
|
||||
let private links ctx =
|
||||
let navLink url icon text =
|
||||
a [ _href url
|
||||
_onclick "jjj.hideMenu()"
|
||||
if url = ctx.CurrentUrl then _class "jjj-current-page"
|
||||
] [ i [ _class $"mdi mdi-{icon}"; _ariaHidden "true" ] []; txt text ]
|
||||
nav [ _class "jjj-nav" ] [
|
||||
if ctx.IsLoggedOn then
|
||||
navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard"
|
||||
navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!"
|
||||
navLink "/profile/search" "view-list-outline" "Job Seekers"
|
||||
navLink "/success-stories" "thumb-up" "Success Stories"
|
||||
div [ _class "separator" ] []
|
||||
navLink "/citizen/account" "account-edit" "My Account"
|
||||
navLink "/listings/mine" "sign-text" "My Job Listings"
|
||||
navLink "/profile/edit" "pencil" "My Employment Profile"
|
||||
div [ _class "separator" ] []
|
||||
navLink "/citizen/log-off" "logout-variant" "Log Off"
|
||||
else
|
||||
navLink "/" "home" "Home"
|
||||
navLink "/profile/search" "view-list-outline" "Job Seekers"
|
||||
navLink "/citizen/log-on" "login-variant" "Log On"
|
||||
navLink "/how-it-works" "help-circle-outline" "How It Works"
|
||||
]
|
||||
|
||||
/// Generate mobile and desktop side navigation areas
|
||||
let private sideNavs ctx = [
|
||||
div [ _id "mobileMenu"; _class "jjj-mobile-menu offcanvas offcanvas-end"; _tabindex "-1"
|
||||
_ariaLabelledBy "mobileMenuLabel" ] [
|
||||
div [ _class "offcanvas-header" ] [
|
||||
h5 [ _id "mobileMenuLabel" ] [ txt "Menu" ]
|
||||
button [
|
||||
_class "btn-close text-reset"; _type "button"; _data "bs-dismiss" "offcanvas"; _ariaLabel "Close"
|
||||
] []
|
||||
]
|
||||
div [ _class "offcanvas-body" ] [ links ctx ]
|
||||
]
|
||||
aside [ _class "jjj-full-menu d-none d-md-block p-3" ] [
|
||||
p [ _class "home-link pb-3" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ]
|
||||
emptyP
|
||||
links ctx
|
||||
]
|
||||
]
|
||||
|
||||
/// Title bars for mobile and desktop
|
||||
let private titleBars = [
|
||||
nav [ _class "d-flex d-md-none navbar navbar-dark" ] [
|
||||
span [ _class "navbar-text" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ]
|
||||
button [ _class "btn"; _data "bs-toggle" "offcanvas"; _data "bs-target" "#mobileMenu"
|
||||
_ariaControls "mobileMenu" ] [ i [ _class "mdi mdi-menu" ] [] ]
|
||||
]
|
||||
nav [ _class "d-none d-md-flex navbar navbar-light bg-light"] [
|
||||
span [] [ txt " " ]
|
||||
span [ _class "navbar-text" ] [
|
||||
txt "(…and Jobs – "; audioClip "pelosi-jobs" (txt "Let’s Vote for Jobs!"); txt ")"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The HTML footer for the page
|
||||
let private htmlFoot =
|
||||
let v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version
|
||||
let version =
|
||||
seq {
|
||||
string v.Major
|
||||
if v.Minor > 0 then
|
||||
"."; string v.Minor
|
||||
if v.Build > 0 then
|
||||
"."; string v.Build
|
||||
} |> Seq.reduce (+)
|
||||
footer [] [
|
||||
p [ _class "text-muted" ] [
|
||||
txt $"Jobs, Jobs, Jobs v{version} • "
|
||||
a [ _href "/privacy-policy" ] [ txt "Privacy Policy" ]; txt " • "
|
||||
a [ _href "/terms-of-service" ] [ txt "Terms of Service" ]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render any messages
|
||||
let private messages ctx =
|
||||
ctx.Messages
|
||||
|> List.map (fun msg ->
|
||||
let parts = msg.Split "|||"
|
||||
let level = if parts[0] = "error" then "danger" else parts[0]
|
||||
let message = parts[1]
|
||||
div [ _class $"alert alert-{level} alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
|
||||
_roleAlert ] [
|
||||
p [ _class "mb-0" ] [
|
||||
if level <> "success" then strong [] [ txt $"{parts[0].ToUpperInvariant ()}: " ]
|
||||
txt message
|
||||
]
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
|
||||
])
|
||||
|> div [ _id "alerts" ]
|
||||
|
||||
/// Create a full view
|
||||
let full ctx =
|
||||
html [ _lang "en" ] [
|
||||
htmlHead ctx
|
||||
body [] [
|
||||
div [ _class "jjj-app"; _hxBoost; _hxTarget "this" ] [
|
||||
yield! sideNavs ctx
|
||||
div [ _class "jjj-main" ] [
|
||||
yield! titleBars
|
||||
main [ _class "jjj-content container-fluid" ] [
|
||||
messages ctx
|
||||
ctx.Content
|
||||
]
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
Script.minified
|
||||
script [ _async
|
||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
||||
_crossorigin "anonymous" ] []
|
||||
script [ _src "/script.js" ] []
|
||||
template [ _id "alertTemplate" ] [
|
||||
div [ _class $"alert alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
|
||||
_roleAlert ] [
|
||||
p [ _class "mb-0" ] []
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Create a partial (boosted response) view
|
||||
let partial ctx =
|
||||
html [ _lang "en" ] [
|
||||
head [] [
|
||||
constructTitle ctx
|
||||
]
|
||||
body [] [
|
||||
yield! sideNavs ctx
|
||||
div [ _class "jjj-main" ] [
|
||||
yield! titleBars
|
||||
main [ _class "jjj-content container-fluid" ] [
|
||||
messages ctx
|
||||
ctx.Content
|
||||
]
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a print view (styles, but no other layout)
|
||||
let print ctx =
|
||||
html [ _lang "en" ] [
|
||||
htmlHead ctx
|
||||
body [ _class "m-1" ] [ ctx.Content ]
|
||||
]
|
||||
|
||||
/// Render a bare view (used for components)
|
||||
let bare ctx =
|
||||
html [ _lang "en" ] [
|
||||
head [] [ title [] [] ]
|
||||
body [] [ ctx.Content ]
|
||||
]
|
||||
Reference in New Issue
Block a user