Version 3 #40
@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3">Account Confirmation</h3>
|
||||
<load-data :load="confirmToken">
|
||||
<p v-if="isConfirmed">
|
||||
Your account was confirmed successfully! You may <router-link to="/citizen/log-on">log on here</router-link>.
|
||||
</p>
|
||||
<p v-else>
|
||||
The confirmation token did not match any pending accounts. Confirmation tokens are only valid for 3 days; if
|
||||
the token expired, you will need to re-register,
|
||||
which <router-link to="/citizen/register">you can do here</router-link>.
|
||||
</p>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
import api from "@/api"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** Whether the account was confirmed */
|
||||
const isConfirmed = ref(false)
|
||||
|
||||
/** Confirm the account via the token */
|
||||
const confirmToken = async (errors: string[]) => {
|
||||
const resp = await api.citizen.confirmToken(route.params.token as string)
|
||||
if (typeof resp === "string") {
|
||||
errors.push(resp)
|
||||
} else {
|
||||
isConfirmed.value = resp
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<article class="container">
|
||||
<h3 class="pb-4">ITM, {{user.name}}!</h3>
|
||||
<load-data :load="retrieveData">
|
||||
<div class="row row-cols-1 row-cols-md-2">
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header">Your Profile</h5>
|
||||
<div class="card-body">
|
||||
<h6 v-if="profile" class="card-subtitle mb-3 text-muted fst-italic">
|
||||
Last updated <full-date-time :date="profile.lastUpdatedOn" />
|
||||
</h6>
|
||||
<p v-if="profile" class="card-text">
|
||||
Your profile currently lists {{profile.skills.length}}
|
||||
skill<template v-if="profile.skills.length !== 1">s</template>.
|
||||
<span v-if="profile.seekingEmployment">
|
||||
<br><br>
|
||||
Your profile indicates that you are seeking employment. Once you find it,
|
||||
<router-link to="/success-story/add">tell your fellow citizens about it!</router-link>
|
||||
</span>
|
||||
</p>
|
||||
<p class="card-text" v-else>
|
||||
You do not have an employment profile established; click below (or “Edit Profile” in the
|
||||
menu) to get started!
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<template v-if="profile">
|
||||
<router-link class="btn btn-outline-secondary"
|
||||
:to="`/profile/${user.citizenId}/view`">View Profile</router-link>
|
||||
|
||||
<router-link class="btn btn-outline-secondary" to="/profile/edit">Edit Profile</router-link>
|
||||
</template>
|
||||
<router-link class="btn btn-primary" v-else to="/profile/edit">Create Profile</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header">Other Citizens</h5>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-3 text-muted fst-italic">
|
||||
<template v-if="profileCount === 0">No </template>
|
||||
<template v-else>{{profileCount}} Total </template>
|
||||
Employment Profile<template v-if="profileCount !== 1">s</template>
|
||||
</h6>
|
||||
<p v-if="profileCount === 1 && profile" class="card-text">
|
||||
It looks like, for now, it’s just you…
|
||||
</p>
|
||||
<p v-else-if="profileCount > 0" class="card-text">
|
||||
Take a look around and see if you can help them find work!
|
||||
</p>
|
||||
<p v-else class="card-text">
|
||||
You can click below, but you will not find anything…
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<router-link class="btn btn-outline-secondary" to="/profile/search">Search Profiles</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</load-data>
|
||||
<p> </p>
|
||||
<p>
|
||||
To see how this application works, check out “How It Works” in the sidebar (last updated August
|
||||
29<sup>th</sup>, 2021).
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref } from "vue"
|
||||
import api, { LogOnSuccess, Profile } from "@/api"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import FullDateTime from "@/components/FullDateTime.vue"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
|
||||
/** The currently logged-in user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** The user's profile */
|
||||
const profile : Ref<Profile | undefined> = ref(undefined)
|
||||
|
||||
/** A count of profiles in the system */
|
||||
const profileCount = ref(0)
|
||||
|
||||
const retrieveData = async (errors : string[]) => {
|
||||
const profileResult = await api.profile.retreive(undefined, user)
|
||||
if (typeof profileResult === "string") {
|
||||
errors.push(profileResult)
|
||||
} else if (typeof profileResult !== "undefined") {
|
||||
profile.value = profileResult
|
||||
}
|
||||
const count = await api.profile.count(user)
|
||||
if (typeof count === "string") {
|
||||
errors.push(count)
|
||||
} else {
|
||||
profileCount.value = count
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3">Account Deletion</h3>
|
||||
<load-data :load="denyAccount">
|
||||
<p v-if="isDeleted">
|
||||
The account was deleted successfully; sorry for the trouble.
|
||||
</p>
|
||||
<p v-else>
|
||||
The confirmation token did not match any pending accounts; if this was an inadvertently created account, it has
|
||||
likely already been deleted.
|
||||
</p>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
import api from "@/api"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** Whether the account was deleted */
|
||||
const isDeleted = ref(false)
|
||||
|
||||
/** Deny the account after confirming the token */
|
||||
const denyAccount = async (errors: string[]) => {
|
||||
const resp = await api.citizen.denyAccount(route.params.token as string)
|
||||
if (typeof resp === "string") {
|
||||
errors.push(resp)
|
||||
} else {
|
||||
isDeleted.value = resp
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<p> </p>
|
||||
<p class="fst-italic">Logging off…</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { useStore, Mutations } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
store.commit(Mutations.ClearUser)
|
||||
toastSuccess("Log Off Successful | <strong>Have a Nice Day!</strong>")
|
||||
await router.push("/")
|
||||
})
|
||||
</script>
|
@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3">Log On</h3>
|
||||
<p v-if="message !== ''" class="pb-3 text-center">
|
||||
<span class="text-danger">{{message}}</span><br>
|
||||
<template v-if="message.indexOf('ocked') > -1">
|
||||
If this is a new account, it must be confirmed before it can be used; otherwise, you need to
|
||||
<router-link to="/citizen/forgot-password">request an unlock code</router-link> before you may log on.
|
||||
</template>
|
||||
</p>
|
||||
<form class="row g-3 pb-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-floating">
|
||||
<div class="form-floating">
|
||||
<input type="email" id="email" :class="{ 'form-control': true, 'is-invalid': v$.email.$error }"
|
||||
v-model="v$.email.$model" placeholder="E-mail Address" autofocus>
|
||||
<div class="invalid-feedback">Please enter a valid e-mail address</div>
|
||||
<label class="jjj-required" for="email">E-mail Address</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-floating">
|
||||
<input type="password" id="password" :class="{ 'form-control': true, 'is-invalid': v$.password.$error }"
|
||||
v-model="v$.password.$model" placeholder="Password">
|
||||
<div class="invalid-feedback">Please enter a password</div>
|
||||
<label class="jjj-required" for="password">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p class="text-danger" v-if="v$.$error">Please correct the errors above</p>
|
||||
<button class="btn btn-primary" @click.prevent="logOn" :disabled="!logOnEnabled">
|
||||
<icon :icon="mdiLogin" /> Log On
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="text-center">Need an account? <router-link to="/citizen/register">Register for one!</router-link></p>
|
||||
<p class="text-center">
|
||||
Forgot your password? <router-link to="/citizen/forgot-password">Request a reset.</router-link>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { mdiLogin } from "@mdi/js"
|
||||
import useVuelidate from "@vuelidate/core"
|
||||
import { email, required } from "@vuelidate/validators"
|
||||
|
||||
import { LogOnForm } from "@/api"
|
||||
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { AFTER_LOG_ON_URL } from "@/router"
|
||||
import { useStore, Actions } from "@/store"
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
/** The form to log on to Jobs, Jobs, Jobs */
|
||||
const logOnForm = reactive(new LogOnForm())
|
||||
|
||||
/** Whether the log on button is enabled */
|
||||
const logOnEnabled = ref(true)
|
||||
|
||||
/** The message returned from the log on attempt */
|
||||
const message = computed(() => store.state.logOnState)
|
||||
|
||||
/** Validation rules for the log on form */
|
||||
const rules = computed(() => ({
|
||||
email: { required, email },
|
||||
password: { required }
|
||||
}))
|
||||
|
||||
/** Form and validation */
|
||||
const v$ = useVuelidate(rules, logOnForm, { $lazy: true })
|
||||
|
||||
/** Log the citizen on */
|
||||
const logOn = async () => {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$error) return
|
||||
logOnEnabled.value = false
|
||||
await store.dispatch(Actions.LogOn, { form: logOnForm })
|
||||
logOnEnabled.value = true
|
||||
if (store.state.user !== undefined) {
|
||||
toastSuccess("Log On Successful")
|
||||
v$.value.$reset()
|
||||
const nextUrl = window.localStorage.getItem(AFTER_LOG_ON_URL) ?? "/citizen/dashboard"
|
||||
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
||||
await router.push(nextUrl)
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3">Register</h3>
|
||||
<form class="row g-3" novalidate>
|
||||
<div class="col-6 col-xl-4">
|
||||
<div class="form-floating has-validation">
|
||||
<input type="text" id="firstName" :class="{ 'form-control': true, 'is-invalid': v$.firstName.$error }"
|
||||
v-model="v$.firstName.$model" placeholder="First Name">
|
||||
<div class="invalid-feedback">Please enter your first name</div>
|
||||
<label class="jjj-required" for="firstName">First Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-4">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="lastName" :class="{ 'form-control': true, 'is-invalid': v$.lastName.$error }"
|
||||
v-model="v$.lastName.$model" placeholder="Last Name">
|
||||
<div class="invalid-feedback">Please enter your last name</div>
|
||||
<label class="jjj-required" for="firstName">Last Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-4">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="displayName" class="form-control" v-model="v$.displayName.$model"
|
||||
placeholder="Display Name">
|
||||
<label for="displayName">Display Name</label>
|
||||
<div class="form-text"><em>Optional; overrides first/last for display</em></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-4">
|
||||
<div class="form-floating">
|
||||
<input type="email" id="email" :class="{ 'form-control': true, 'is-invalid': v$.email.$error }"
|
||||
v-model="v$.email.$model" placeholder="E-mail Address">
|
||||
<div class="invalid-feedback">Please enter a valid e-mail address</div>
|
||||
<label class="jjj-required" for="email">E-mail Address</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-4">
|
||||
<div class="form-floating">
|
||||
<input type="password" id="password" :class="{ 'form-control': true, 'is-invalid': v$.password.$error }"
|
||||
v-model="v$.password.$model" placeholder="Password">
|
||||
<div class="invalid-feedback">Please enter a password at least 8 characters long</div>
|
||||
<label class="jjj-required" for="password">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-4">
|
||||
<div class="form-floating">
|
||||
<input type="password" id="confirmPassword"
|
||||
:class="{ 'form-control': true, 'is-invalid': v$.confirmPassword.$error }"
|
||||
v-model="v$.confirmPassword.$model" placeholder="Confirm Password">
|
||||
<div class="invalid-feedback">The passwords do not match</div>
|
||||
<label class="jjj-required" for="confirmPassword">Confirm Password</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p class="text-danger" v-if="v$.$error">Please correct the errors above</p>
|
||||
<button class="btn btn-primary" @click.prevent="saveProfile">
|
||||
<icon :icon="mdiContentSaveOutline" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { mdiContentSaveOutline } from "@mdi/js"
|
||||
import useVuelidate from "@vuelidate/core"
|
||||
import { email, minLength, required, sameAs } from "@vuelidate/validators"
|
||||
|
||||
import api, { CitizenRegistrationForm } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** The information required to register a user */
|
||||
const regForm = reactive(new CitizenRegistrationForm())
|
||||
|
||||
/** The validation rules for the form */
|
||||
const rules = computed(() => ({
|
||||
firstName: { required },
|
||||
lastName: { required },
|
||||
email: { required, email },
|
||||
displayName: { },
|
||||
password: { required, length: minLength(8) },
|
||||
confirmPassword: { required, matchPassword: sameAs(regForm.password) }
|
||||
}))
|
||||
|
||||
/** Initialize form validation */
|
||||
const v$ = useVuelidate(rules, regForm, { $lazy: true })
|
||||
|
||||
/** Register the citizen */
|
||||
const saveProfile = async () => {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$error) return
|
||||
const registerResult = await api.citizen.register(regForm)
|
||||
if (typeof registerResult === "string") {
|
||||
toastError(registerResult, "registering")
|
||||
} else {
|
||||
toastSuccess("Registered Successfully")
|
||||
v$.value.$reset()
|
||||
await router.push("/citizen/registered")
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3">Registration Successful</h3>
|
||||
<p>
|
||||
You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation link; it will
|
||||
be valid for the next 72 hours (3 days). Once you confirm your account, you will be able to log on using the
|
||||
e-mail address and password you provided.
|
||||
</p>
|
||||
<p>
|
||||
If the account is not confirmed within the 72-hour window, it will be deleted, and you will need to register
|
||||
again.
|
||||
</p>
|
||||
<p>If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance.</p>
|
||||
</article>
|
||||
</template>
|
@ -54,7 +54,7 @@ module Error =
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler = fun next ctx ->
|
||||
if ctx.Request.Method = "GET" then
|
||||
let redirectUrl = $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
|
||||
let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
|
||||
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx
|
||||
else redirectTo false redirectUrl next ctx
|
||||
else
|
||||
@ -256,7 +256,7 @@ module Citizen =
|
||||
|
||||
// GET: /citizen/dashboard
|
||||
let dashboard = requireUser >=> fun next ctx -> task {
|
||||
let citizenId = CitizenId.ofString (tryUser ctx).Value
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! citizen = Citizens.findById citizenId
|
||||
let! profile = Profiles.findById citizenId
|
||||
let! prfCount = Profiles.count ()
|
||||
@ -561,10 +561,25 @@ module Listing =
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/profile routes
|
||||
/// Handlers for /profile routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
// GET: /profile/edit
|
||||
let edit = requireUser >=> fun next ctx -> task {
|
||||
let! profile = Profiles.findById (currentCitizenId ctx)
|
||||
let! continents = Continents.all ()
|
||||
let isNew = Option.isNone profile
|
||||
let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value
|
||||
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
|
||||
return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/profile routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module ProfileApi =
|
||||
|
||||
// GET: /api/profile
|
||||
// This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
|
||||
// is not an error). The "get" handler returns a 404 if a profile is not found.
|
||||
@ -724,7 +739,13 @@ let allEndpoints = [
|
||||
]
|
||||
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
|
||||
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
route "/edit" Profile.edit
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
|
||||
|
||||
subRoute "/api" [
|
||||
subRoute "/citizen" [
|
||||
GET_HEAD [ routef "/%O" CitizenApi.get ]
|
||||
@ -749,15 +770,15 @@ let allEndpoints = [
|
||||
]
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
route "" Profile.current
|
||||
route "/count" Profile.count
|
||||
routef "/%O" Profile.get
|
||||
routef "/%O/view" Profile.view
|
||||
route "/public-search" Profile.publicSearch
|
||||
route "/search" Profile.search
|
||||
route "" ProfileApi.current
|
||||
route "/count" ProfileApi.count
|
||||
routef "/%O" ProfileApi.get
|
||||
routef "/%O/view" ProfileApi.view
|
||||
route "/public-search" ProfileApi.publicSearch
|
||||
route "/search" ProfileApi.search
|
||||
]
|
||||
PATCH [ route "/employment-found" Profile.employmentFound ]
|
||||
POST [ route "" Profile.save ]
|
||||
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
||||
POST [ route "" ProfileApi.save ]
|
||||
]
|
||||
subRoute "/success" [
|
||||
GET_HEAD [
|
||||
|
@ -15,6 +15,7 @@
|
||||
<Compile Include="Views\Layout.fs" />
|
||||
<Compile Include="Views\Citizen.fs" />
|
||||
<Compile Include="Views\Home.fs" />
|
||||
<Compile Include="Views\Profile.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="App.fs" />
|
||||
</ItemGroup>
|
||||
|
@ -1,6 +1,87 @@
|
||||
/// View models for Jobs, Jobs, Jobs
|
||||
module JobsJobsJobs.ViewModels
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// The fields required for a skill
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type SkillForm =
|
||||
{ /// The ID of this skill
|
||||
Id : string
|
||||
|
||||
/// The description of the skill
|
||||
Description : string
|
||||
|
||||
/// Notes regarding the skill
|
||||
Notes : string option
|
||||
}
|
||||
|
||||
/// The data required to update a profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditProfileViewModel =
|
||||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether this profile should appear in the public search
|
||||
IsPublic : bool
|
||||
|
||||
/// The ID of the continent on which the citizen is located
|
||||
ContinentId : string
|
||||
|
||||
/// The area within that continent where the citizen is located
|
||||
Region : string
|
||||
|
||||
/// If the citizen is available for remote work
|
||||
RemoteWork : bool
|
||||
|
||||
/// If the citizen is seeking full-time employment
|
||||
FullTime : bool
|
||||
|
||||
/// The user's professional biography
|
||||
Biography : string
|
||||
|
||||
/// The user's past experience
|
||||
Experience : string option
|
||||
|
||||
/// The skills for the user
|
||||
Skills : SkillForm list
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
module EditProfileViewModel =
|
||||
|
||||
/// An empty view model (used for new profiles)
|
||||
let empty =
|
||||
{ IsSeekingEmployment = false
|
||||
IsPublic = false
|
||||
ContinentId = ""
|
||||
Region = ""
|
||||
RemoteWork = false
|
||||
FullTime = false
|
||||
Biography = ""
|
||||
Experience = None
|
||||
Skills = [ { Id = ""; Description = ""; Notes = None } ]
|
||||
}
|
||||
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Profile) =
|
||||
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
||||
IsPublic = profile.IsPubliclySearchable
|
||||
ContinentId = string profile.ContinentId
|
||||
Region = profile.Region
|
||||
RemoteWork = profile.IsRemote
|
||||
FullTime = profile.IsFullTime
|
||||
Biography = MarkdownString.toString profile.Biography
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
Skills = profile.Skills
|
||||
|> List.map (fun s ->
|
||||
{ Id = string s.Id
|
||||
Description = s.Description
|
||||
Notes = s.Notes
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// View model for the log on page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LogOnViewModel =
|
||||
|
@ -2,7 +2,9 @@
|
||||
module JobsJobsJobs.Views.Common
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Create an audio clip with the specified text node
|
||||
let audioClip clip text =
|
||||
@ -13,3 +15,34 @@ let audioClip clip text =
|
||||
/// Create an anti-forgery hidden input
|
||||
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
||||
|
||||
/// Create a select list of continents
|
||||
let continentList attrs name (continents : Continent list) emptyLabel selectedValue =
|
||||
div [ _class "form-floating" ] [
|
||||
select (List.append attrs [ _id name; _name name; _class "form-select" ]) (
|
||||
option [ _value ""; if selectedValue = "" then _selected ] [
|
||||
rawText $"""– {defaultArg emptyLabel "Select"} –""" ]
|
||||
:: (continents
|
||||
|> List.map (fun c ->
|
||||
let theId = ContinentId.toString c.Id
|
||||
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
|
||||
label [ _class "jjj-required"; _for name ] [ rawText "Continent" ]
|
||||
]
|
||||
|
||||
/// Create a Markdown editor
|
||||
let markdownEditor attrs name value editorLabel =
|
||||
div [ _class "col-12" ] [
|
||||
nav [ _class "nav nav-pills pb-1" ] [
|
||||
button [ _type "button"; _class "jjj-md-source"; _onclick "jjj.showMarkdown('TODO')" ] [
|
||||
rawText "Markdown"
|
||||
]; rawText " "
|
||||
button [ _type "button"; _class "jjj-md-preview"; _onclick "jjj.showPreview('TODO')" ] [ rawText "Preview" ]
|
||||
]
|
||||
section [ _id $"{name}Preview"; _class "jjj-preview"; _ariaLabel "Rendered Markdown preview" ] []
|
||||
div [ _class "form-floating" ] [
|
||||
textarea (List.append attrs [ _id name; _name name; _class "form-control md-edit"; _rows "10" ]) [
|
||||
rawText value
|
||||
]
|
||||
label [ _for name ] [ rawText editorLabel ]
|
||||
]
|
||||
]
|
||||
|
110
src/JobsJobsJobs/Server/Views/Profile.fs
Normal file
110
src/JobsJobsJobs/Server/Views/Profile.fs
Normal file
@ -0,0 +1,110 @@
|
||||
/// Views for /profile URLs
|
||||
[<RequireQualifiedAccess>]
|
||||
module JobsJobsJobs.Views.Profile
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open JobsJobsJobs.ViewModels
|
||||
|
||||
/// The profile edit page
|
||||
let edit (m : EditProfileViewModel) continents isNew csrf =
|
||||
article [] [
|
||||
h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ]
|
||||
form [ _class "row g-3"; _action "/profile/edit"; _hxPost "/profile/edit" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-12" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.IsSeekingEmployment); _name (nameof m.IsSeekingEmployment)
|
||||
_class "form-check-input"; if m.IsSeekingEmployment then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.IsSeekingEmployment) ] [
|
||||
rawText "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!" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-4" ] [
|
||||
continentList [ _required ] (nameof m.ContinentId) continents None m.ContinentId
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-8" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id (nameof m.Region); _name (nameof m.Region); _class "form-control"
|
||||
_maxlength "255"; _placeholder "Country, state, geographic area, etc."; _required ]
|
||||
label [ _class "jjj-required"; _for (m.Region) ] [ rawText "Region" ]
|
||||
]
|
||||
div [ _class "form-text" ] [ rawText "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" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.RemoteWork); _name (nameof m.RemoteWork)
|
||||
_class "form-check-input"; if m.RemoteWork then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.RemoteWork) ] [
|
||||
rawText "I am looking for remote work"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.FullTime); _name (nameof m.FullTime)
|
||||
_class "form-check-input"; if m.FullTime then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.FullTime) ] [
|
||||
rawText "I am looking for full-time work"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [ _class "pb-2" ] [
|
||||
rawText "Skills "
|
||||
button [ _class "btn btn-sm btn-outline-primary rounded-pill"; _onclick "jjj.addSkill" ] [
|
||||
rawText "Add a Skill"
|
||||
]
|
||||
]
|
||||
]
|
||||
//<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
|
||||
// @remove="removeSkill(skill.id)" @input="v$.skills.$touch" />
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [] [ rawText "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."
|
||||
]
|
||||
]
|
||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
||||
div [ _class "col-12" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.IsPublic); _name (nameof m.IsPublic)
|
||||
_class "form-check-input"; if m.IsPublic then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.IsPublic) ] [
|
||||
rawText "Allow my profile to be searched publicly"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [
|
||||
i [ _class "mdi mdi-content-save-outline" ] []; rawText " Save"
|
||||
]
|
||||
if not isNew then
|
||||
rawText " "
|
||||
a [ _class "btn btn-outline-secondary"; _href "`/profile/${user.citizenId}/view`" ] [
|
||||
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
|
||||
rawText " 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 "/so-long/options" ] [ rawText "see your deletion options here" ]; rawText ".)"
|
||||
]
|
||||
]
|
Loading…
Reference in New Issue
Block a user