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
|
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||||
let notAuthorized : HttpHandler = fun next ctx ->
|
let notAuthorized : HttpHandler = fun next ctx ->
|
||||||
if ctx.Request.Method = "GET" then
|
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
|
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx
|
||||||
else redirectTo false redirectUrl next ctx
|
else redirectTo false redirectUrl next ctx
|
||||||
else
|
else
|
||||||
|
@ -256,7 +256,7 @@ module Citizen =
|
||||||
|
|
||||||
// GET: /citizen/dashboard
|
// GET: /citizen/dashboard
|
||||||
let dashboard = requireUser >=> fun next ctx -> task {
|
let dashboard = requireUser >=> fun next ctx -> task {
|
||||||
let citizenId = CitizenId.ofString (tryUser ctx).Value
|
let citizenId = currentCitizenId ctx
|
||||||
let! citizen = Citizens.findById citizenId
|
let! citizen = Citizens.findById citizenId
|
||||||
let! profile = Profiles.findById citizenId
|
let! profile = Profiles.findById citizenId
|
||||||
let! prfCount = Profiles.count ()
|
let! prfCount = Profiles.count ()
|
||||||
|
@ -561,10 +561,25 @@ module Listing =
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/profile routes
|
/// Handlers for /profile routes
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Profile =
|
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
|
// 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
|
// 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.
|
// 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 "/how-it-works" Home.howItWorks ]
|
||||||
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
||||||
|
subRoute "/profile" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "/edit" Profile.edit
|
||||||
|
]
|
||||||
|
]
|
||||||
GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
|
GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
|
||||||
|
|
||||||
subRoute "/api" [
|
subRoute "/api" [
|
||||||
subRoute "/citizen" [
|
subRoute "/citizen" [
|
||||||
GET_HEAD [ routef "/%O" CitizenApi.get ]
|
GET_HEAD [ routef "/%O" CitizenApi.get ]
|
||||||
|
@ -749,15 +770,15 @@ let allEndpoints = [
|
||||||
]
|
]
|
||||||
subRoute "/profile" [
|
subRoute "/profile" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
route "" Profile.current
|
route "" ProfileApi.current
|
||||||
route "/count" Profile.count
|
route "/count" ProfileApi.count
|
||||||
routef "/%O" Profile.get
|
routef "/%O" ProfileApi.get
|
||||||
routef "/%O/view" Profile.view
|
routef "/%O/view" ProfileApi.view
|
||||||
route "/public-search" Profile.publicSearch
|
route "/public-search" ProfileApi.publicSearch
|
||||||
route "/search" Profile.search
|
route "/search" ProfileApi.search
|
||||||
]
|
]
|
||||||
PATCH [ route "/employment-found" Profile.employmentFound ]
|
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
||||||
POST [ route "" Profile.save ]
|
POST [ route "" ProfileApi.save ]
|
||||||
]
|
]
|
||||||
subRoute "/success" [
|
subRoute "/success" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
<Compile Include="Views\Layout.fs" />
|
<Compile Include="Views\Layout.fs" />
|
||||||
<Compile Include="Views\Citizen.fs" />
|
<Compile Include="Views\Citizen.fs" />
|
||||||
<Compile Include="Views\Home.fs" />
|
<Compile Include="Views\Home.fs" />
|
||||||
|
<Compile Include="Views\Profile.fs" />
|
||||||
<Compile Include="Handlers.fs" />
|
<Compile Include="Handlers.fs" />
|
||||||
<Compile Include="App.fs" />
|
<Compile Include="App.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
@ -1,6 +1,87 @@
|
||||||
/// View models for Jobs, Jobs, Jobs
|
/// View models for Jobs, Jobs, Jobs
|
||||||
module JobsJobsJobs.ViewModels
|
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
|
/// View model for the log on page
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type LogOnViewModel =
|
type LogOnViewModel =
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
module JobsJobsJobs.Views.Common
|
module JobsJobsJobs.Views.Common
|
||||||
|
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
|
open Giraffe.ViewEngine.Accessibility
|
||||||
open Microsoft.AspNetCore.Antiforgery
|
open Microsoft.AspNetCore.Antiforgery
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
|
||||||
/// Create an audio clip with the specified text node
|
/// Create an audio clip with the specified text node
|
||||||
let audioClip clip text =
|
let audioClip clip text =
|
||||||
|
@ -13,3 +15,34 @@ let audioClip clip text =
|
||||||
/// Create an anti-forgery hidden input
|
/// Create an anti-forgery hidden input
|
||||||
let antiForgery (csrf : AntiforgeryTokenSet) =
|
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||||
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
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