From a79fb46c991d9f2b2d5a191d0280a63e0652551c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 18 Jan 2023 21:23:04 -0500 Subject: [PATCH] WIP on password reset - Request reset, cancel reset done - Update Npgsql.FSharp to 5.6.0 - Update Giraffe.Htmx to 1.8.5 - Implement common functions to tighten up templates --- src/JobsJobsJobs/Data/Cache.fs | 20 +- src/JobsJobsJobs/Data/Data.fs | 95 +- .../Data/JobsJobsJobs.Data.fsproj | 3 +- src/JobsJobsJobs/Server/Email.fs | 67 +- src/JobsJobsJobs/Server/Handlers.fs | 82 +- .../Server/JobsJobsJobs.Server.fsproj | 4 +- src/JobsJobsJobs/Server/ViewModels.fs | 8 + src/JobsJobsJobs/Server/Views/Citizen.fs | 238 ++--- src/JobsJobsJobs/Server/Views/Common.fs | 40 +- src/JobsJobsJobs/Server/Views/Home.fs | 821 +++++++++--------- src/JobsJobsJobs/Server/Views/Layout.fs | 35 +- src/JobsJobsJobs/Server/Views/Listing.fs | 105 +-- src/JobsJobsJobs/Server/Views/Profile.fs | 156 ++-- src/JobsJobsJobs/Server/Views/Success.fs | 41 +- 14 files changed, 904 insertions(+), 811 deletions(-) diff --git a/src/JobsJobsJobs/Data/Cache.fs b/src/JobsJobsJobs/Data/Cache.fs index 53064d9..f4335b4 100644 --- a/src/JobsJobsJobs/Data/Cache.fs +++ b/src/JobsJobsJobs/Data/Cache.fs @@ -58,16 +58,16 @@ module private CacheHelpers = open DataConnection -/// A distributed cache implementation in PostgreSQL used to handle sessions for myWebLog +/// A distributed cache implementation in PostgreSQL used to handle sessions for Jobs, Jobs, Jobs type DistributedCache () = // ~~~ INITIALIZATION ~~~ do task { - let conn = connection () + let dataSource = dataSource () let! exists = - conn + dataSource |> Sql.query $" SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session') @@ -75,7 +75,7 @@ type DistributedCache () = |> Sql.executeRowAsync (fun row -> row.bool "does_exist") if not exists then let! _ = - conn + dataSource |> Sql.query "CREATE TABLE session ( id TEXT NOT NULL PRIMARY KEY, @@ -92,10 +92,10 @@ type DistributedCache () = /// Get an entry, updating it for sliding expiration let getEntry key = backgroundTask { - let conn = connection () + let dataSource = dataSource () let idParam = "@id", Sql.string key let! tryEntry = - conn + dataSource |> Sql.query "SELECT * FROM session WHERE id = @id" |> Sql.parameters [ idParam ] |> Sql.executeAsync (fun row -> @@ -118,7 +118,7 @@ type DistributedCache () = else true, { entry with ExpireAt = now.Plus slideExp } if needsRefresh then let! _ = - conn + dataSource |> Sql.query "UPDATE session SET expire_at = @expireAt WHERE id = @id" |> Sql.parameters [ expireParam item.ExpireAt; idParam ] |> Sql.executeNonQueryAsync @@ -135,7 +135,7 @@ type DistributedCache () = let now = getNow () if lastPurge.Plus (Duration.FromMinutes 30L) < now then let! _ = - connection () + dataSource () |> Sql.query "DELETE FROM session WHERE expire_at < @expireAt" |> Sql.parameters [ expireParam now ] |> Sql.executeNonQueryAsync @@ -145,7 +145,7 @@ type DistributedCache () = /// Remove a cache entry let removeEntry key = backgroundTask { let! _ = - connection () + dataSource () |> Sql.query "DELETE FROM session WHERE id = @id" |> Sql.parameters [ "@id", Sql.string key ] |> Sql.executeNonQueryAsync @@ -170,7 +170,7 @@ type DistributedCache () = let slide = Duration.FromHours 1 now.Plus slide, Some slide, None let! _ = - connection () + dataSource () |> Sql.query "INSERT INTO session ( id, payload, expire_at, sliding_expiration, absolute_expiration diff --git a/src/JobsJobsJobs/Data/Data.fs b/src/JobsJobsJobs/Data/Data.fs index 8529b5a..daaddab 100644 --- a/src/JobsJobsJobs/Data/Data.fs +++ b/src/JobsJobsJobs/Data/Data.fs @@ -37,12 +37,12 @@ module DataConnection = open Npgsql /// The data source for the document store - let mutable private dataSource : NpgsqlDataSource option = None + let mutable private theDataSource : NpgsqlDataSource option = None - /// Get a connection - let connection () = - match dataSource with - | Some ds -> ds.OpenConnection () |> Sql.existingConnection + /// Get the data source as the start of a SQL statement + let dataSource () = + match theDataSource with + | Some ds -> Sql.fromDataSource ds | None -> invalidOp "Connection.setUp() must be called before accessing the database" /// Create tables @@ -66,7 +66,7 @@ module DataConnection = $"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'))" ] let! _ = - connection () + dataSource () |> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ])) () } @@ -75,7 +75,7 @@ module DataConnection = let setUp (cfg : IConfiguration) = backgroundTask { let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL") let _ = builder.UseNodaTime () - dataSource <- Some (builder.Build ()) + theDataSource <- Some (builder.Build ()) do! createTables () } @@ -166,7 +166,7 @@ module Citizens = /// Delete a citizen by their ID let deleteById citizenId = - doDeleteById citizenId (connection ()) + doDeleteById citizenId (dataSource ()) /// Save a citizen let private saveCitizen (citizen : Citizen) connProps = @@ -178,7 +178,7 @@ module Citizens = /// Purge expired tokens let private purgeExpiredTokens now = backgroundTask { - let connProps = connection () + let connProps = dataSource () let! info = Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps |> Sql.executeAsync toDocument @@ -202,7 +202,7 @@ module Citizens = /// Find a citizen by their ID let findById citizenId = backgroundTask { - match! connection () |> getDocument Table.Citizen (CitizenId.toString citizenId) with + match! dataSource () |> getDocument Table.Citizen (CitizenId.toString citizenId) with | Some c when not c.IsLegacy -> return Some c | Some _ | None -> return None @@ -210,11 +210,11 @@ module Citizens = /// Save a citizen let save citizen = - saveCitizen citizen (connection ()) + saveCitizen citizen (dataSource ()) /// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken let register citizen (security : SecurityInfo) = backgroundTask { - let connProps = connection () + let connProps = dataSource () use conn = Sql.createConnection connProps use! txn = conn.BeginTransactionAsync () try @@ -245,7 +245,7 @@ module Citizens = /// Confirm a citizen's account let confirmAccount token = backgroundTask { do! checkForPurge true - let connProps = connection () + let connProps = dataSource () match! tryConfirmToken token connProps with | Some info -> do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None } @@ -257,7 +257,7 @@ module Citizens = /// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent) let denyAccount token = backgroundTask { do! checkForPurge true - let connProps = connection () + let connProps = dataSource () match! tryConfirmToken token connProps with | Some info -> do! doDeleteById info.Id connProps @@ -269,7 +269,7 @@ module Citizens = let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string) now = backgroundTask { do! checkForPurge false - let connProps = connection () + let connProps = dataSource () let! tryCitizen = connProps |> Sql.query $" @@ -307,6 +307,37 @@ module Citizens = | None -> return Error "Log on unsuccessful" } + /// Try to retrieve a citizen and their security information by their e-mail address + let tryByEmailWithSecurity email = backgroundTask { + let toCitizenSecurityPair row = (toDocument row, toDocumentFrom "sec_data" row) + let! results = + dataSource () + |> Sql.query $" + SELECT c.*, s.data AS sec_data + FROM {Table.Citizen} c + INNER JOIN {Table.SecurityInfo} s ON s.id = c.id + WHERE c.data ->> 'email' = @email" + |> Sql.parameters [ "@email", Sql.string email ] + |> Sql.executeAsync toCitizenSecurityPair + return List.tryHead results + } + + /// Save an updated security information document + let saveSecurityInfo security = backgroundTask { + do! saveSecurity security (dataSource ()) + } + + /// Try to retrieve security information by the given token + let trySecurityByToken token = backgroundTask { + do! checkForPurge false + let! results = + dataSource () + |> Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'token' = @token" + |> Sql.parameters [ "@token", Sql.string token ] + |> Sql.executeAsync toDocument + return List.tryHead results + } + /// Continent data access functions [] @@ -314,13 +345,13 @@ module Continents = /// Retrieve all continents let all () = - connection () + dataSource () |> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'" |> Sql.executeAsync toDocument /// Retrieve a continent by its ID let findById continentId = - connection () |> getDocument Table.Continent (ContinentId.toString continentId) + dataSource () |> getDocument Table.Continent (ContinentId.toString continentId) open JobsJobsJobs.Domain.SharedTypes @@ -345,14 +376,14 @@ module Listings = /// Find all job listings posted by the given citizen let findByCitizen citizenId = - connection () + dataSource () |> Sql.query $"{viewSql} WHERE l.data ->> 'citizenId' = @citizenId AND l.data ->> 'isLegacy' = 'false'" |> Sql.parameters [ "@citizenId", Sql.string (CitizenId.toString citizenId) ] |> Sql.executeAsync toListingForView /// Find a listing by its ID let findById listingId = backgroundTask { - match! connection () |> getDocument Table.Listing (ListingId.toString listingId) with + match! dataSource () |> getDocument Table.Listing (ListingId.toString listingId) with | Some listing when not listing.IsLegacy -> return Some listing | Some _ | None -> return None @@ -361,7 +392,7 @@ module Listings = /// Find a listing by its ID for viewing (includes continent information) let findByIdForView listingId = backgroundTask { let! tryListing = - connection () + dataSource () |> Sql.query $"{viewSql} WHERE l.id = @id AND l.data ->> 'isLegacy' = 'false'" |> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ] |> Sql.executeAsync toListingForView @@ -370,7 +401,7 @@ module Listings = /// Save a listing let save (listing : Listing) = - connection () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing + dataSource () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing /// Search job listings let search (search : ListingSearchForm) = @@ -384,7 +415,7 @@ module Listings = if search.Text <> "" then "l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ] ] - connection () + dataSource () |> Sql.query $" {viewSql} WHERE l.data ->> 'isExpired' = 'false' AND l.data ->> 'isLegacy' = 'false' @@ -399,14 +430,14 @@ module Profiles = /// Count the current profiles let count () = - connection () + dataSource () |> Sql.query $"SELECT COUNT(id) AS the_count FROM {Table.Profile} WHERE data ->> 'isLegacy' = 'false'" |> Sql.executeRowAsync (fun row -> row.int64 "the_count") /// Delete a profile by its ID let deleteById citizenId = backgroundTask { let! _ = - connection () + dataSource () |> Sql.query $"DELETE FROM {Table.Profile} WHERE id = @id" |> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ] |> Sql.executeNonQueryAsync @@ -415,7 +446,7 @@ module Profiles = /// Find a profile by citizen ID let findById citizenId = backgroundTask { - match! connection () |> getDocument Table.Profile (CitizenId.toString citizenId) with + match! dataSource () |> getDocument Table.Profile (CitizenId.toString citizenId) with | Some profile when not profile.IsLegacy -> return Some profile | Some _ | None -> return None @@ -424,7 +455,7 @@ module Profiles = /// Find a profile by citizen ID for viewing (includes citizen and continent information) let findByIdForView citizenId = backgroundTask { let! tryCitizen = - connection () + dataSource () |> Sql.query $" SELECT p.*, c.data AS cit_data, o.data AS cont_data FROM {Table.Profile} p @@ -443,7 +474,7 @@ module Profiles = /// Save a profile let save (profile : Profile) = - connection () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile + dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile /// Search profiles (logged-on users) let search (search : ProfileSearchForm) = backgroundTask { @@ -462,7 +493,7 @@ module Profiles = [ "@text", like search.BioExperience ] ] let! results = - connection () + dataSource () |> Sql.query $" SELECT p.*, c.data AS cit_data FROM {Table.Profile} p @@ -498,7 +529,7 @@ module Profiles = WHERE x ->> 'description' ILIKE @description)", [ "@description", like search.Skill ] ] - connection () + dataSource () |> Sql.query $" SELECT p.*, c.data AS cont_data FROM {Table.Profile} p @@ -525,7 +556,7 @@ module Successes = // Retrieve all success stories let all () = - connection () + dataSource () |> Sql.query $" SELECT s.*, c.data AS cit_data FROM {Table.Success} s @@ -544,9 +575,9 @@ module Successes = /// Find a success story by its ID let findById successId = - connection () |> getDocument Table.Success (SuccessId.toString successId) + dataSource () |> getDocument Table.Success (SuccessId.toString successId) /// Save a success story let save (success : Success) = - connection () |> saveDocument Table.Success (SuccessId.toString success.Id) <| mkDoc success + dataSource () |> saveDocument Table.Success (SuccessId.toString success.Id) <| mkDoc success \ No newline at end of file diff --git a/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj b/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj index 18d9762..f9e4636 100644 --- a/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj +++ b/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj @@ -17,8 +17,7 @@ - - + diff --git a/src/JobsJobsJobs/Server/Email.fs b/src/JobsJobsJobs/Server/Email.fs index 8ceb611..a2f1286 100644 --- a/src/JobsJobsJobs/Server/Email.fs +++ b/src/JobsJobsJobs/Server/Email.fs @@ -6,20 +6,34 @@ open MailKit.Net.Smtp open MailKit.Security open MimeKit +/// Private functions for sending e-mail +[] +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" *) "summersd@localhost")) + msg.To.Add (MailboxAddress (Citizen.name citizen, (* citizen.Email *) "summersd@localhost")) + msg.Subject <- subject + msg + + /// Send an account confirmation e-mail let sendAccountConfirmation citizen security = backgroundTask { - let name = Citizen.name citizen - let token = WebUtility.UrlEncode security.Token.Value - use client = new SmtpClient () - do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None) - - use msg = new MimeMessage () - msg.From.Add (MailboxAddress ("Jobs, Jobs, Jobs", "daniel@bitbadger.solutions" (* "summersd@localhost" *) )) - msg.To.Add (MailboxAddress (name, citizen.Email (* "summersd@localhost" *) )) - msg.Subject <- "Account Confirmation Request" + let token = WebUtility.UrlEncode security.Token.Value + use! client = smtpClient () + use msg = createMessage citizen "Account Confirmation Request" let text = - [ $"ITM, {name}!" + [ $"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" @@ -45,3 +59,36 @@ let sendAccountConfirmation citizen security = backgroundTask { 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 +} diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 13d9ee2..df02c96 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -261,14 +261,26 @@ module Citizen = | None -> return! Error.notFound next ctx } + // GET: /citizen/cancel-reset/[token] + let cancelReset token : HttpHandler = fun next ctx -> task { + let! wasCanceled = task { + match! Citizens.trySecurityByToken token with + | Some security -> + do! Citizens.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None } + return true + | None -> return false + } + return! Citizen.resetCanceled wasCanceled |> render "Password Reset Cancellation" next ctx + } + // GET: /citizen/confirm/[token] - let confirm token next ctx = task { + let confirm token : HttpHandler = fun next ctx -> task { let! isConfirmed = Citizens.confirmAccount token return! Citizen.confirmAccount isConfirmed |> render "Account Confirmation" next ctx } // GET: /citizen/dashboard - let dashboard = requireUser >=> fun next ctx -> task { + let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { let citizenId = currentCitizenId ctx let! citizen = Citizens.findById citizenId let! profile = Profiles.findById citizenId @@ -284,13 +296,38 @@ module Citizen = } // GET: /citizen/deny/[token] - let deny token next ctx = task { + let deny token : HttpHandler = fun next ctx -> task { let! wasDeleted = Citizens.denyAccount token return! Citizen.denyAccount wasDeleted |> render "Account Deletion" next ctx } + // GET: /citizen/forgot-password + let forgotPassword : HttpHandler = fun next ctx -> + Citizen.forgotPassword (csrf ctx) |> render "Forgot Password" next ctx + + // POST: /citizen/forgot-password + let doForgotPassword : HttpHandler = validateCsrf >=> fun next ctx -> task { + let! form = ctx.BindFormAsync () + match! Citizens.tryByEmailWithSecurity form.Email with + | Some (citizen, security) -> + let withToken = + { security with + Token = Some (Auth.createToken citizen) + TokenUsage = Some "reset" + TokenExpires = Some (now ctx + (Duration.FromDays 3)) + } + do! Citizens.saveSecurityInfo withToken + let! emailResponse = Email.sendPasswordReset citizen withToken + let logFac = logger ctx + let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen" + log.LogInformation $"Password reset e-mail for {citizen.Email} received {emailResponse}" + | None -> () + // TODO: send link if it matches an account + return! Citizen.forgotPasswordSent form |> render "Reset Request Processed" next ctx + } + // GET: /citizen/log-off - let logOff = requireUser >=> fun next ctx -> task { + let logOff : HttpHandler = requireUser >=> fun next ctx -> task { do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme do! addSuccess "Log off successful" ctx return! redirectToGet "/" next ctx @@ -396,6 +433,15 @@ module Citizen = return! refreshPage () next ctx } + // GET: /citizen/reset-password/[token] + let resetPassword token : HttpHandler = fun next ctx -> task { + match! Citizens.trySecurityByToken token with + | Some security -> + // TODO: create form and page + return! Home.home |> render "TODO" next ctx + | None -> return! Error.notFound next ctx + } + // POST: /citizen/save-account let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! theForm = ctx.BindFormAsync () @@ -807,20 +853,24 @@ let allEndpoints = [ ] subRoute "/citizen" [ GET_HEAD [ - route "/account" Citizen.account - routef "/confirm/%s" Citizen.confirm - route "/dashboard" Citizen.dashboard - routef "/deny/%s" Citizen.deny - route "/log-off" Citizen.logOff - route "/log-on" Citizen.logOn - route "/register" Citizen.register - route "/so-long" Citizen.soLong + route "/account" Citizen.account + routef "/cancel-reset/%s" Citizen.cancelReset + routef "/confirm/%s" Citizen.confirm + route "/dashboard" Citizen.dashboard + routef "/deny/%s" Citizen.deny + route "/forgot-password" Citizen.forgotPassword + route "/log-off" Citizen.logOff + route "/log-on" Citizen.logOn + route "/register" Citizen.register + routef "/reset-password/%s" Citizen.resetPassword + route "/so-long" Citizen.soLong ] POST [ - route "/delete" Citizen.delete - route "/log-on" Citizen.doLogOn - route "/register" Citizen.doRegistration - route "/save-account" Citizen.saveAccount + route "/delete" Citizen.delete + route "/forgot-password" Citizen.doForgotPassword + route "/log-on" Citizen.doLogOn + route "/register" Citizen.doRegistration + route "/save-account" Citizen.saveAccount ] ] subRoute "/listing" [ diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj index 9fa79e0..f15a1ac 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj @@ -33,9 +33,9 @@ - + - + diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 437f569..e94c556 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -238,6 +238,14 @@ type ExpireListingForm = } +/// Form for the forgot / reset password page +[] +type ForgotPasswordForm = + { /// The e-mail address for the account wishing to reset their password + Email : string + } + + /// View model for the log on page [] type LogOnViewModel = diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index 0beba62..124be54 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -13,9 +13,7 @@ let contactEdit (contacts : OtherContactForm array) = div [ _id $"contactRow{idx}"; _class "row pb-3" ] [ div [ _class "col-2 col-md-1" ] [ button [ _type "button"; _class "btn btn-sm btn-outline-danger rounded-pill mt-3"; _title "Delete" - _onclick $"jjj.citizen.removeContact({idx})" ] [ - rawText " − " - ] + _onclick $"jjj.citizen.removeContact({idx})" ] [ txt " − " ] ] div [ _class "col-10 col-md-4 col-xl-3" ] [ div [ _class "form-floating" ] [ @@ -23,37 +21,37 @@ let contactEdit (contacts : OtherContactForm array) = _value contact.ContactType; _placeholder "Type"; _required ] [ let optionFor value label = let typ = ContactType.toString value - option [ _value typ; if contact.ContactType = typ then _selected ] [ rawText label ] + option [ _value typ; if contact.ContactType = typ then _selected ] [ txt label ] optionFor Website "Website" optionFor Email "E-mail Address" optionFor Phone "Phone Number" ] - label [ _class "jjj-required"; _for $"contactType{idx}" ] [ rawText "Type" ] + label [ _class "jjj-required"; _for $"contactType{idx}" ] [ txt "Type" ] ] ] div [ _class "col-12 col-md-4 col-xl-3" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _id $"contactName{idx}"; _name $"Contacts[{idx}].Name"; _class "form-control" _maxlength "1000"; _value contact.Name; _placeholder "Name" ] - label [ _class "jjj-label"; _for $"contactName{idx}" ] [ rawText "Name" ] + label [ _class "jjj-label"; _for $"contactName{idx}" ] [ txt "Name" ] ] if idx < 1 then - div [ _class "form-text" ] [ rawText "Optional; will link sites and e-mail, qualify phone numbers" ] + div [ _class "form-text" ] [ txt "Optional; will link sites and e-mail, qualify phone numbers" ] ] div [ _class "col-12 col-md-7 offset-md-1 col-xl-4 offset-xl-0" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _id $"contactValue{idx}"; _name $"Contacts[{idx}].Value" _class "form-control"; _maxlength "1000"; _value contact.Value; _placeholder "Contact" _required ] - label [ _class "jjj-required"; _for "contactValue{idx}" ] [ rawText "Contact" ] + label [ _class "jjj-required"; _for "contactValue{idx}" ] [ txt "Contact" ] ] - if idx < 1 then div [ _class "form-text"] [ rawText "The URL, e-mail address, or phone number" ] + if idx < 1 then div [ _class "form-text"] [ txt "The URL, e-mail address, or phone number" ] ] div [ _class "col-12 col-md-3 offset-md-1 col-xl-1 offset-xl-0" ] [ div [ _class "form-check mt-3" ] [ input [ _type "checkbox"; _id $"contactIsPublic{idx}"; _name $"Contacts[{idx}].IsPublic"; _class "form-check-input"; _value "true"; if contact.IsPublic then _checked ] - label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ rawText "Public" ] + label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ txt "Public" ] ] ] ] @@ -64,12 +62,11 @@ let contactEdit (contacts : OtherContactForm array) = /// The account edit page let account (m : AccountProfileForm) csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Account Profile" ] + pageWithTitle "Account Profile" [ p [] [ - rawText "This information is visible to all fellow logged-on citizens. For publicly-visible employment " - rawText "profiles and job listings, the “Display Name” fields and any public contacts will be " - rawText "displayed." + txt "This information is visible to all fellow logged-on citizens. For publicly-visible employment " + txt "profiles and job listings, the “Display Name” fields and any public contacts will be " + txt "displayed." ] form [ _class "row g-3"; _method "POST"; _action "/citizen/save-account" ] [ antiForgery csrf @@ -81,38 +78,32 @@ let account (m : AccountProfileForm) csrf = ] div [ _class "col-6 col-xl-4" ] [ textBox [ _type "text" ] (nameof m.DisplayName) m.DisplayName "Display Name" false - div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ] + div [ _class "form-text" ] [ em [] [ txt "Optional; overrides first/last for display" ] ] ] div [ _class "col-6 col-xl-4" ] [ textBox [ _type "password"; _minlength "8" ] (nameof m.NewPassword) "" "New Password" false - div [ _class "form-text" ] [ rawText "Leave blank to keep your current password" ] + div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ] ] div [ _class "col-6 col-xl-4" ] [ textBox [ _type "password"; _minlength "8" ] (nameof m.NewPasswordConfirm) "" "Confirm New Password" false - div [ _class "form-text" ] [ rawText "Leave blank to keep your current password" ] + div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ] ] div [ _class "col-12" ] [ hr [] h4 [ _class "pb-2" ] [ - rawText "Ways to Be Contacted   " + txt "Ways to Be Contacted   " button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill" - _onclick "jjj.citizen.addContact()" ] [ - rawText "Add a Contact Method" - ] + _onclick "jjj.citizen.addContact()" ] [ txt "Add a Contact Method" ] ] ] yield! contactEdit m.Contacts - div [ _class "col-12" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-content-save-outline" ] [ rawText "  Save" ] - ] - ] + div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ] ] hr [] p [ _class "text-muted fst-italic" ] [ - rawText "(If you want to delete your profile, or your entire account, " - a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)" + txt "(If you want to delete your profile, or your entire account, " + a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; txt ".)" ] jsOnLoad $" jjj.citizen.nextIndex = {m.Contacts.Length} @@ -122,157 +113,171 @@ let account (m : AccountProfileForm) csrf = /// The account confirmation page let confirmAccount isConfirmed = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Account Confirmation" ] + pageWithTitle "Account Confirmation" [ p [] [ if isConfirmed then - rawText "Your account was confirmed successfully! You may " - a [ _href "/citizen/log-on" ] [ rawText "log on here" ]; rawText "." + txt "Your account was confirmed successfully! You may " + a [ _href "/citizen/log-on" ] [ rawText "log on here" ]; txt "." else - rawText "The confirmation token did not match any pending accounts. Confirmation tokens are only valid " - rawText "for 3 days; if the token expired, you will need to re-register, which " - a [ _href "/citizen/register" ] [ rawText "you can do here" ]; rawText "." + txt "The confirmation token did not match any pending accounts. Confirmation tokens are only valid for " + txt "3 days; if the token expired, you will need to re-register, which " + a [ _href "/citizen/register" ] [ txt "you can do here" ]; txt "." ] ] /// The citizen's dashboard page let dashboard (citizen : Citizen) (profile : Profile option) profileCount tz = article [ _class "container" ] [ - h3 [ _class "pb-4" ] [ rawText "ITM, "; str citizen.FirstName; rawText "!" ] + h3 [ _class "pb-4" ] [ str $"ITM, {citizen.FirstName}!" ] div [ _class "row row-cols-1 row-cols-md-2" ] [ div [ _class "col" ] [ div [ _class "card h-100" ] [ - h5 [ _class "card-header" ] [ rawText "Your Profile" ] + h5 [ _class "card-header" ] [ txt "Your Profile" ] div [ _class "card-body" ] [ match profile with | Some prfl -> h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [ - rawText "Last updated "; str (fullDateTime prfl.LastUpdatedOn tz) + str $"Last updated {fullDateTime prfl.LastUpdatedOn tz}" ] p [ _class "card-text" ] [ - rawText "Your profile currently lists "; str $"{List.length prfl.Skills}" - rawText " skill"; rawText (if List.length prfl.Skills <> 1 then "s" else "") - rawText "." + txt $"Your profile currently lists {List.length prfl.Skills} skill" + txt (if List.length prfl.Skills <> 1 then "s" else ""); txt "." if prfl.IsSeekingEmployment then br []; br [] - rawText "Your profile indicates that you are seeking employment. Once you find it, " - a [ _href "/success-story/add" ] [ rawText "tell your fellow citizens about it!" ] + txt "Your profile indicates that you are seeking employment. Once you find it, " + a [ _href "/success-story/add" ] [ txt "tell your fellow citizens about it!" ] ] | None -> p [ _class "card-text" ] [ - rawText "You do not have an employment profile established; click below (or " - rawText "“Edit Profile” in the menu) to get started!" + txt "You do not have an employment profile established; click below (or “Edit " + txt "Profile” in the menu) to get started!" ] ] div [ _class "card-footer" ] [ match profile with - | Some p -> + | Some _ -> a [ _href $"/profile/{CitizenId.toString citizen.Id}/view" - _class "btn btn-outline-secondary" ] [ - rawText "View Profile" - ]; rawText "   " - a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ] + _class "btn btn-outline-secondary" ] [ txt "View Profile" ]; txt "   " + a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ txt "Edit Profile" ] | None -> - a [ _href "/profile/edit"; _class "btn btn-primary" ] [ rawText "Create Profile" ] + a [ _href "/profile/edit"; _class "btn btn-primary" ] [ txt "Create Profile" ] ] ] ] div [ _class "col" ] [ div [ _class "card h-100" ] [ - h5 [ _class "card-header" ] [ rawText "Other Citizens" ] + h5 [ _class "card-header" ] [ txt "Other Citizens" ] div [ _class "card-body" ] [ h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [ - rawText (if profileCount = 0L then "No" else $"{profileCount} Total") - rawText " Employment Profile"; rawText (if profileCount <> 1 then "s" else "") + txt (if profileCount = 0L then "No" else $"{profileCount} Total") + txt " Employment Profile"; txt (if profileCount <> 1 then "s" else "") ] p [ _class "card-text" ] [ if profileCount = 1 && Option.isSome profile then "It looks like, for now, it’s just you…" else if profileCount > 0 then "Take a look around and see if you can help them find work!" else "You can click below, but you will not find anything…" - |> rawText + |> txt ] ] div [ _class "card-footer" ] [ - a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ rawText "Search Profiles" ] + a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ txt "Search Profiles" ] ] ] ] ] - p [] [ rawText " " ] + emptyP p [] [ - rawText "To see how this application works, check out “How It Works” in the sidebar (last " - rawText "updated August 29th, 2021)." + txt "To see how this application works, check out “How It Works” in the sidebar (last updated " + txt "August 29th, 2021)." ] ] /// The account deletion success page let deleted = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Account Deletion Success" ] - p [] [ rawText " " ] - p [] [ rawText "Your account has been successfully deleted." ] - p [] [ rawText " " ] - p [] [ rawText "Thank you for participating, and thank you for your courage. #GitmoNation" ] + pageWithTitle "Account Deletion Success" [ + emptyP; p [] [ txt "Your account has been successfully deleted." ] + emptyP; p [] [ txt "Thank you for participating, and thank you for your courage. #GitmoNation" ] ] /// The profile or account deletion page let deletionOptions csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Account Deletion Options" ] - h4 [ _class "pb-3" ] [ rawText "Option 1 – Delete Your Profile" ] + pageWithTitle "Account Deletion Options" [ + h4 [ _class "pb-3" ] [ txt "Option 1 – Delete Your Profile" ] p [] [ - rawText "Utilizing this option will remove your current employment profile and skills. This will preserve " - rawText "any job listings you may have posted, or any success stories you may have written, and preserves " - rawText "this application’s knowledge of you. This is what you want to use if you want to clear out " - rawText "your profile and start again (and remove the current one from others’ view)." + txt "Utilizing this option will remove your current employment profile and skills. This will preserve any " + txt "job listings you may have posted, or any success stories you may have written, and preserves this " + txt "this application’s knowledge of you. This is what you want to use if you want to clear out your " + txt "profile and start again (and remove the current one from others’ view)." ] form [ _class "text-center"; _method "POST"; _action "/profile/delete" ] [ antiForgery csrf - button [ _type "submit"; _class "btn btn-danger" ] [ rawText "Delete Your Profile" ] + button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Profile" ] ] hr [] - h4 [ _class "pb-3" ] [ rawText "Option 2 – Delete Your Account" ] + h4 [ _class "pb-3" ] [ txt "Option 2 – Delete Your Account" ] p [] [ - rawText "This option will make it like you never visited this site. It will delete your profile, skills, " - rawText "job listings, success stories, and account. This is what you want to use if you want to disappear " - rawText "from this application." + txt "This option will make it like you never visited this site. It will delete your profile, skills, job " + txt "listings, success stories, and account. This is what you want to use if you want to disappear from " + txt "this application." ] form [ _class "text-center"; _method "POST"; _action "/citizen/delete" ] [ antiForgery csrf - button [ _type "submit"; _class "btn btn-danger" ] [ rawText "Delete Your Entire Account" ] + button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Entire Account" ] ] ] /// The account denial page let denyAccount wasDeleted = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Account Deletion" ] + pageWithTitle "Account Deletion" [ p [] [ - if wasDeleted then - rawText "The account was deleted successfully; sorry for the trouble." + if wasDeleted then txt "The account was deleted successfully; sorry for the trouble." else - rawText "The confirmation token did not match any pending accounts; if this was an inadvertently " - rawText "created account, it has likely already been deleted." + txt "The confirmation token did not match any pending accounts; if this was an inadvertently created " + txt "account, it has likely already been deleted." ] ] + +/// The forgot / reset password page +let forgotPassword csrf = + let m = { Email = "" } + pageWithTitle "Forgot Password" [ + p [] [ + txt "Enter your e-mail address below; if it matches the e-mail address of an account, we will send a " + txt "password reset link." + ] + form [ _class "row g-3 pb-3"; _method "POST"; _action "/citizen/forgot-password" ] [ + antiForgery csrf + div [ _class "col-12 col-md-6 offset-md-3" ] [ + textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true + ] + div [ _class "col-12" ] [ submitButton "login" "Send Reset Link" ] + ] + ] + + +/// The page displayed after a forgotten / reset request has been processed +let forgotPasswordSent (m : ForgotPasswordForm) = + pageWithTitle "Reset Request Processed" [ + p [] [ txt "The reset link request has been processed; check your e-mail for further instructions." ] + ] + + /// The log on page let logOn (m : LogOnViewModel) csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Log On" ] + pageWithTitle "Log On" [ match m.ErrorMessage with | Some msg -> p [ _class "pb-3 text-center" ] [ - span [ _class "text-danger" ] [ str msg ]; br [] + span [ _class "text-danger" ] [ txt msg ]; br [] if msg.IndexOf("ocked") > -1 then - rawText "If this is a new account, it must be confirmed before it can be used; otherwise, you need " - rawText "to " - a [ _href "/citizen/forgot-password" ] [ rawText "request an unlock code" ] - rawText " before you may log on." + txt "If this is a new account, it must be confirmed before it can be used; otherwise, you need to " + a [ _href "/citizen/forgot-password" ] [ txt "request an unlock code" ] + txt " before you may log on." ] | None -> () form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [ @@ -286,24 +291,19 @@ let logOn (m : LogOnViewModel) csrf = div [ _class "col-12 col-md-6" ] [ textBox [ _type "password" ] (nameof m.Password) "" "Password" true ] - div [ _class "col-12" ] [ - button [ _class "btn btn-primary"; _type "submit" ] [ - i [ _class "mdi mdi-login" ] []; rawText "  Log On" - ] - ] + div [ _class "col-12" ] [ submitButton "login" "Log On" ] ] p [ _class "text-center" ] [ - rawText "Need an account? "; a [ _href "/citizen/register" ] [ rawText "Register for one!" ] + txt "Need an account? "; a [ _href "/citizen/register" ] [ txt "Register for one!" ] ] p [ _class "text-center" ] [ - rawText "Forgot your password? "; a [ _href "/citizen/forgot-password" ] [ rawText "Request a reset." ] + txt "Forgot your password? "; a [ _href "/citizen/forgot-password" ] [ txt "Request a reset." ] ] ] /// The registration page let register q1 q2 (m : RegisterViewModel) csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Register" ] + pageWithTitle "Register" [ form [ _class "row g-3"; _hxPost "/citizen/register" ] [ antiForgery csrf div [ _class "col-6 col-xl-4" ] [ @@ -314,7 +314,7 @@ let register q1 q2 (m : RegisterViewModel) csrf = ] div [ _class "col-6 col-xl-4" ] [ textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false - div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ] + div [ _class "form-text fst-italic" ] [ txt "Optional; overrides first/last for display" ] ] div [ _class "col-6 col-xl-4" ] [ textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" true @@ -328,7 +328,7 @@ let register q1 q2 (m : RegisterViewModel) csrf = div [ _class "col-12" ] [ hr [] p [ _class "mb-0 text-muted fst-italic" ] [ - rawText "Before your account request is through, you must answer these questions two…" + txt "Before your account request is through, you must answer these questions two…" ] ] div [ _class "col-12 col-xl-6" ] [ @@ -339,30 +339,34 @@ let register q1 q2 (m : RegisterViewModel) csrf = textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer q2 true input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ] ] - div [ _class "col-12" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" - ] - ] + div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ] jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" ] ] /// The confirmation page for user registration let registered = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Registration Successful" ] + pageWithTitle "Registration Successful" [ p [] [ - rawText "You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation " - rawText "link; it will be valid for the next 72 hours (3 days). Once you confirm your account, you will be " - rawText "able to log on using the e-mail address and password you provided." + txt "You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation " + txt "link; it will be valid for the next 72 hours (3 days). Once you confirm your account, you will be " + txt "able to log on using the e-mail address and password you provided." ] p [] [ - rawText "If the account is not confirmed within the 72-hour window, it will be deleted, and you will need " - rawText "to register again." + txt "If the account is not confirmed within the 72-hour window, it will be deleted, and you will need to " + txt "register again." ] p [] [ - rawText "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for " - rawText "assistance." + txt "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance." + ] + ] + +/// The confirmation page for canceling a reset request +let resetCanceled wasCanceled = + let pgTitle = if wasCanceled then "Password Reset Request Canceled" else "Reset Request Not Found" + pageWithTitle pgTitle [ + p [] [ + if wasCanceled then txt "Your password reset request has been canceled." + else txt "There was no active password reset request found; it may have already expired." ] ] diff --git a/src/JobsJobsJobs/Server/Views/Common.fs b/src/JobsJobsJobs/Server/Views/Common.fs index c2637f9..454fdec 100644 --- a/src/JobsJobsJobs/Server/Views/Common.fs +++ b/src/JobsJobsJobs/Server/Views/Common.fs @@ -16,6 +16,16 @@ let audioClip clip text = 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" ] [ @@ -23,7 +33,7 @@ let textBox attrs name value fieldLabel isRequired = _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 ] [ rawText fieldLabel ] + label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ txt fieldLabel ] ] /// Create a checkbox that will post "true" if checked @@ -33,7 +43,7 @@ let checkBox attrs name isChecked checkLabel = [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true" if isChecked then _checked ] |> input - label [ _class "form-check-label"; _for name ] [ str checkLabel ] + label [ _class "form-check-label"; _for name ] [ txt checkLabel ] ] /// Create a select list of continents @@ -46,26 +56,32 @@ let continentList attrs name (continents : Continent list) emptyLabel selectedVa |> 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 ] [ rawText "Continent" ] + 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 = - script [] [ - rawText """document.addEventListener("DOMContentLoaded", function () { """; rawText js; rawText " })" - ] + script [] [ txt """document.addEventListener("DOMContentLoaded", function () { """; txt js; txt " })" ] /// Create a Markdown editor let markdownEditor attrs name value editorLabel = 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" ] [ - rawText "Markdown" + txt "Markdown" ] rawText "   " button [ _type "button"; _id $"{name}PreviewButton" _class "btn btn-outline-secondary btn-sm rounded-pill" ] [ - rawText "Preview" + txt "Preview" ] ] section [ _id $"{name}Preview"; _class "jjj-not-shown jjj-markdown-preview px-2 pt-2" @@ -73,9 +89,9 @@ let markdownEditor attrs name value editorLabel = 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" ]) [ - rawText value + txt value ] - label [ _for name ] [ rawText editorLabel ] + label [ _for name ] [ txt editorLabel ] ] jsOnLoad $"jjj.markdownOnLoad('{name}')" ] @@ -87,7 +103,7 @@ let collapsePanel header content = h6 [ _class "card-title" ] [ // TODO: toggle collapse //a [ _href "#"; _class "{ 'cp-c': collapsed, 'cp-o': !collapsed }"; @click.prevent="toggle">{{headerText}} ] - rawText header + txt header ] yield! content ] @@ -99,7 +115,7 @@ let yesOrNo value = /// Markdown as a raw HTML text node let md2html value = - rawText (MarkdownString.toHtml value) + (MarkdownString.toHtml >> txt) value /// Display a citizen's contact information let contactInfo citizen isPublic = diff --git a/src/JobsJobsJobs/Server/Views/Home.fs b/src/JobsJobsJobs/Server/Views/Home.fs index d832082..4be5439 100644 --- a/src/JobsJobsJobs/Server/Views/Home.fs +++ b/src/JobsJobsJobs/Server/Views/Home.fs @@ -5,558 +5,539 @@ open Giraffe.ViewEngine /// The home page let home = article [] [ - p [] [ rawText " " ] + emptyP p [] [ - rawText "Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist " - rawText "one another in finding employment. This will enable them to continue providing value-for-value to " - rawText "Adam and John, as they continue their work deconstructing the misinformation that passes for news " - rawText "on a day-to-day basis." + txt "Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one " + txt "another in finding employment. This will enable them to continue providing value-for-value to Adam " + txt "and John, as they continue their work deconstructing the misinformation that passes for news on a " + txt "day-to-day basis." ] p [] [ - rawText "Do you not understand the terms in the paragraph above? No worries; just head over to " + txt "Do you not understand the terms in the paragraph above? No worries; just head over to " a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ - rawText "The Best Podcast in the Universe" + txt "The Best Podcast in the Universe" ] - rawText " "; em [] [ audioClip "thats-true" (rawText "(that’s true!)") ] - rawText " and find out what you’re missing." + txt " "; em [] [ audioClip "thats-true" (txt "(that’s true!)") ] + txt " and find out what you’re missing." ] ] /// Online help / documentation let howItWorks = - article [] [ - h3 [] [ rawText "How It Works" ] - p [] [ rawText "TODO: convert and update for v3"] + pageWithTitle "How It Works" [ + p [] [ txt "TODO: convert and update for v3"] ] /// The privacy policy let privacyPolicy = - let appName = rawText "Jobs, Jobs, Jobs" + let appName = txt "Jobs, Jobs, Jobs" article [] [ - h3 [] [ rawText "Privacy Policy" ] - p [ _class "fst-italic" ] [ rawText "(as of December 27th, 2022)" ] + h3 [] [ txt "Privacy Policy" ] + p [ _class "fst-italic" ] [ txt "(as of December 27th, 2022)" ] p [] [ - appName; rawText " (“we,” “our,” or “us”) is committed to protecting " - rawText "your privacy. This Privacy Policy explains how your personal information is collected, used, and " - rawText "disclosed by "; appName; rawText "." + appName; txt " (“we,” “our,” or “us”) is committed to protecting your " + txt "privacy. This Privacy Policy explains how your personal information is collected, used, and disclosed " + txt "disclosed by "; appName; txt "." ] p [] [ - rawText "This Privacy Policy applies to our website, and its associated subdomains (collectively, our " - rawText "“Service”) alongside our application, "; appName; rawText ". By accessing or using " - rawText "our Service, you signify that you have read, understood, and agree to our collection, storage, " - rawText "use, and disclosure of your personal information as described in this Privacy Policy and our " - rawText "Terms of Service." + txt "This Privacy Policy applies to our website, and its associated subdomains (collectively, our " + txt "“Service”) alongside our application, "; appName; rawText ". By accessing or using our " + txt "Service, you signify that you have read, understood, and agree to our collection, storage, use, and " + txt "disclosure of your personal information as described in this Privacy Policy and our Terms of Service." ] - h4 [] [ rawText "Definitions and key terms" ] + h4 [] [ txt "Definitions and key terms" ] p [] [ - rawText "To help explain things as clearly as possible in this Privacy Policy, every time any of these " - rawText "terms are referenced, are strictly defined as:" + txt "To help explain things as clearly as possible in this Privacy Policy, every time any of these terms " + txt "are referenced, are strictly defined as:" ] ul [] [ li [] [ - rawText "Cookie: small amount of data generated by a website and saved by your web browser. It is used " - rawText "to identify your browser, provide analytics, remember information about you such as your " - rawText "language preference or login information." + txt "Cookie: small amount of data generated by a website and saved by your web browser. It is used to " + txt "identify your browser, provide analytics, remember information about you such as your language " + txt "preference or login information." ] li [] [ - rawText "Company: when this policy mentions “Company,” “we,” “us,” " - rawText "or “our,” it refers to "; appName; rawText ", that is responsible for your " - rawText "information under this Privacy Policy." + txt "Company: when this policy mentions “Company,” “we,” “us,” or " + txt "“our,” it refers to "; appName; txt ", that is responsible for your information under " + txt "this Privacy Policy." ] li [] [ - rawText "Country: where "; appName; rawText " or the owners/founders of "; appName - rawText " are based, in this case is US." + txt "Country: where "; appName; txt " or the owners/founders of "; appName + txt " are based, in this case is US." ] li [] [ - rawText "Customer: refers to the company, organization or person that signs up to use the "; appName - rawText " Service to manage the relationships with your consumers or service users." + txt "Customer: refers to the company, organization or person that signs up to use the "; appName + txt " Service to manage the relationships with your consumers or service users." ] li [] [ - rawText "Device: any internet connected device such as a phone, tablet, computer or any other device " - rawText "that can be used to visit "; appName; rawText " and use the services." + txt "Device: any internet connected device such as a phone, tablet, computer or any other device that " + txt "can be used to visit "; appName; txt " and use the services." ] li [] [ - rawText "IP address: Every device connected to the Internet is assigned a number known as an Internet " - rawText "protocol (IP) address. These numbers are usually assigned in geographic blocks. An IP address " - rawText "can often be used to identify the location from which a device is connecting to the Internet." + txt "IP address: Every device connected to the Internet is assigned a number known as an Internet " + txt "protocol (IP) address. These numbers are usually assigned in geographic blocks. An IP address can " + txt "often be used to identify the location from which a device is connecting to the Internet." ] li [] [ - rawText "Personnel: refers to those individuals who are employed by "; appName; rawText " or are under " - rawText "contract to perform a service on behalf of one of the parties." + txt "Personnel: refers to those individuals who are employed by "; appName; txt " or are under " + txt "contract to perform a service on behalf of one of the parties." ] li [] [ - rawText "Personal Data: any information that directly, indirectly, or in connection with other " - rawText "information — including a personal identification number — allows for the identification or " - rawText "identifiability of a natural person." + txt "Personal Data: any information that directly, indirectly, or in connection with other information " + txt "— including a personal identification number — allows for the identification or identifiability " + txt "of a natural person." ] li [] [ - rawText "Service: refers to the service provided by "; appName; rawText " as described in the relative " - rawText "terms (if available) and on this platform." + txt "Service: refers to the service provided by "; appName; txt " as described in the relative terms " + txt "(if available) and on this platform." ] li [] [ - rawText "Third-party service: refers to advertisers, contest sponsors, promotional and marketing " - rawText "partners, and others who provide our content or whose products or services we think may " - rawText "interest you." + txt "Third-party service: refers to advertisers, contest sponsors, promotional and marketing partners, " + txt "and others who provide our content or whose products or services we think may interest you." ] li [] [ - rawText "Website: "; appName; rawText "’s site, which can be accessed via this URL: " - a [ _href "/" ] [ rawText "https://noagendacareers.com/" ] + txt "Website: "; appName; txt "’s site, which can be accessed via this URL: " + a [ _href "/" ] [ txt "https://noagendacareers.com/" ] ] li [] [ - rawText "You: a person or entity that is registered with "; appName; rawText " to use the Services." + txt "You: a person or entity that is registered with "; appName; txt " to use the Services." ] ] - h4 [] [ rawText "What Information Do We Collect?" ] + h4 [] [ txt "What Information Do We Collect?" ] p [] [ - rawText "We collect information from you when you visit our website, register on our site, or fill out a " - rawText "form." + txt "We collect information from you when you visit our website, register on our site, or fill out a form." ] ul [] [ - li [] [ rawText "Name / Username" ] - li [] [ rawText "Coarse Geographic Location" ] - li [] [ rawText "Employment History" ] - li [] [ rawText "Job Listing Information" ] + li [] [ txt "Name / Username" ] + li [] [ txt "Coarse Geographic Location" ] + li [] [ txt "Employment History" ] + li [] [ txt "Job Listing Information" ] ] - h4 [] [ rawText "How Do We Use The Information We Collect?" ] - p [] [ rawText "Any of the information we collect from you may be used in one of the following ways:" ] - ul[] [ - li [] [ - rawText "To personalize your experience (your information helps us to better respond to your " - rawText "individual needs)" - ] - li [] [ - rawText "To improve our website (we continually strive to improve our website offerings based on the " - rawText "information and feedback we receive from you)" - ] - li [] [ - rawText "To improve customer service (your information helps us to more effectively respond to your " - rawText "customer service requests and support needs)" - ] - ] - - h4 [] [ rawText "When does "; appName; rawText " use end user information from third parties?" ] - p [] [ - appName; rawText " will collect End User Data necessary to provide the "; appName - rawText " services to our customers." - ] - p [] [ - rawText "End users may voluntarily provide us with information they have made available on social media " - rawText "websites. If you provide us with any such information, we may collect publicly available " - rawText "information from the social media websites you have indicated. You can control how much of your " - rawText "information social media websites make public by visiting these websites and changing your " - rawText "privacy settings." - ] - - h4 [] [ rawText "When does "; appName; rawText " use customer information from third parties?" ] - p [] [ rawText "We do not utilize third party information apart from the end-user data described above." ] - - h4 [] [ rawText "Do we share the information we collect with third parties?" ] - p [] [ - rawText "We may disclose personal and non-personal information about you to government or law enforcement " - rawText "officials or private parties as we, in our sole discretion, believe necessary or appropriate in " - rawText "order to respond to claims, legal process (including subpoenas), to protect our rights and " - rawText "interests or those of a third party, the safety of the public or any person, to prevent or stop " - rawText "any illegal, unethical, or legally actionable activity, or to otherwise comply with applicable " - rawText "court orders, laws, rules and regulations." - ] - - h4 [] [ rawText "Where and when is information collected from customers and end users?" ] - p [] [ - appName; rawText " will collect personal information that you submit to us. We may also receive personal " - rawText "information about you from third parties as described above." - ] - - h4 [] [ rawText "How Do We Use Your E-mail Address?" ] - p [] [ - appName; rawText " uses your e-mail address to identify you, along with your password, as an authorized " - rawText "user of this site. E-mail addresses are verified via a time-sensitive link, and may also be used " - rawText "to send password reset authorization codes. We do not display this e-mail address to users. If " - rawText "you choose to add an e-mail address as a contact type, that e-mail address will be visible to " - rawText "other authorized users." - ] - - h4 [] [ rawText "How Long Do We Keep Your Information?" ] - p [] [ - rawText "We keep your information only so long as we need it to provide "; appName; rawText " to you and " - rawText "fulfill the purposes described in this policy. When we no longer need to use your information and " - rawText "there is no need for us to keep it to comply with our legal or regulatory obligations, we’ll " - rawText "either remove it from our systems or depersonalize it so that we can’t identify you." - ] - - h4 [] [ rawText "How Do We Protect Your Information?" ] - p [] [ - rawText "We implement a variety of security measures to maintain the safety of your personal information " - rawText "when you enter, submit, or access your personal information. We mandate the use of a secure " - rawText "server. We cannot, however, ensure or warrant the absolute security of any information you " - rawText "transmit to "; appName; rawText " or guarantee that your information on the Service may not be " - rawText "accessed, disclosed, altered, or destroyed by a breach of any of our physical, technical, or " - rawText "managerial safeguards." - ] - - h4 [] [ rawText "Could my information be transferred to other countries?" ] - p [] [ - appName; rawText " is hosted in the US. Information collected via our website may be viewed and hosted " - rawText "anywhere in the world, including countries that may not have laws of general applicability " - rawText "regulating the use and transfer of such data. To the fullest extent allowed by applicable law, by " - rawText "using any of the above, you voluntarily consent to the trans-border transfer and hosting of such " - rawText "information." - ] - - h4 [] [ rawText "Is the information collected through the "; appName; rawText " Service secure?" ] - p [] [ - rawText "We take precautions to protect the security of your information. We have physical, electronic, " - rawText "and managerial procedures to help safeguard, prevent unauthorized access, maintain data security, " - rawText "and correctly use your information. However, neither people nor security systems are foolproof, " - rawText "including encryption systems. In addition, people can commit intentional crimes, make mistakes, " - rawText "or fail to follow policies. Therefore, while we use reasonable efforts to protect your personal " - rawText "information, we cannot guarantee its absolute security. If applicable law imposes any " - rawText "non-disclaimable duty to protect your personal information, you agree that intentional misconduct " - rawText "will be the standards used to measure our compliance with that duty." - ] - - h4 [] [ rawText "Can I update or correct my information?" ] - p [] [ - rawText "The rights you have to request updates or corrections to the information "; appName - rawText " collects depend on your relationship with "; appName; rawText "." - ] - p [] [ - rawText "Customers have the right to request the restriction of certain uses and disclosures of personally " - rawText "identifiable information as follows. You can contact us in order to (1) update or correct your " - rawText "personally identifiable information, or (3) delete the personally identifiable information " - rawText "maintained about you on our systems (subject to the following paragraph), by cancelling your " - rawText "account. Such updates, corrections, changes and deletions will have no effect on other " - rawText "information that we maintain in accordance with this Privacy Policy prior to such update, " - rawText "correction, change, or deletion. You are responsible for maintaining the secrecy of your unique " - rawText "password and account information at all times." - ] - p [] [ - appName; rawText " also provides ways for users to modify or remove the information we have collected from " - rawText "them from the application; these actions will have the same effect as contacting us to modify or " - rawText "remove data." - ] - p [] [ - rawText "You should be aware that it is not technologically possible to remove each and every record of " - rawText "the information you have provided to us from our system. The need to back up our systems to " - rawText "protect information from inadvertent loss means that a copy of your information may exist in a " - rawText "non-erasable form that will be difficult or impossible for us to locate. Promptly after receiving " - rawText "your request, all personal information stored in databases we actively use, and other readily " - rawText "searchable media will be updated, corrected, changed, or deleted, as appropriate, as soon as and " - rawText "to the extent reasonably and technically practicable." - ] - p [] [ - rawText "If you are an end user and wish to update, delete, or receive any information we have about you, " - rawText "you may do so by contacting the organization of which you are a customer." - ] - - h4 [] [ rawText "Governing Law" ] - p [] [ - rawText "This Privacy Policy is governed by the laws of US without regard to its conflict of laws " - rawText "provision. You consent to the exclusive jurisdiction of the courts in connection with any action " - rawText "or dispute arising between the parties under or in connection with this Privacy Policy except for " - rawText "those individuals who may have rights to make claims under Privacy Shield, or the Swiss-US " - rawText "framework." - ] - p [] [ - rawText "The laws of US, excluding its conflicts of law rules, shall govern this Agreement and your use of " - rawText "the website. Your use of the website may also be subject to other local, state, national, or " - rawText "international laws." - ] - p [] [ - rawText "By using "; appName; rawText " or contacting us directly, you signify your acceptance of this " - rawText "Privacy Policy. If you do not agree to this Privacy Policy, you should not engage with our " - rawText "website, or use our services. Continued use of the website, direct engagement with us, or " - rawText "following the posting of changes to this Privacy Policy that do not significantly affect the use " - rawText "or disclosure of your personal information will mean that you accept those changes." - ] - - h4 [] [ rawText "Your Consent" ] - p [] [ - rawText "We’ve updated our Privacy Policy to provide you with complete transparency into what is " - rawText "being set when you visit our site and how it’s being used. By using our website, " - rawText "registering an account, or making a purchase, you hereby consent to our Privacy Policy and agree " - rawText "to its terms." - ] - - h4 [] [ rawText "Links to Other Websites" ] - p [] [ - rawText "This Privacy Policy applies only to the Services. The Services may contain links to other " - rawText "websites not operated or controlled by "; appName; rawText ". We are not responsible for the " - rawText "content, accuracy or opinions expressed in such websites, and such websites are not investigated, " - rawText "monitored or checked for accuracy or completeness by us. Please remember that when you use a link " - rawText "to go from the Services to another website, our Privacy Policy is no longer in effect. Your " - rawText "browsing and interaction on any other website, including those that have a link on our platform, " - rawText "is subject to that website’s own rules and policies. Such third parties may use their own " - rawText "cookies or other methods to collect information about you." - ] - - h4 [] [ rawText "Cookies" ] - p [] [ - appName; rawText " uses a session Cookie to identify an active, logged-on session. This Cookie is removed " - rawText "when You explicitly log off; is not accessible via script; and must be transferred over a " - rawText "secured, encrypted connection." - ] - p [] [ - appName; rawText " uses no persistent or Third-Party Cookies." - ] - - h4 [] [ rawText "Kids’ Privacy" ] - p [] [ - rawText "We do not address anyone under the age of 13. We do not knowingly collect personally identifiable " - rawText "information from anyone under the age of 13. If You are a parent or guardian and You are aware " - rawText "that Your child has provided Us with Personal Data, please contact Us. If We become aware that We " - rawText "have collected Personal Data from anyone under the age of 13 without verification of parental " - rawText "consent, We take steps to remove that information from Our servers." - ] - - h4 [] [ rawText "Changes To Our Privacy Policy" ] - p [] [ - rawText "We may change our Service and policies, and we may need to make changes to this Privacy Policy so " - rawText "that they accurately reflect our Service and policies. Unless otherwise required by law, we will " - rawText "notify you (for example, through our Service) before we make changes to this Privacy Policy and " - rawText "give you an opportunity to review them before they go into effect. Then, if you continue to use " - rawText "the Service, you will be bound by the updated Privacy Policy. If you do not want to agree to this " - rawText "or any updated Privacy Policy, you can delete your account." - ] - - h4 [] [ rawText "Third-Party Services" ] - p [] [ - rawText "We may display, include or make available third-party content (including data, information, " - rawText "applications and other products services) or provide links to third-party websites or services " - rawText "(“Third-Party Services”)." - ] - p [] [ - rawText "You acknowledge and agree that "; appName; rawText " shall not be responsible for any Third-Party " - rawText "Services, including their accuracy, completeness, timeliness, validity, copyright compliance, " - rawText "legality, decency, quality or any other aspect thereof. "; appName; rawText " does not assume and " - rawText "shall not have any liability or responsibility to you or any other person or entity for any " - rawText "Third-Party Services." - ] - p [] [ - rawText "Third-Party Services and links thereto are provided solely as a convenience to you and you access " - rawText "and use them entirely at your own risk and subject to such third parties’ terms and " - rawText "conditions." - ] - - h4 [] [ rawText "Tracking Technologies" ] - p [] [ appName; rawText " does not use any tracking technologies." ] - - h4 [] [ rawText "Information about General Data Protection Regulation (GDPR)" ] - p [] [ - rawText "We may be collecting and using information from you if you are from the European Economic Area " - rawText "(EEA), and in this section of our Privacy Policy we are going to explain exactly how and why is " - rawText "this data collected, and how we maintain this data under protection from being replicated or used " - rawText "in the wrong way." - ] - - h5 [] [ rawText "What is GDPR?" ] - p [] [ - rawText "GDPR is an EU-wide privacy and data protection law that regulates how EU residents’ data is " - rawText "protected by companies and enhances the control the EU residents have, over their personal data." - ] - p [] [ - rawText "The GDPR is relevant to any globally operating company and not just the EU-based businesses and " - rawText "EU residents. Our customers’ data is important irrespective of where they are located, which is " - rawText "why we have implemented GDPR controls as our baseline standard for all our operations worldwide." - ] - - h5 [] [ rawText "What is personal data?" ] - p [] [ - rawText "Any data that relates to an identifiable or identified individual. GDPR covers a broad spectrum " - rawText "of information that could be used on its own, or in combination with other pieces of information, " - rawText "to identify a person. Personal data extends beyond a person’s name or email address. Some " - rawText "examples include financial information, political opinions, genetic data, biometric data, IP " - rawText "addresses, physical address, sexual orientation, and ethnicity." - ] - p [] [ rawText "The Data Protection Principles include requirements such as:" ] + h4 [] [ txt "How Do We Use The Information We Collect?" ] + p [] [ txt "Any of the information we collect from you may be used in one of the following ways:" ] ul [] [ li [] [ - rawText "Personal data collected must be processed in a fair, legal, and transparent way and should " - rawText "only be used in a way that a person would reasonably expect." + txt "To personalize your experience (your information helps us to better respond to your individual " + txt "needs)" ] li [] [ - rawText "Personal data should only be collected to fulfil a specific purpose and it should only be " - rawText "used for that purpose. Organizations must specify why they need the personal data when they " - rawText "collect it." + txt "To improve our website (we continually strive to improve our website offerings based on the " + txt "information and feedback we receive from you)" ] - li [] [ rawText "Personal data should be held no longer than necessary to fulfil its purpose." ] li [] [ - rawText "People covered by the GDPR have the right to access their own personal data. They can also " - rawText "request a copy of their data, and that their data be updated, deleted, restricted, or moved " - rawText "to another organization." + txt "To improve customer service (your information helps us to more effectively respond to your " + txt "customer service requests and support needs)" ] ] - h5 [] [ rawText "Why is GDPR important?" ] + h4 [] [ txt "When does "; appName; txt " use end user information from third parties?" ] p [] [ - rawText "GDPR adds some new requirements regarding how companies should protect individuals’ " - rawText "personal data that they collect and process. It also raises the stakes for compliance by " - rawText "increasing enforcement and imposing greater fines for breach. Beyond these facts, it’s " - rawText "simply the right thing to do. At "; appName; rawText " we strongly believe that your data privacy " - rawText "is very important and we already have solid security and privacy practices in place that go " - rawText "beyond the requirements of this regulation." + appName; txt " will collect End User Data necessary to provide the "; appName + txt " services to our customers." + ] + p [] [ + txt "End users may voluntarily provide us with information they have made available on social media " + txt "websites. If you provide us with any such information, we may collect publicly available information " + txt "from the social media websites you have indicated. You can control how much of your information " + txt "social media websites make public by visiting these websites and changing your privacy settings." ] - h5 [] [ rawText "Individual Data Subject’s Rights - Data Access, Portability, and Deletion" ] + h4 [] [ txt "When does "; appName; txt " use customer information from third parties?" ] + p [] [ txt "We do not utilize third party information apart from the end-user data described above." ] + + h4 [] [ txt "Do we share the information we collect with third parties?" ] p [] [ - rawText "We are committed to helping our customers meet the data subject rights requirements of GDPR. " - appName; rawText " processes or stores all personal data in fully vetted, DPA compliant vendors. We do " - rawText "store all conversation and personal data for up to 6 years unless your account is deleted. In " - rawText "which case, we dispose of all data in accordance with our Terms of Service and Privacy Policy, " - rawText "but we will not hold it longer than 60 days." - ] - p [] [ - rawText "We are aware that if you are working with EU customers, you need to be able to provide them with " - rawText "the ability to access, update, retrieve and remove personal data. We got you! We’ve been " - rawText "set up as self service from the start and have always given you access to your data. Our customer " - rawText "support team is here for you to answer any questions you might have about working with the API." + txt "We may disclose personal and non-personal information about you to government or law enforcement " + txt "officials or private parties as we, in our sole discretion, believe necessary or appropriate in order " + txt "to respond to claims, legal process (including subpoenas), to protect our rights and interests or " + txt "those of a third party, the safety of the public or any person, to prevent or stop any illegal, " + txt "unethical, or legally actionable activity, or to otherwise comply with applicable court orders, laws, " + txt "rules and regulations." ] - h4 [] [ rawText "California Residents" ] + h4 [] [ txt "Where and when is information collected from customers and end users?" ] p [] [ - rawText "The California Consumer Privacy Act (CCPA) requires us to disclose categories of Personal " - rawText "Information we collect and how we use it, the categories of sources from whom we collect Personal " - rawText "Information, and the third parties with whom we share it, which we have explained above." + appName; txt " will collect personal information that you submit to us. We may also receive personal " + txt "information about you from third parties as described above." + ] + + h4 [] [ txt "How Do We Use Your E-mail Address?" ] + p [] [ + appName; txt " uses your e-mail address to identify you, along with your password, as an authorized user " + txt "of this site. E-mail addresses are verified via a time-sensitive link, and may also be used to send " + txt "password reset authorization codes. We do not display this e-mail address to users. If you choose to " + txt "add an e-mail address as a contact type, that e-mail address will be visible to other authorized " + txt "users." + ] + + h4 [] [ txt "How Long Do We Keep Your Information?" ] + p [] [ + txt "We keep your information only so long as we need it to provide "; appName; txt " to you and fulfill " + txt "the purposes described in this policy. When we no longer need to use your information and there is no " + txt "need for us to keep it to comply with our legal or regulatory obligations, we’ll either remove it " + txt "from our systems or depersonalize it so that we can’t identify you." + ] + + h4 [] [ txt "How Do We Protect Your Information?" ] + p [] [ + txt "We implement a variety of security measures to maintain the safety of your personal information when " + txt "you enter, submit, or access your personal information. We mandate the use of a secure server. We " + txt "cannot, however, ensure or warrant the absolute security of any information you transmit to "; appName + txt " or guarantee that your information on the Service may not be accessed, disclosed, altered, or " + txt "destroyed by a breach of any of our physical, technical, or managerial safeguards." + ] + + h4 [] [ txt "Could my information be transferred to other countries?" ] + p [] [ + appName; txt " is hosted in the US. Information collected via our website may be viewed and hosted " + txt "anywhere in the world, including countries that may not have laws of general applicability regulating " + txt "the use and transfer of such data. To the fullest extent allowed by applicable law, by using any of " + txt "the above, you voluntarily consent to the trans-border transfer and hosting of such information." + ] + + h4 [] [ txt "Is the information collected through the "; appName; txt " Service secure?" ] + p [] [ + txt "We take precautions to protect the security of your information. We have physical, electronic, and " + txt "managerial procedures to help safeguard, prevent unauthorized access, maintain data security, and " + txt "correctly use your information. However, neither people nor security systems are foolproof, including " + txt "encryption systems. In addition, people can commit intentional crimes, make mistakes, or fail to " + txt "follow policies. Therefore, while we use reasonable efforts to protect your personal information, we " + txt "cannot guarantee its absolute security. If applicable law imposes any non-disclaimable duty to " + txt "protect your personal information, you agree that intentional misconduct will be the standards used " + txt "to measure our compliance with that duty." + ] + + h4 [] [ txt "Can I update or correct my information?" ] + p [] [ + txt "The rights you have to request updates or corrections to the information "; appName + txt " collects depend on your relationship with "; appName; txt "." ] p [] [ - rawText "We are also required to communicate information about rights California residents have under " - rawText "California law. You may exercise the following rights:" + txt "Customers have the right to request the restriction of certain uses and disclosures of personally " + txt "identifiable information as follows. You can contact us in order to (1) update or correct your " + txt "personally identifiable information, or (3) delete the personally identifiable information maintained " + txt "about you on our systems (subject to the following paragraph), by cancelling your account. Such " + txt "updates, corrections, changes and deletions will have no effect on other information that we maintain " + txt "in accordance with this Privacy Policy prior to such update, correction, change, or deletion. You are " + txt "responsible for maintaining the secrecy of your unique password and account information at all times." + ] + p [] [ + appName; txt " also provides ways for users to modify or remove the information we have collected from " + txt "them from the application; these actions will have the same effect as contacting us to modify or " + txt "remove data." + ] + p [] [ + txt "You should be aware that it is not technologically possible to remove each and every record of the " + txt "information you have provided to us from our system. The need to back up our systems to protect " + txt "information from inadvertent loss means that a copy of your information may exist in a non-erasable " + txt "form that will be difficult or impossible for us to locate. Promptly after receiving your request, " + txt "all personal information stored in databases we actively use, and other readily searchable media will " + txt "be updated, corrected, changed, or deleted, as appropriate, as soon as and to the extent reasonably " + txt "and technically practicable." + ] + p [] [ + txt "If you are an end user and wish to update, delete, or receive any information we have about you, you " + txt "may do so by contacting the organization of which you are a customer." + ] + + h4 [] [ txt "Governing Law" ] + p [] [ + txt "This Privacy Policy is governed by the laws of US without regard to its conflict of laws provision. " + txt "You consent to the exclusive jurisdiction of the courts in connection with any action or dispute " + txt "arising between the parties under or in connection with this Privacy Policy except for those " + txt "individuals who may have rights to make claims under Privacy Shield, or the Swiss-US framework." + ] + p [] [ + txt "The laws of US, excluding its conflicts of law rules, shall govern this Agreement and your use of the " + txt "website. Your use of the website may also be subject to other local, state, national, or " + txt "international laws." + ] + p [] [ + txt "By using "; appName; txt " or contacting us directly, you signify your acceptance of this Privacy " + txt "Policy. If you do not agree to this Privacy Policy, you should not engage with our website, or use " + txt "our services. Continued use of the website, direct engagement with us, or following the posting of " + txt "changes to this Privacy Policy that do not significantly affect the use or disclosure of your " + txt "personal information will mean that you accept those changes." + ] + + h4 [] [ txt "Your Consent" ] + p [] [ + txt "We’ve updated our Privacy Policy to provide you with complete transparency into what is being " + txt "set when you visit our site and how it’s being used. By using our website, registering an " + txt "account, or making a purchase, you hereby consent to our Privacy Policy and agree to its terms." + ] + + h4 [] [ txt "Links to Other Websites" ] + p [] [ + txt "This Privacy Policy applies only to the Services. The Services may contain links to other websites " + txt "not operated or controlled by "; appName; txt ". We are not responsible for the content, accuracy, or " + txt "opinions expressed in such websites, and such websites are not investigated, monitored, or checked " + txt "for accuracy or completeness by us. Please remember that when you use a link to from the Services to " + txt "another website, our Privacy Policy is no longer in effect. Your browsing and interaction on any " + txt "other website, including those that have a link on our platform, is subject to that website’s own " + txt "rules and policies. Such third parties may use their own cookies or other methods to collect " + txt "information about you." + ] + + h4 [] [ txt "Cookies" ] + p [] [ + appName; txt " uses a session Cookie to identify an active, logged-on session. This Cookie is removed when " + txt "when You explicitly log off; is not accessible via script; and must be transferred over a secured, " + txt "encrypted connection." + ] + p [] [ appName; txt " uses no persistent or Third-Party Cookies." ] + + h4 [] [ txt "Kids’ Privacy" ] + p [] [ + txt "We do not address anyone under the age of 13. We do not knowingly collect personally identifiable " + txt "information from anyone under the age of 13. If You are a parent or guardian and You are aware that " + txt "Your child has provided Us with Personal Data, please contact Us. If We become aware that We have " + txt "collected Personal Data from anyone under the age of 13 without verification of parental consent, We " + txt "take steps to remove that information from Our servers." + ] + + h4 [] [ txt "Changes To Our Privacy Policy" ] + p [] [ + txt "We may change our Service and policies, and we may need to make changes to this Privacy Policy so " + txt "that they accurately reflect our Service and policies. Unless otherwise required by law, we will " + txt "notify you (for example, through our Service) before we make changes to this Privacy Policy and give " + txt "you an opportunity to review them before they go into effect. Then, if you continue to use the " + txt "Service, you will be bound by the updated Privacy Policy. If you do not want to agree to this or any " + txt "updated Privacy Policy, you can delete your account." + ] + + h4 [] [ txt "Third-Party Services" ] + p [] [ + txt "We may display, include or make available third-party content (including data, information, " + txt "applications and other products services) or provide links to third-party websites or services " + txt "(“Third-Party Services”)." + ] + p [] [ + txt "You acknowledge and agree that "; appName; txt " shall not be responsible for any Third-Party " + txt "Services, including their accuracy, completeness, timeliness, validity, copyright compliance, " + txt "legality, decency, quality or any other aspect thereof. "; appName; txt " does not assume and shall " + txt "not have any liability or responsibility to you or any other person or entity for any Third-Party " + txt "Services." + ] + p [] [ + txt "Third-Party Services and links thereto are provided solely as a convenience to you and you access and " + txt "use them entirely at your own risk and subject to such third parties’ terms and conditions." + ] + + h4 [] [ txt "Tracking Technologies" ] + p [] [ appName; txt " does not use any tracking technologies." ] + + h4 [] [ txt "Information about General Data Protection Regulation (GDPR)" ] + p [] [ + txt "We may be collecting and using information from you if you are from the European Economic Area (EEA), " + txt "and in this section of our Privacy Policy we are going to explain exactly how and why is this data " + txt "collected, and how we maintain this data under protection from being replicated or used in the wrong " + txt "way." + ] + + h5 [] [ txt "What is GDPR?" ] + p [] [ + txt "GDPR is an EU-wide privacy and data protection law that regulates how EU residents’ data is " + txt "protected by companies and enhances the control the EU residents have, over their personal data." + ] + p [] [ + txt "The GDPR is relevant to any globally operating company and not just the EU-based businesses and EU " + txt "residents. Our customers’ data is important irrespective of where they are located, which is why we " + txt "have implemented GDPR controls as our baseline standard for all our operations worldwide." + ] + + h5 [] [ txt "What is personal data?" ] + p [] [ + txt "Any data that relates to an identifiable or identified individual. GDPR covers a broad spectrum of " + txt "information that could be used on its own, or in combination with other pieces of information, to " + txt "identify a person. Personal data extends beyond a person’s name or email address. Some examples " + txt "include financial information, political opinions, genetic data, biometric data, IP addresses, " + txt "physical address, sexual orientation, and ethnicity." + ] + p [] [ txt "The Data Protection Principles include requirements such as:" ] + ul [] [ + li [] [ + txt "Personal data collected must be processed in a fair, legal, and transparent way and should only " + txt "be used in a way that a person would reasonably expect." + ] + li [] [ + txt "Personal data should only be collected to fulfil a specific purpose and it should only be used " + txt "for that purpose. Organizations must specify why they need the personal data when they collect it." + ] + li [] [ txt "Personal data should be held no longer than necessary to fulfil its purpose." ] + li [] [ + txt "People covered by the GDPR have the right to access their own personal data. They can also " + txt "request a copy of their data, and that their data be updated, deleted, restricted, or moved to " + txt "another organization." + ] + ] + + h5 [] [ txt "Why is GDPR important?" ] + p [] [ + txt "GDPR adds some new requirements regarding how companies should protect individuals’ personal " + txt "data that they collect and process. It also raises the stakes for compliance by increasing " + txt "enforcement and imposing greater fines for breach. Beyond these facts, it’s simply the right " + txt "thing to do. At "; appName; txt " we strongly believe that your data privacy is very important and we " + txt "already have solid security and privacy practices in place that go beyond the requirements of this " + txt "regulation." + ] + + h5 [] [ txt "Individual Data Subject’s Rights - Data Access, Portability, and Deletion" ] + p [] [ + txt "We are committed to helping our customers meet the data subject rights requirements of GDPR. " + appName; txt " processes or stores all personal data in fully vetted, DPA compliant vendors. We do store " + txt "all conversation and personal data for up to 6 years unless your account is deleted. In which case, " + txt "we dispose of all data in accordance with our Terms of Service and Privacy Policy, but we will not " + txt "hold it longer than 60 days." + ] + p [] [ + txt "We are aware that if you are working with EU customers, you need to be able to provide them with the " + txt "ability to access, update, retrieve and remove personal data. We got you! We’ve been set up as " + txt "self service from the start and have always given you access to your data. Our customer support team " + txt "is here for you to answer any questions you might have about working with the API." + ] + + h4 [] [ txt "California Residents" ] + p [] [ + txt "The California Consumer Privacy Act (CCPA) requires us to disclose categories of Personal Information " + txt "we collect and how we use it, the categories of sources from whom we collect Personal Information, " + txt "and the third parties with whom we share it, which we have explained above." + ] + p [] [ + txt "We are also required to communicate information about rights California residents have under " + txt "California law. You may exercise the following rights:" ] ul [] [ li [] [ - rawText "Right to Know and Access. You may submit a verifiable request for information regarding the: " - rawText "(1) categories of Personal Information we collect, use, or share; (2) purposes for which " - rawText "categories of Personal Information are collected or used by us; (3) categories of sources " - rawText "from which we collect Personal Information; and (4) specific pieces of Personal Information " - rawText "we have collected about you." + txt "Right to Know and Access. You may submit a verifiable request for information regarding the: (1) " + txt "categories of Personal Information we collect, use, or share; (2) purposes for which categories " + txt "of Personal Information are collected or used by us; (3) categories of sources from which we " + txt "collect Personal Information; and (4) specific pieces of Personal Information we have collected " + txt "about you." ] li [] [ - rawText "Right to Equal Service. We will not discriminate against you if you exercise your privacy " - rawText "rights." + txt "Right to Equal Service. We will not discriminate against you if you exercise your privacy rights." ] li [] [ - rawText "Right to Delete. You may submit a verifiable request to close your account and we will delete " - rawText "Personal Information about you that we have collected." + txt "Right to Delete. You may submit a verifiable request to close your account and we will delete " + txt "Personal Information about you that we have collected." ] li [] [ - rawText "Request that a business that sells a consumer’s personal data, not sell the " - rawText "consumer’s personal data." + txt "Request that a business that sells a consumer’s personal data, not sell the " + txt "consumer’s personal data." ] ] p [] [ - rawText "If you make a request, we have one month to respond to you. If you would like to exercise any of " - rawText "these rights, please contact us." + txt "If you make a request, we have one month to respond to you. If you would like to exercise any of " + txt "these rights, please contact us." ] - p [] [ rawText "We do not sell the Personal Information of our users." ] - p [] [ rawText "For more information about these rights, please contact us." ] + p [] [ txt "We do not sell the Personal Information of our users." ] + p [] [ txt "For more information about these rights, please contact us." ] - h5 [] [ rawText "California Online Privacy Protection Act (CalOPPA)" ] + h5 [] [ txt "California Online Privacy Protection Act (CalOPPA)" ] p [] [ - rawText "CalOPPA requires us to disclose categories of Personal Information we collect and how we use it, " - rawText "the categories of sources from whom we collect Personal Information, and the third parties with " - rawText "whom we share it, which we have explained above." + txt "CalOPPA requires us to disclose categories of Personal Information we collect and how we use it, the " + txt "categories of sources from whom we collect Personal Information, and the third parties with whom we " + txt "share it, which we have explained above." ] - p [] [ rawText "CalOPPA users have the following rights:" ] + p [] [ txt "CalOPPA users have the following rights:" ] ul [] [ li [] [ - rawText "Right to Know and Access. You may submit a verifiable request for information regarding the: " - rawText "(1) categories of Personal Information we collect, use, or share; (2) purposes for which " - rawText "categories of Personal Information are collected or used by us; (3) categories of sources " - rawText "from which we collect Personal Information; and (4) specific pieces of Personal Information " - rawText "we have collected about you." + txt "Right to Know and Access. You may submit a verifiable request for information regarding the: (1) " + txt "categories of Personal Information we collect, use, or share; (2) purposes for which categories " + txt "of Personal Information are collected or used by us; (3) categories of sources from which we " + txt "collect Personal Information; and (4) specific pieces of Personal Information we have collected " + txt "about you." ] li [] [ - rawText "Right to Equal Service. We will not discriminate against you if you exercise your privacy " - rawText "rights." + txt "Right to Equal Service. We will not discriminate against you if you exercise your privacy rights." ] li [] [ - rawText "Right to Delete. You may submit a verifiable request to close your account and we will delete " - rawText "Personal Information about you that we have collected." + txt "Right to Delete. You may submit a verifiable request to close your account and we will delete " + txt "Personal Information about you that we have collected." ] li [] [ - rawText "Right to request that a business that sells a consumer’s personal data, not sell the " - rawText "consumer’s personal data." + txt "Right to request that a business that sells a consumer’s personal data, not sell the " + txt "consumer’s personal data." ] ] p [] [ - rawText "If you make a request, we have one month to respond to you. If you would like to exercise any of " - rawText "these rights, please contact us." + txt "If you make a request, we have one month to respond to you. If you would like to exercise any of " + txt "these rights, please contact us." ] - p [] [ rawText "We do not sell the Personal Information of our users." ] - p [] [ rawText "For more information about these rights, please contact us." ] + p [] [ txt "We do not sell the Personal Information of our users." ] + p [] [ txt "For more information about these rights, please contact us." ] - h4 [] [ rawText "Contact Us" ] - p [] [ rawText "Don’t hesitate to contact us if you have any questions." ] + h4 [] [ txt "Contact Us" ] + p [] [ txt "Don’t hesitate to contact us if you have any questions." ] ul [] [ li [] [ - rawText "Via this Link: " - a [ _href "/how-it-works" ] [rawText "https://noagendacareers.com/how-it-works" ] + txt "Via this Link: "; a [ _href "/how-it-works" ] [ txt "https://noagendacareers.com/how-it-works" ] ] ] hr [] - p [ _class "fst-italic" ] [ rawText "Changes for "; appName; rawText " v3 (December 27th, 2022)" ] + p [ _class "fst-italic" ] [ txt "Changes for "; appName; txt " v3 (December 27th, 2022)" ] ul [] [ - li [ _class "fst-italic" ] [ rawText "Removed references to Mastodon" ] - li [ _class "fst-italic" ] [ rawText "Added references to job listings" ] - li [ _class "fst-italic" ] [ rawText "Changed information regarding e-mail addresses" ] - li [ _class "fst-italic" ] [ rawText "Updated cookie / tracking sections for new architecture" ] + li [ _class "fst-italic" ] [ txt "Removed references to Mastodon" ] + li [ _class "fst-italic" ] [ txt "Added references to job listings" ] + li [ _class "fst-italic" ] [ txt "Changed information regarding e-mail addresses" ] + li [ _class "fst-italic" ] [ txt "Updated cookie / tracking sections for new architecture" ] ] p [ _class "fst-italic" ] [ - rawText "Change on September 6th, 2021 – replaced “No Agenda Social” with " - rawText "generic terms for any authorized Mastodon instance." + txt "Change on September 6th, 2021 – replaced “No Agenda Social” with generic " + txt "terms for any authorized Mastodon instance." ] ] /// The page for terms of service let termsOfService = article [] [ - h3 [] [ rawText "Terms of Service" ] - p [ _class "fst-italic" ] [ rawText "(as of August 30th, 2022)" ] - h4 [] [ rawText "Acceptance of Terms" ] + h3 [] [ txt "Terms of Service" ] + p [ _class "fst-italic" ] [ txt "(as of August 30th, 2022)" ] + h4 [] [ txt "Acceptance of Terms" ] p [] [ - rawText "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that " - rawText "you are responsible to ensure that your use of this site complies with all applicable laws. Your " - rawText "continued use of this site implies your acceptance of these terms." + txt "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you " + txt "are responsible to ensure that your use of this site complies with all applicable laws. Your " + txt "continued use of this site implies your acceptance of these terms." ] - h4 [] [ rawText "Description of Service and Registration" ] + h4 [] [ txt "Description of Service and Registration" ] p [] [ - rawText "Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles and " - rawText "job listings, restricting access to the details of these to other users of this site, unless the " - rawText "individual specifies that this information should be visible publicly. See our " - a [ _href "/privacy-policy" ] [ str "privacy policy" ] - rawText " for details on the personal (user) information we maintain." + txt "Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles and job " + txt "listings, restricting access to the details of these to other users of this site, unless the " + txt "individual specifies that this information should be visible publicly. See our " + a [ _href "/privacy-policy" ] [ txt "privacy policy" ] + txt " for details on the personal (user) information we maintain." ] - h4 [] [ rawText "Liability" ] + h4 [] [ txt "Liability" ] p [] [ - rawText "This service is provided “as is”, and no warranty (express or implied) exists. The " - rawText "service and its developers may not be held liable for any damages that may arise through the use " - rawText "of this service." + txt "This service is provided “as is”, and no warranty (express or implied) exists. The " + txt "service and its developers may not be held liable for any damages that may arise through the use of " + txt "this service." ] - h4 [] [ rawText "Updates to Terms" ] + h4 [] [ txt "Updates to Terms" ] p [] [ - rawText "These terms and conditions may be updated at any time. When these terms are updated, users will " - rawText "be notified via a notice on the dashboard page. Additionally, the date at the top of this page " - rawText "will be updated, and any substantive updates will also be accompanied by a summary of those " - rawText "changes." + txt "These terms and conditions may be updated at any time. When these terms are updated, users will be " + txt "notified via a notice on the dashboard page. Additionally, the date at the top of this page will be " + txt "updated, and any substantive updates will also be accompanied by a summary of those changes." ] hr [] p [] [ - rawText "You may also wish to review our " - a [ _href "/privacy-policy" ] [ rawText "privacy policy" ] - rawText " to learn how we handle your data." + txt "You may also wish to review our "; a [ _href "/privacy-policy" ] [ txt "privacy policy" ] + txt " to learn how we handle your data." ] hr [] p [ _class "fst-italic" ] [ - rawText "Change on August 30th, 2022 – added references to job listings, removed " - rawText "references to Mastodon instances." + txt "Change on August 30th, 2022 – added references to job listings, removed references " + txt "to Mastodon instances." ] p [ _class "fst-italic" ] [ - rawText "Change on September 6th, 2021 – replaced “No Agenda Social” with a " - rawText "list of all No Agenda-affiliated Mastodon instances." + txt "Change on September 6th, 2021 – replaced “No Agenda Social” with a list " + txt "of all No Agenda-affiliated Mastodon instances." ] ] diff --git a/src/JobsJobsJobs/Server/Views/Layout.fs b/src/JobsJobsJobs/Server/Views/Layout.fs index 4715b6a..072dba5 100644 --- a/src/JobsJobsJobs/Server/Views/Layout.fs +++ b/src/JobsJobsJobs/Server/Views/Layout.fs @@ -54,7 +54,7 @@ let private links ctx = a [ _href url _onclick "jjj.hideMenu()" if url = ctx.CurrentUrl then _class "jjj-current-page" - ] [ i [ _class $"mdi mdi-{icon}"; _ariaHidden "true" ] []; rawText text ] + ] [ i [ _class $"mdi mdi-{icon}"; _ariaHidden "true" ] []; txt text ] nav [ _class "jjj-nav" ] [ if ctx.IsLoggedOn then navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard" @@ -76,12 +76,10 @@ let private links ctx = /// Generate mobile and desktop side navigation areas let private sideNavs ctx = [ - div [ _id "mobileMenu" - _class "jjj-mobile-menu offcanvas offcanvas-end" - _tabindex "-1" + div [ _id "mobileMenu"; _class "jjj-mobile-menu offcanvas offcanvas-end"; _tabindex "-1" _ariaLabelledBy "mobileMenuLabel" ] [ div [ _class "offcanvas-header" ] [ - h5 [ _id "mobileMenuLabel" ] [ rawText "Menu" ] + h5 [ _id "mobileMenuLabel" ] [ txt "Menu" ] button [ _class "btn-close text-reset"; _type "button"; _data "bs-dismiss" "offcanvas"; _ariaLabel "Close" ] [] @@ -89,8 +87,8 @@ let private sideNavs ctx = [ 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 "/" ] [ rawText "Jobs, Jobs, Jobs" ] ] - p [] [ rawText " " ] + p [ _class "home-link pb-3" ] [ a [ _href "/" ] [ txt "Jobs, Jobs, Jobs" ] ] + emptyP links ctx ] ] @@ -98,18 +96,14 @@ let private sideNavs 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 "/" ] [ rawText "Jobs, Jobs, Jobs" ] ] - button [ _class "btn" - _data "bs-toggle" "offcanvas" - _data "bs-target" "#mobileMenu" + 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 [] [ rawText " " ] + span [] [ txt " " ] span [ _class "navbar-text" ] [ - rawText "(…and Jobs – " - audioClip "pelosi-jobs" (rawText "Let’s Vote for Jobs!") - rawText ")" + txt "(…and Jobs – "; audioClip "pelosi-jobs" (txt "Let’s Vote for Jobs!"); txt ")" ] ] ] @@ -127,9 +121,9 @@ let private htmlFoot = } |> Seq.reduce (+) footer [] [ p [ _class "text-muted" ] [ - str "Jobs, Jobs, Jobs v"; str version; rawText " • " - a [ _href "/privacy-policy" ] [ str "Privacy Policy" ]; rawText " • " - a [ _href "/terms-of-service" ] [ str "Terms of Service" ] + txt $"Jobs, Jobs, Jobs v{version} • " + a [ _href "/privacy-policy" ] [ txt "Privacy Policy" ]; txt " • " + a [ _href "/terms-of-service" ] [ txt "Terms of Service" ] ] ] @@ -143,9 +137,8 @@ let private messages ctx = 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 [] [ rawText (parts[0].ToUpperInvariant ()); rawText ": " ] - rawText message + if level <> "success" then strong [] [ txt $"{parts[0].ToUpperInvariant ()}: " ] + txt message ] button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] [] ]) diff --git a/src/JobsJobsJobs/Server/Views/Listing.fs b/src/JobsJobsJobs/Server/Views/Listing.fs index 70156d2..ddc0c0b 100644 --- a/src/JobsJobsJobs/Server/Views/Listing.fs +++ b/src/JobsJobsJobs/Server/Views/Listing.fs @@ -3,7 +3,6 @@ module JobsJobsJobs.Views.Listing open Giraffe.ViewEngine -open Giraffe.ViewEngine.Htmx open JobsJobsJobs.Domain open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.ViewModels @@ -11,15 +10,14 @@ open JobsJobsJobs.ViewModels /// Job listing edit page let edit (m : EditListingForm) continents isNew csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText (if isNew then "Add a" else "Edit"); rawText " Job Listing" ] + pageWithTitle $"""{if isNew then "Add a" else "Edit"} Job Listing""" [ form [ _class "row g-3"; _method "POST"; _action "/listing/save" ] [ antiForgery csrf input [ _type "hidden"; _name (nameof m.Id); _value m.Id ] div [ _class "col-12 col-sm-10 col-md-8 col-lg-6" ] [ textBox [ _type "text"; _maxlength "255"; _autofocus ] (nameof m.Title) m.Title "Title" true div [ _class "form-text" ] [ - rawText "No need to put location here; it will always be show to seekers with continent and region" + txt "No need to put location here; it will always be show to seekers with continent and region" ] ] div [ _class "col-12 col-sm-6 col-md-4" ] [ @@ -27,7 +25,7 @@ let edit (m : EditListingForm) continents isNew csrf = ] div [ _class "col-12 col-sm-6 col-md-8" ] [ textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true - div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ] + div [ _class "form-text" ] [ txt "Country, state, geographic area, etc." ] ] div [ _class "col-12" ] [ checkBox [] (nameof m.RemoteWork) m.RemoteWork "This opportunity is for remote work" @@ -36,21 +34,19 @@ let edit (m : EditListingForm) continents isNew csrf = div [ _class "col-12 col-md-4" ] [ textBox [ _type "date" ] (nameof m.NeededBy) m.NeededBy "Needed By" false ] - div [ _class "col-12" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" - ] - ] + div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ] ] ] +open System.Net + +/// Page to expire a job listing let expire (m : ExpireListingForm) (listing : Listing) csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Expire Job Listing ("; str listing.Title; rawText ")" ] + pageWithTitle $"Expire Job Listing ({WebUtility.HtmlEncode listing.Title})" [ p [ _class "fst-italic" ] [ - rawText "Expiring this listing will remove it from search results. You will be able to see it via your " - rawText "“My Job Listings” page, but you will not be able to “un-expire” it." + txt "Expiring this listing will remove it from search results. You will be able to see it via your " + txt "“My Job Listings” page, but you will not be able to “un-expire” it." ] form [ _class "row g-3"; _method "POST"; _action "/listing/expire" ] [ antiForgery csrf @@ -61,16 +57,12 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf = ] div [ _class "col-12"; _id "successRow" ] [ p [] [ - rawText "Consider telling your fellow citizens about your experience! Comments entered here will " - rawText "be visible to logged-on users here, but not to the general public." + txt "Consider telling your fellow citizens about your experience! Comments entered here will be " + txt "visible to logged-on users here, but not to the general public." ] ] markdownEditor [] (nameof m.SuccessStory) m.SuccessStory "Your Success Story" - div [ _class "col-12" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-text-box-remove-outline" ] []; rawText "  Expire Listing" - ] - ] + div [ _class "col-12" ] [ submitButton "text-box-remove-outline" "Expire Listing" ] ] jsOnLoad "jjj.listing.toggleFromHere()" ] @@ -80,17 +72,15 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf = let mine (listings : ListingForView list) tz = let active = listings |> List.filter (fun it -> not it.Listing.IsExpired) let expired = listings |> List.filter (fun it -> it.Listing.IsExpired) - article [] [ - h3 [ _class "pb-3" ] [ rawText "My Job Listings" ] - p [] [ a [ _href "/listing/new/edit"; _class "btn btn-outline-primary" ] [ rawText "Add a New Job Listing" ] ] - if not (List.isEmpty expired) then h4 [ _class "pb-2" ] [ rawText "Active Job Listings" ] - if List.isEmpty active then - p [ _class "pb-3 fst-italic" ] [ rawText "You have no active job listings" ] + pageWithTitle "My Job Listings" [ + p [] [ a [ _href "/listing/new/edit"; _class "btn btn-outline-primary" ] [ txt "Add a New Job Listing" ] ] + if not (List.isEmpty expired) then h4 [ _class "pb-2" ] [ txt "Active Job Listings" ] + if List.isEmpty active then p [ _class "pb-3 fst-italic" ] [ txt "You have no active job listings" ] else table [ _class "pb-3 table table-sm table-hover pt-3" ] [ thead [] [ [ "Action"; "Title"; "Continent / Region"; "Created"; "Updated" ] - |> List.map (fun it -> th [ _scope "col" ] [ rawText it ]) + |> List.map (fun it -> th [ _scope "col" ] [ txt it ]) |> tr [] ] active @@ -98,9 +88,9 @@ let mine (listings : ListingForView list) tz = let listId = ListingId.toString it.Listing.Id tr [] [ td [] [ - a [ _href $"/listing/{listId}/edit" ] [ rawText "Edit" ]; rawText " ~ " - a [ _href $"/listing/{listId}/view" ] [ rawText "View" ]; rawText " ~ " - a [ _href $"/listing/{listId}/expire" ] [ rawText "Expire" ] + a [ _href $"/listing/{listId}/edit" ] [ txt "Edit" ]; txt " ~ " + a [ _href $"/listing/{listId}/view" ] [ txt "View" ]; txt " ~ " + a [ _href $"/listing/{listId}/expire" ] [ txt "Expire" ] ] td [] [ str it.Listing.Title ] td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ] @@ -110,17 +100,17 @@ let mine (listings : ListingForView list) tz = |> tbody [] ] if not (List.isEmpty expired) then - h4 [ _class "pb-2" ] [ rawText "Expired Job Listings" ] + h4 [ _class "pb-2" ] [ txt "Expired Job Listings" ] table [ _class "table table-sm table-hover pt-3" ] [ thead [] [ [ "Action"; "Title"; "Filled Here?"; "Expired" ] - |> List.map (fun it -> th [ _scope "col" ] [ rawText it ]) + |> List.map (fun it -> th [ _scope "col" ] [ txt it ]) |> tr [] ] expired |> List.map (fun it -> tr [] [ - td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [rawText "View" ] ] + td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [ txt "View" ] ] td [] [ str it.Listing.Title ] td [] [ str (yesOrNo (defaultArg it.Listing.WasFilledHere false)) ] td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ] @@ -136,12 +126,11 @@ let private neededBy dt = (LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format dt let search (m : ListingSearchForm) continents (listings : ListingForView list option) = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Help Wanted" ] + pageWithTitle "Help Wanted" [ if Option.isNone listings then p [] [ - rawText "Enter relevant criteria to find results, or just click “Search” to see all " - rawText "current job listings." + txt "Enter relevant criteria to find results, or just click “Search” to see all active job " + txt "listings." ] collapsePanel "Search Criteria" [ form [ _class "container"; _method "GET"; _action "/help-wanted" ] [ @@ -152,61 +141,61 @@ let search (m : ListingSearchForm) continents (listings : ListingForView list op ] div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [ textBox [ _maxlength "1000" ] (nameof m.Region) m.Region "Region" false - div [ _class "form-text" ] [ rawText "(free-form text)" ] + div [ _class "form-text" ] [ txt "(free-form text)" ] ] div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [ - label [ _class "jjj-label" ] [ rawText "Seeking Remote Work?" ]; br [] + label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br [] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value "" _class "form-check-input"; if m.RemoteWork = "" then _checked ] - label [ _class "form-check-label"; _for "remoteNull" ] [ rawText "No Selection" ] + label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ] ] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes" _class "form-check-input"; if m.RemoteWork = "yes" then _checked ] - label [ _class "form-check-label"; _for "remoteYes" ] [ rawText "Yes" ] + label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ] ] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no" _class "form-check-input"; if m.RemoteWork = "no" then _checked ] - label [ _class "form-check-label"; _for "remoteNo" ] [ rawText "No" ] + label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ] ] ] div [ _class "col-12 col-sm-6 col-lg-3" ] [ textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Job Listing Text" false - div [ _class "form-text" ] [ rawText "(free-form text)" ] + div [ _class "form-text" ] [ txt "(free-form text)" ] ] ] div [ _class "row" ] [ div [ _class "col" ] [ br [] - button [ _type "submit"; _class "btn btn-outline-primary" ] [ rawText "Search" ] + button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] ] ] ] ] match listings with | Some r when List.isEmpty r -> - p [ _class "pt-3" ] [ rawText "No job listings found for the specified criteria" ] + p [ _class "pt-3" ] [ txt "No job listings found for the specified criteria" ] | Some r -> table [ _class "table table-sm table-hover pt-3" ] [ thead [] [ tr [] [ - th [ _scope "col" ] [ rawText "Listing" ] - th [ _scope "col" ] [ rawText "Title" ] - th [ _scope "col" ] [ rawText "Location" ] - th [ _scope "col"; _class "text-center" ] [ rawText "Remote?" ] - th [ _scope "col"; _class "text-center" ] [ rawText "Needed By" ] + th [ _scope "col" ] [ txt "Listing" ] + th [ _scope "col" ] [ txt "Title" ] + th [ _scope "col" ] [ txt "Location" ] + th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] + th [ _scope "col"; _class "text-center" ] [ txt "Needed By" ] ] ] r |> List.map (fun it -> tr [] [ - td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [ rawText "View" ] ] + td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [ txt "View" ] ] td [] [ str it.Listing.Title ] td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ] td [ _class "text-center" ] [ str (yesOrNo it.Listing.IsRemote) ] td [ _class "text-center" ] [ - match it.Listing.NeededBy with Some needed -> str (neededBy needed) | None -> rawText "N/A" + match it.Listing.NeededBy with Some needed -> str (neededBy needed) | None -> txt "N/A" ] ]) |> tbody [] @@ -221,20 +210,18 @@ let view (it : ListingForView) = str it.Listing.Title if it.Listing.IsExpired then span [ _class "jjj-heading-label" ] [ - rawText "     "; span [ _class "badge bg-warning text-dark" ] [ rawText "Expired" ] + txt "     "; span [ _class "badge bg-warning text-dark" ] [ txt "Expired" ] if defaultArg it.Listing.WasFilledHere false then - rawText "     " - span [ _class "badge bg-success" ] [ rawText "Filled via Jobs, Jobs, Jobs" ] + txt "     "; span [ _class "badge bg-success" ] [ txt "Filled via Jobs, Jobs, Jobs" ] ] ] h4 [ _class "pb-3 text-muted" ] [ str it.ContinentName; rawText " / "; str it.Listing.Region ] p [] [ match it.Listing.NeededBy with | Some needed -> - strong [] [ em [] [ rawText "NEEDED BY "; str ((neededBy needed).ToUpperInvariant ()) ] ] - rawText " • " + strong [] [ em [] [ txt "NEEDED BY "; str ((neededBy needed).ToUpperInvariant ()) ] ]; txt " • " | None -> () - rawText "Listed by "; strong [ _class "me-4" ] [ str (Citizen.name it.Citizen) ]; br [] + txt "Listed by "; strong [ _class "me-4" ] [ str (Citizen.name it.Citizen) ]; br [] span [ _class "ms-3" ] []; yield! contactInfo it.Citizen false ] hr [] diff --git a/src/JobsJobsJobs/Server/Views/Profile.fs b/src/JobsJobsJobs/Server/Views/Profile.fs index 1a47abc..4703821 100644 --- a/src/JobsJobsJobs/Server/Views/Profile.fs +++ b/src/JobsJobsJobs/Server/Views/Profile.fs @@ -14,29 +14,25 @@ let skillEdit (skills : SkillForm array) = div [ _id $"skillRow{idx}"; _class "row pb-3" ] [ div [ _class "col-2 col-md-1 align-self-center" ] [ button [ _class "btn btn-sm btn-outline-danger rounded-pill"; _title "Delete" - _onclick $"jjj.profile.removeSkill(idx)" ] [ - rawText " − " - ] + _onclick $"jjj.profile.removeSkill(idx)" ] [ txt " − " ] ] div [ _class "col-10 col-md-6" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _id $"skillDesc{idx}"; _name $"Skills[{idx}].Description" _class "form-control"; _placeholder "A skill (language, design technique, process, etc.)" _maxlength "200"; _value skill.Description; _required ] - label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ rawText "Skill" ] + label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ txt "Skill" ] ] - if idx < 1 then - div [ _class "form-text" ] [ rawText "A skill (language, design technique, process, etc.)" ] + if idx < 1 then div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ] ] div [ _class "col-12 col-md-5" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control" _maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)" _value skill.Notes ] - label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ rawText "Notes" ] + label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ txt "Notes" ] ] - if idx < 1 then - div [ _class "form-text" ] [ rawText "A further description of the skill" ] + if idx < 1 then div [ _class "form-text" ] [ txt "A further description of the skill" ] ] ] template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = "" } ] @@ -44,18 +40,15 @@ let skillEdit (skills : SkillForm array) = /// The profile edit page let edit (m : EditProfileViewModel) continents isNew citizenId csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ] + pageWithTitle "My Employment Profile" [ form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [ antiForgery csrf div [ _class "col-12" ] [ checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment" if m.IsSeekingEmployment then - p [] [ - em [] [ - rawText "If you have found employment, consider " - a [ _href "/success-story/new/edit" ] [ rawText "telling your fellow citizens about it!" ] - ] + p [ _class "fst-italic " ] [ + txt "If you have found employment, consider " + a [ _href "/success-story/new/edit" ] [ txt "telling your fellow citizens about it!" ] ] ] div [ _class "col-12 col-sm-6 col-md-4" ] [ @@ -63,7 +56,7 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = ] div [ _class "col-12 col-sm-6 col-md-8" ] [ textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true - div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ] + div [ _class "form-text" ] [ txt "Country, state, geographic area, etc." ] ] markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography" div [ _class "col-12 col-offset-md-2 col-md-4" ] [ @@ -75,22 +68,19 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = div [ _class "col-12" ] [ hr [] h4 [ _class "pb-2" ] [ - rawText "Skills   " + txt "Skills   " button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill" - _onclick "jjj.profile.addSkill()" ] [ - rawText "Add a Skill" - ] + _onclick "jjj.profile.addSkill()" ] [ txt "Add a Skill" ] ] ] yield! skillEdit m.Skills div [ _class "col-12" ] [ hr [] - h4 [] [ rawText "Experience" ] + h4 [] [ txt "Experience" ] p [] [ - rawText "This application does not have a place to individually list your chronological job " - rawText "history; however, you can use this area to list prior jobs, their dates, and anything " - rawText "else you want to include that’s not already a part of your Professional Biography " - rawText "above." + txt "This application does not have a place to individually list your chronological job history; " + txt "however, you can use this area to list prior jobs, their dates, and anything else you want to " + txt "include that’s not already a part of your Professional Biography above." ] ] markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" @@ -103,21 +93,19 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = "Show my profile to anyone who has the direct link to it" ] div [ _class "col-12" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" - ] + submitButton "content-save-outline" "Save" if not isNew then - rawText "    " + txt "    " a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [ i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] [] - rawText "  View Your User Profile" + txt "  View Your User Profile" ] ] ] hr [] p [ _class "text-muted fst-italic" ] [ - rawText "(If you want to delete your profile, or your entire account, " - a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)" + txt "(If you want to delete your profile, or your entire account, " + a [ _href "/citizen/so-long" ] [ txt "see your deletion options here" ]; txt ".)" ] jsOnLoad $"jjj.profile.nextIndex = {m.Skills.Length}" ] @@ -125,12 +113,11 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = /// The public search page let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) = - article [] [ - h3 [ _class "pb-3" ] [ rawText "People Seeking Work" ] + pageWithTitle "People Seeking Work" [ if Option.isNone results then p [] [ - rawText "Enter one or more criteria to filter results, or just click “Search” to list all " - rawText "publicly searchable profiles." + txt "Enter one or more criteria to filter results, or just click “Search” to list all " + txt "publicly searchable profiles." ] collapsePanel "Search Criteria" [ form [ _class "container"; _method "GET"; _action "/profile/seeking" ] [ @@ -141,62 +128,62 @@ let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult ] div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [ textBox [ _maxlength "1000" ] (nameof m.Region) m.Region "Region" false - div [ _class "form-text" ] [ rawText "(free-form text)" ] + div [ _class "form-text" ] [ txt "(free-form text)" ] ] div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [ - label [ _class "jjj-label" ] [ rawText "Seeking Remote Work?" ]; br [] + label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br [] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value "" _class "form-check-input"; if m.RemoteWork = "" then _checked ] - label [ _class "form-check-label"; _for "remoteNull" ] [ rawText "No Selection" ] + label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ] ] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes" _class "form-check-input"; if m.RemoteWork = "yes" then _checked ] - label [ _class "form-check-label"; _for "remoteYes" ] [ rawText "Yes" ] + label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ] ] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no" _class "form-check-input"; if m.RemoteWork = "no" then _checked ] - label [ _class "form-check-label"; _for "remoteNo" ] [ rawText "No" ] + label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ] ] ] div [ _class "col-12 col-sm-6 col-lg-3" ] [ textBox [ _maxlength "1000" ] (nameof m.Skill) m.Skill "Skill" false - div [ _class "form-text" ] [ rawText "(free-form text)" ] + div [ _class "form-text" ] [ txt "(free-form text)" ] ] ] div [ _class "row" ] [ div [ _class "col" ] [ br [] - button [ _type "submit"; _class "btn btn-outline-primary" ] [ rawText "Search" ] + button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] ] ] ] ] match results with - | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ rawText "No results found for the specified criteria" ] + | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ] | Some r -> p [ _class "py-3" ] [ - rawText "These profiles match your search criteria. To learn more about these people, join the merry " - rawText "band of human resources in the " - a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ rawText "No Agenda" ] - rawText " tribe!" + txt "These profiles match your search criteria. To learn more about these people, join the merry band " + txt "of human resources in the " + a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ txt "No Agenda" ] + txt " tribe!" ] table [ _class "table table-sm table-hover" ] [ thead [] [ tr [] [ - th [ _scope "col" ] [ rawText "Continent" ] - th [ _scope "col"; _class "text-center" ] [ rawText "Region" ] - th [ _scope "col"; _class "text-center" ] [ rawText "Remote?" ] - th [ _scope "col"; _class "text-center" ] [ rawText "Skills" ] + th [ _scope "col" ] [ txt "Continent" ] + th [ _scope "col"; _class "text-center" ] [ txt "Region" ] + th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] + th [ _scope "col"; _class "text-center" ] [ txt "Skills" ] ] ] r |> List.map (fun profile -> tr [] [ td [] [ str profile.Continent ] td [] [ str profile.Region ] - td [ _class "text-center" ] [ rawText (yesOrNo profile.RemoteWork) ] + td [ _class "text-center" ] [ txt (yesOrNo profile.RemoteWork) ] profile.Skills |> List.collect (fun skill -> [ str skill; br [] ]) |> td [] @@ -209,12 +196,11 @@ let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult /// Logged-on search page let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult list option) = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Search Profiles" ] + pageWithTitle "Search Profiles" [ if Option.isNone results then p [] [ - rawText "Enter one or more criteria to filter results, or just click “Search” to list all " - rawText "profiles." + txt "Enter one or more criteria to filter results, or just click “Search” to list all " + txt "profiles." ] collapsePanel "Search Criteria" [ form [ _class "container"; _method "GET"; _action "/profile/search" ] [ @@ -224,63 +210,63 @@ let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult continentList [] "ContinentId" continents (Some "Any") m.ContinentId false ] div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [ - label [ _class "jjj-label" ] [ rawText "Seeking Remote Work?" ]; br [] + label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br [] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value "" _class "form-check-input"; if m.RemoteWork = "" then _checked ] - label [ _class "form-check-label"; _for "remoteNull" ] [ rawText "No Selection" ] + label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ] ] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes" _class "form-check-input"; if m.RemoteWork = "yes" then _checked ] - label [ _class "form-check-label"; _for "remoteYes" ] [ rawText "Yes" ] + label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ] ] div [ _class "form-check form-check-inline" ] [ input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no" _class "form-check-input"; if m.RemoteWork = "no" then _checked ] - label [ _class "form-check-label"; _for "remoteNo" ] [ rawText "No" ] + label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ] ] ] div [ _class "col-12 col-sm-6 col-lg-3" ] [ textBox [ _maxlength "1000" ] (nameof m.Skill) m.Skill "Skill" false - div [ _class "form-text" ] [ rawText "(free-form text)" ] + div [ _class "form-text" ] [ txt "(free-form text)" ] ] div [ _class "col-12 col-sm-6 col-lg-3" ] [ textBox [ _maxlength "1000" ] (nameof m.BioExperience) m.BioExperience "Bio / Experience" false - div [ _class "form-text" ] [ rawText "(free-form text)" ] + div [ _class "form-text" ] [ txt "(free-form text)" ] ] ] div [ _class "row" ] [ div [ _class "col" ] [ br [] - button [ _type "submit"; _class "btn btn-outline-primary" ] [ rawText "Search" ] + button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] ] ] ] ] match results with - | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ rawText "No results found for the specified criteria" ] + | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ] | Some r -> // Bootstrap utility classes to only show at medium or above let isWide = "d-none d-md-table-cell" table [ _class "table table-sm table-hover pt-3" ] [ thead [] [ tr [] [ - th [ _scope "col" ] [ rawText "Profile" ] - th [ _scope "col" ] [ rawText "Name" ] - th [ _scope "col"; _class $"{isWide} text-center" ] [ rawText "Seeking?" ] - th [ _scope "col"; _class "text-center" ] [ rawText "Remote?" ] - th [ _scope "col"; _class $"{isWide} text-center" ] [ rawText "Full-Time?" ] - th [ _scope "col"; _class isWide ] [ rawText "Last Updated" ] + th [ _scope "col" ] [ txt "Profile" ] + th [ _scope "col" ] [ txt "Name" ] + th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Seeking?" ] + th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] + th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Full-Time?" ] + th [ _scope "col"; _class isWide ] [ txt "Last Updated" ] ] ] r |> List.map (fun profile -> tr [] [ - td [] [ a [ _href $"/profile/{CitizenId.toString profile.CitizenId}/view" ] [ rawText "View" ] ] + td [] [ a [ _href $"/profile/{CitizenId.toString profile.CitizenId}/view" ] [ txt "View" ] ] td [ if profile.SeekingEmployment then _class "fw-bold" ] [ str profile.DisplayName ] - td [ _class $"{isWide} text-center" ] [ rawText (yesOrNo profile.SeekingEmployment) ] - td [ _class "text-center" ] [ rawText (yesOrNo profile.RemoteWork) ] - td [ _class $"{isWide} text-center" ] [ rawText (yesOrNo profile.FullTime) ] + td [ _class $"{isWide} text-center" ] [ txt (yesOrNo profile.SeekingEmployment) ] + td [ _class "text-center" ] [ txt (yesOrNo profile.RemoteWork) ] + td [ _class $"{isWide} text-center" ] [ txt (yesOrNo profile.FullTime) ] td [ _class isWide ] [ str (fullDate profile.LastUpdatedOn tz) ] ]) |> tbody [] @@ -296,38 +282,34 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) curren str (Citizen.name citizen) if profile.IsSeekingEmployment then span [ _class "jjj-heading-label" ] [ - rawText "   "; span [ _class "badge bg-dark" ] [ rawText "Currently Seeking Employment" ] + txt "   "; span [ _class "badge bg-dark" ] [ txt "Currently Seeking Employment" ] ] ] h4 [] [ str $"{continentName}, {profile.Region}" ] contactInfo citizen (Option.isNone currentId) |> div [ _class "pb-3" ] p [] [ - rawText (if profile.IsFullTime then "I" else "Not i"); rawText "nterested in full-time employment" - rawText " • " - rawText (if profile.IsRemote then "I" else "Not i"); rawText "nterested in remote opportunities" + txt (if profile.IsFullTime then "I" else "Not i"); txt "nterested in full-time employment • " + txt (if profile.IsRemote then "I" else "Not i"); txt "nterested in remote opportunities" ] hr [] div [] [ md2html profile.Biography ] if not (List.isEmpty profile.Skills) then hr [] - h4 [ _class "pb-3" ] [ rawText "Skills" ] + h4 [ _class "pb-3" ] [ txt "Skills" ] profile.Skills |> List.map (fun skill -> li [] [ str skill.Description - match skill.Notes with - | Some notes -> - rawText "  ("; str notes; rawText ")" - | None -> () + match skill.Notes with Some notes -> txt "  ("; str notes; txt ")" | None -> () ]) |> ul [] match profile.Experience with - | Some exp -> hr []; h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]; div [] [ md2html exp ] + | Some exp -> hr []; h4 [ _class "pb-3" ] [ txt "Experience / Employment History" ]; div [] [ md2html exp ] | None -> () if Option.isSome currentId && currentId.Value = citizen.Id then br []; br [] a [ _href "/profile/edit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-pencil" ] []; rawText "  Edit Your Profile" + i [ _class "mdi mdi-pencil" ] []; txt "  Edit Your Profile" ] ] diff --git a/src/JobsJobsJobs/Server/Views/Success.fs b/src/JobsJobsJobs/Server/Views/Success.fs index 8fce1ae..7dee224 100644 --- a/src/JobsJobsJobs/Server/Views/Success.fs +++ b/src/JobsJobsJobs/Server/Views/Success.fs @@ -9,13 +9,12 @@ open JobsJobsJobs.ViewModels /// The add/edit success story page let edit (m : EditSuccessForm) isNew pgTitle csrf = - article [] [ - h3 [ _class "pb-3" ] [ rawText pgTitle ] + pageWithTitle pgTitle [ if isNew then p [] [ - rawText "Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came " - rawText "about; tell us about it below! " - em [] [ rawText "(These will be visible to other users, but not to the general public.)" ] + txt "Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came " + txt "about; tell us about it below! " + em [] [ txt "(These will be visible to other users, but not to the general public.)" ] ] form [ _class "row g-3"; _method "POST"; _action "/success-story/save" ] [ antiForgery csrf @@ -25,13 +24,11 @@ let edit (m : EditSuccessForm) isNew pgTitle csrf = ] markdownEditor [] (nameof m.Story) m.Story "The Success Story" div [ _class "col-12" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ - i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" - ] + submitButton "content-save-outline" "Save" if isNew then p [ _class "fst-italic" ] [ - rawText "(Saving this will set “Seeking Employment” to “No” on your " - rawText "profile.)" + txt "(Saving this will set “Seeking Employment” to “No” on your " + txt "profile.)" ] ] ] @@ -40,28 +37,27 @@ let edit (m : EditSuccessForm) isNew pgTitle csrf = /// The list of success stories let list (m : StoryEntry list) citizenId tz = - article [] [ - h3 [ _class "pb-3" ] [ rawText "Success Stories" ] + pageWithTitle "Success Stories" [ if List.isEmpty m then - p [] [ rawText "There are no success stories recorded "; em [] [ rawText "(yet)" ] ] + p [] [ txt "There are no success stories recorded "; em [] [ txt "(yet)" ] ] else table [ _class "table table-sm table-hover" ] [ thead [] [ [ "Story"; "From"; "Found Here?"; "Recorded On" ] - |> List.map (fun it -> th [ _scope "col" ] [ rawText it ]) + |> List.map (fun it -> th [ _scope "col" ] [ txt it ]) |> tr [] ] m |> List.map (fun story -> tr [] [ td [] [ let theId = SuccessId.toString story.Id - if story.HasStory then a [ _href $"/success-story/{theId}/view" ] [ rawText "View" ] - else em [] [ rawText "None" ] + if story.HasStory then a [ _href $"/success-story/{theId}/view" ] [ txt "View" ] + else em [] [ txt "None" ] if story.CitizenId = citizenId then - rawText " ~ "; a [ _href $"/success-story/{theId}/edit" ] [ rawText "Edit" ] + txt " ~ "; a [ _href $"/success-story/{theId}/edit" ] [ txt "Edit" ] ] td [] [ str story.CitizenName ] - td [] [ if story.FromHere then strong [] [ rawText "Yes" ] else rawText "No" ] + td [] [ if story.FromHere then strong [] [ txt "Yes" ] else txt "No" ] td [] [ str (fullDate story.RecordedOn tz) ] ]) |> tbody [] @@ -73,14 +69,13 @@ let list (m : StoryEntry list) citizenId tz = let view (it : Success) citizenName tz = article [] [ h3 [] [ - str citizenName; rawText "’s Success Story" + str citizenName; txt "’s Success Story" if it.IsFromHere then span [ _class "jjj-heading-label" ] [ - rawText "     " + txt "     " span [ _class "badge bg-success" ] [ - rawText "Via " - rawText (if it.Source = "profile" then "employment profile" else "job listing") - rawText " on Jobs, Jobs, Jobs" + txt "Via "; txt (if it.Source = "profile" then "employment profile" else "job listing") + txt " on Jobs, Jobs, Jobs" ] ] ]