Version 3 #40
|
@ -1,3 +0,0 @@
|
||||||
> 1%
|
|
||||||
last 2 versions
|
|
||||||
not dead
|
|
|
@ -1,5 +0,0 @@
|
||||||
[*.{js,jsx,ts,tsx,vue}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
|
@ -1,30 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"plugin:vue/vue3-essential",
|
|
||||||
"@vue/standard",
|
|
||||||
"@vue/typescript/recommended"
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020
|
|
||||||
},
|
|
||||||
globals: {
|
|
||||||
defineProps: "readonly",
|
|
||||||
defineEmits: "readonly",
|
|
||||||
defineExpose: "readonly",
|
|
||||||
withDefaults: "readonly"
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
"vue/no-multiple-template-root": "off",
|
|
||||||
"vue/multi-word-component-names": "off",
|
|
||||||
"vue/script-setup-uses-vars": 1,
|
|
||||||
"quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }],
|
|
||||||
"func-call-spacing": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "off"
|
|
||||||
}
|
|
||||||
}
|
|
23
src/JobsJobsJobs/App/.gitignore
vendored
23
src/JobsJobsJobs/App/.gitignore
vendored
|
@ -1,23 +0,0 @@
|
||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/dist
|
|
||||||
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
|
@ -1,5 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
|
22795
src/JobsJobsJobs/App/package-lock.json
generated
22795
src/JobsJobsJobs/App/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -1,51 +0,0 @@
|
||||||
{
|
|
||||||
"name": "jobs-jobs-jobs",
|
|
||||||
"version": "3.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"serve": "vue-cli-service serve",
|
|
||||||
"build": "vue-cli-service build",
|
|
||||||
"lint": "vue-cli-service lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mdi/js": "^6.9.96",
|
|
||||||
"@vuelidate/core": "^2.0.0-alpha.24",
|
|
||||||
"@vuelidate/validators": "^2.0.0-alpha.21",
|
|
||||||
"@vueuse/core": "^8.9.1",
|
|
||||||
"bootstrap": "^5.1.0",
|
|
||||||
"core-js": "^3.16.3",
|
|
||||||
"date-fns": "^2.23.0",
|
|
||||||
"date-fns-tz": "^1.1.6",
|
|
||||||
"dompurify": "^2.3.1",
|
|
||||||
"marked": "^4.0.18",
|
|
||||||
"vue": "^3.2.45",
|
|
||||||
"vue-router": "^4.1.6",
|
|
||||||
"vuex": "^4.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bootstrap": "^5.1.2",
|
|
||||||
"@types/dompurify": "^2.2.3",
|
|
||||||
"@types/marked": "^4.0.3",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
|
||||||
"@typescript-eslint/parser": "^5.30.0",
|
|
||||||
"@vue/cli-plugin-babel": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-router": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
|
||||||
"@vue/cli-service": "~5.0.0",
|
|
||||||
"@vue/compiler-sfc": "^3.2.6",
|
|
||||||
"@vue/eslint-config-standard": "^7.0.0",
|
|
||||||
"@vue/eslint-config-typescript": "^11.0.0",
|
|
||||||
"eslint": "^8.19.0",
|
|
||||||
"eslint-plugin-import": "^2.24.2",
|
|
||||||
"eslint-plugin-n": "^15.2.4",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"eslint-plugin-promise": "^6.0.0",
|
|
||||||
"eslint-plugin-standard": "^5.0.0",
|
|
||||||
"eslint-plugin-vue": "^9.2.0",
|
|
||||||
"sass": "~1.37.0",
|
|
||||||
"sass-loader": "^10.0.0",
|
|
||||||
"typescript": "~4.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
|
@ -1,18 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
|
||||||
<link rel="stylesheet" href="https://necolas.github.io/normalize.css/latest/normalize.css">
|
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
|
||||||
</noscript>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- built files will be auto injected -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,99 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="jjj-app">
|
|
||||||
<app-nav />
|
|
||||||
<div class="jjj-main">
|
|
||||||
<title-bar />
|
|
||||||
<main class="jjj-content container-fluid">
|
|
||||||
<router-view v-slot="{ Component }">
|
|
||||||
<transition name="fade" mode="out-in">
|
|
||||||
<component :is="Component" />
|
|
||||||
</transition>
|
|
||||||
</router-view>
|
|
||||||
</main>
|
|
||||||
<app-footer />
|
|
||||||
<app-toaster />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent, onMounted } from "vue"
|
|
||||||
|
|
||||||
import "bootstrap/dist/css/bootstrap.min.css"
|
|
||||||
|
|
||||||
import { Citizen } from "./api"
|
|
||||||
import { Mutations, useStore } from "./store"
|
|
||||||
import AppFooter from "./components/layout/AppFooter.vue"
|
|
||||||
import AppNav from "./components/layout/AppNav.vue"
|
|
||||||
import AppToaster from "./components/layout/AppToaster.vue"
|
|
||||||
import TitleBar from "./components/layout/TitleBar.vue"
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
AppFooter,
|
|
||||||
AppNav,
|
|
||||||
AppToaster,
|
|
||||||
TitleBar
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
onMounted(() => store.commit(Mutations.SetTitle, "Jobs, Jobs, Jobs"))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return "Yes" for true and "No" for false
|
|
||||||
*
|
|
||||||
* @param cond The condition to be checked
|
|
||||||
* @returns "Yes" for true, "No" for false
|
|
||||||
*/
|
|
||||||
export function yesOrNo (cond : boolean) : string {
|
|
||||||
return cond ? "Yes" : "No"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the display name for a citizen
|
|
||||||
*
|
|
||||||
* @param cit The citizen
|
|
||||||
* @returns The citizen's display name
|
|
||||||
*/
|
|
||||||
export function citizenName (cit : Citizen) : string {
|
|
||||||
return cit.displayName ?? `${cit.firstName} ${cit.lastName}`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
// Overall app styles
|
|
||||||
html
|
|
||||||
scroll-behavior: smooth
|
|
||||||
a:link,
|
|
||||||
a:visited
|
|
||||||
text-decoration: none
|
|
||||||
a:not(.btn):hover
|
|
||||||
text-decoration: underline
|
|
||||||
label.jjj-required::after
|
|
||||||
color: red
|
|
||||||
content: ' *'
|
|
||||||
.jjj-heading-label
|
|
||||||
display: inline-block
|
|
||||||
font-size: 1rem
|
|
||||||
text-transform: uppercase
|
|
||||||
// Styles for this component
|
|
||||||
.jjj-app
|
|
||||||
display: flex
|
|
||||||
flex-direction: row
|
|
||||||
.jjj-main
|
|
||||||
flex-grow: 1
|
|
||||||
display: flex
|
|
||||||
flex-flow: column
|
|
||||||
min-height: 100vh
|
|
||||||
.jjj-content
|
|
||||||
flex-grow: 2
|
|
||||||
// Route transitions
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active
|
|
||||||
transition: opacity 0.125s ease
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to
|
|
||||||
opacity: 0
|
|
||||||
</style>
|
|
|
@ -1,426 +0,0 @@
|
||||||
import {
|
|
||||||
AccountProfileForm,
|
|
||||||
Citizen,
|
|
||||||
CitizenRegistrationForm,
|
|
||||||
Continent,
|
|
||||||
Count,
|
|
||||||
Listing,
|
|
||||||
ListingExpireForm,
|
|
||||||
ListingForm,
|
|
||||||
ListingForView,
|
|
||||||
ListingSearch,
|
|
||||||
LogOnForm,
|
|
||||||
LogOnSuccess,
|
|
||||||
Profile,
|
|
||||||
ProfileForm,
|
|
||||||
ProfileForView,
|
|
||||||
ProfileSearch,
|
|
||||||
ProfileSearchResult,
|
|
||||||
PublicSearch,
|
|
||||||
PublicSearchResult,
|
|
||||||
StoryEntry,
|
|
||||||
StoryForm,
|
|
||||||
Success,
|
|
||||||
Valid
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a URL that will access the API
|
|
||||||
* @param url The partial URL for the API
|
|
||||||
* @returns A full URL for the API
|
|
||||||
*/
|
|
||||||
const apiUrl = (url : string) : string => `/api/${url}`
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create request init parameters
|
|
||||||
*
|
|
||||||
* @param method The method by which the request should be executed
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @param body The body of teh request
|
|
||||||
* @returns RequestInit parameters
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line
|
|
||||||
const reqInit = (method : string, user : LogOnSuccess | undefined, body : any | undefined = undefined)
|
|
||||||
: RequestInit => {
|
|
||||||
const headers = new Headers()
|
|
||||||
if (user) headers.append("Authorization", `Bearer ${user.jwt}`)
|
|
||||||
if (body) {
|
|
||||||
headers.append("Content-Type", "application/json")
|
|
||||||
return {
|
|
||||||
headers,
|
|
||||||
method,
|
|
||||||
cache: "no-cache",
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
headers,
|
|
||||||
method
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a result for an API call
|
|
||||||
*
|
|
||||||
* @param resp The response received from the API
|
|
||||||
* @param action The action being performed (used in error messages)
|
|
||||||
* @returns The expected result (if found), undefined (if not found), or an error string
|
|
||||||
*/
|
|
||||||
async function apiResult<T> (resp : Response, action : string) : Promise<T | undefined | string> {
|
|
||||||
if (resp.status === 200) return await resp.json() as T
|
|
||||||
if (resp.status === 404) return undefined
|
|
||||||
return `Error ${action} - ${await resp.text()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an update via the API
|
|
||||||
*
|
|
||||||
* @param resp The response received from the API
|
|
||||||
* @param action The action being performed (used in error messages)
|
|
||||||
* @returns True (if the response is a success) or an error string
|
|
||||||
*/
|
|
||||||
async function apiSend (resp : Response, action : string) : Promise<boolean | string> {
|
|
||||||
if (resp.status === 200) return true
|
|
||||||
// HTTP 422 (Unprocessable Entity) is what the API returns for an expired JWT
|
|
||||||
if (resp.status === 422) return `Error ${action} - Your login has expired; refresh this page to renew it`
|
|
||||||
return `Error ${action} - (${resp.status}) ${await resp.text()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run an API action that does not return a result
|
|
||||||
*
|
|
||||||
* @param resp The response received from the API call
|
|
||||||
* @param action The action being performed (used in error messages)
|
|
||||||
* @returns Undefined (if successful), or an error string
|
|
||||||
*/
|
|
||||||
const apiAction = async (resp : Response, action : string) : Promise<string | undefined> => {
|
|
||||||
if (resp.status === 200) return undefined
|
|
||||||
return `Error ${action} - ${await resp.text()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
|
|
||||||
/** API functions for citizens */
|
|
||||||
citizen: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a citizen
|
|
||||||
*
|
|
||||||
* @param form The registration details for the citizen
|
|
||||||
* @returns True if the registration was successful, an error message if it was not
|
|
||||||
*/
|
|
||||||
register: async (form : CitizenRegistrationForm) : Promise<boolean | string> => {
|
|
||||||
const resp = await fetch(apiUrl("citizen/register"), reqInit("POST", undefined, form))
|
|
||||||
if (resp.status === 200) return true
|
|
||||||
if (resp.status === 409) return "There is already an account registered to the e-mail address provided"
|
|
||||||
return `Error registering citizen - ${await resp.text()}`
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm an account by verifying a token they received via e-mail
|
|
||||||
*
|
|
||||||
* @param token The token to be verified
|
|
||||||
* @return True if the token is valid, false if it is not, or an error message if one is encountered
|
|
||||||
*/
|
|
||||||
confirmToken: async (token : string) : Promise<boolean | string> => {
|
|
||||||
const resp = await apiResult<Valid>(
|
|
||||||
await fetch(apiUrl("citizen/confirm"), reqInit("PATCH", undefined, { token })), "confirming account")
|
|
||||||
if (typeof resp === "string") return resp
|
|
||||||
if (typeof resp === "undefined") return false
|
|
||||||
return resp.valid
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deny an account after verifying the token they received via e-mail
|
|
||||||
*
|
|
||||||
* @param token The token to be verified
|
|
||||||
* @return True if the token is valid, false if it is not, or an error message if one is encountered
|
|
||||||
*/
|
|
||||||
denyAccount: async (token : string) : Promise<boolean | string> => {
|
|
||||||
const resp = await apiResult<Valid>(
|
|
||||||
await fetch(apiUrl("citizen/deny"), reqInit("DELETE", undefined, { token })), "denying account")
|
|
||||||
if (typeof resp === "string") return resp
|
|
||||||
if (typeof resp === "undefined") return false
|
|
||||||
return resp.valid
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a citizen on
|
|
||||||
*
|
|
||||||
* @param form The e-mail address and password provided by the user
|
|
||||||
* @returns The user result, or an error
|
|
||||||
*/
|
|
||||||
logOn: async (form : LogOnForm) : Promise<LogOnSuccess | string> => {
|
|
||||||
const resp = await fetch(apiUrl("citizen/log-on"), reqInit("POST", undefined, form))
|
|
||||||
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
|
||||||
return `Error logging on - ${await resp.text()}`
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a citizen by their ID
|
|
||||||
*
|
|
||||||
* @param id The citizen ID to be retrieved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The citizen, or an error
|
|
||||||
*/
|
|
||||||
retrieve: async (id : string, user : LogOnSuccess) : Promise<Citizen | string | undefined> =>
|
|
||||||
apiResult<Citizen>(await fetch(apiUrl(`citizen/${id}`), reqInit("GET", user)), `retrieving citizen ${id}`),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a citizen's account profile
|
|
||||||
*
|
|
||||||
* @param form The data to be saved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if successful, an error message if not
|
|
||||||
*/
|
|
||||||
save: async (form : AccountProfileForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
|
||||||
apiSend(await fetch(apiUrl("citizen/account"), reqInit("PATCH", user, form)), "saving account profile"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the current citizen's entire Jobs, Jobs, Jobs record
|
|
||||||
*
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns Undefined if successful, an error if not
|
|
||||||
*/
|
|
||||||
delete: async (user : LogOnSuccess) : Promise<string | undefined> =>
|
|
||||||
apiAction(await fetch(apiUrl("citizen"), reqInit("DELETE", user)), "deleting citizen")
|
|
||||||
},
|
|
||||||
|
|
||||||
/** API functions for continents */
|
|
||||||
continent: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all continents
|
|
||||||
*
|
|
||||||
* @returns All continents, or an error
|
|
||||||
*/
|
|
||||||
all: async () : Promise<Continent[] | string | undefined> =>
|
|
||||||
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
|
||||||
},
|
|
||||||
|
|
||||||
/** API functions for job listings */
|
|
||||||
listings: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new job listing
|
|
||||||
*
|
|
||||||
* @param listing The profile data to be saved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if the addition was successful, an error string if not
|
|
||||||
*/
|
|
||||||
add: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
|
||||||
apiSend(await fetch(apiUrl("listings"), reqInit("POST", user, listing)), "adding job listing"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expire a job listing
|
|
||||||
*
|
|
||||||
* @param id The ID of the job listing to be expired
|
|
||||||
* @param form The information needed to expire the listing
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if the action was successful, an error string if not
|
|
||||||
*/
|
|
||||||
expire: async (id : string, form : ListingExpireForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
|
||||||
apiSend(await fetch(apiUrl(`listing/${id}`), reqInit("PATCH", user, form)), "expiring job listing"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve the job listings posted by the current citizen
|
|
||||||
*
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The job listings the user has posted, or an error string
|
|
||||||
*/
|
|
||||||
mine: async (user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> =>
|
|
||||||
apiResult<ListingForView[]>(await fetch(apiUrl("listings/mine"), reqInit("GET", user)),
|
|
||||||
"retrieving your job listings"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a job listing
|
|
||||||
*
|
|
||||||
* @param id The ID of the job listing to retrieve
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The job listing (if found), undefined (if not found), or an error string
|
|
||||||
*/
|
|
||||||
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
|
|
||||||
apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit("GET", user)), "retrieving job listing"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a job listing for viewing (also contains continent information)
|
|
||||||
*
|
|
||||||
* @param id The ID of the job listing to retrieve
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The job listing (if found), undefined (if not found), or an error string
|
|
||||||
*/
|
|
||||||
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ListingForView | undefined | string> =>
|
|
||||||
apiResult<ListingForView>(await fetch(apiUrl(`listing/${id}/view`), reqInit("GET", user)),
|
|
||||||
"retrieving job listing"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for job listings using the given parameters
|
|
||||||
*
|
|
||||||
* @param query The listing search parameters
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The matching job listings (if found), undefined (if API returns 404), or an error string
|
|
||||||
*/
|
|
||||||
search: async (query : ListingSearch, user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (query.continentId) params.append("continentId", query.continentId)
|
|
||||||
if (query.region) params.append("region", query.region)
|
|
||||||
params.append("remoteWork", query.remoteWork)
|
|
||||||
if (query.text) params.append("text", query.text)
|
|
||||||
return apiResult<ListingForView[]>(await fetch(apiUrl(`listing/search?${params.toString()}`),
|
|
||||||
reqInit("GET", user)), "searching job listings")
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing job listing
|
|
||||||
*
|
|
||||||
* @param listing The profile data to be saved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if the update was successful, an error string if not
|
|
||||||
*/
|
|
||||||
update: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
|
||||||
apiSend(await fetch(apiUrl(`listing/${listing.id}`), reqInit("PUT", user, listing)), "updating job listing")
|
|
||||||
},
|
|
||||||
|
|
||||||
/** API functions for profiles */
|
|
||||||
profile: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the "seeking employment" flag on the current citizen's profile
|
|
||||||
*
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if the action was successful, or an error string if not
|
|
||||||
*/
|
|
||||||
markEmploymentFound: async (user : LogOnSuccess) : Promise<boolean | string> => {
|
|
||||||
const result = await fetch(apiUrl("profile/employment-found"), reqInit("PATCH", user))
|
|
||||||
if (result.ok) return true
|
|
||||||
return `${result.status} - ${result.statusText} (${await result.text()})`
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for public profile data using the given parameters
|
|
||||||
*
|
|
||||||
* @param query The public profile search parameters
|
|
||||||
* @returns The matching public profiles (if found), undefined (if API returns 404), or an error string
|
|
||||||
*/
|
|
||||||
publicSearch: async (query : PublicSearch) : Promise<PublicSearchResult[] | string | undefined> => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (query.continentId) params.append("continentId", query.continentId)
|
|
||||||
if (query.region) params.append("region", query.region)
|
|
||||||
if (query.skill) params.append("skill", query.skill)
|
|
||||||
params.append("remoteWork", query.remoteWork)
|
|
||||||
return apiResult<PublicSearchResult[]>(
|
|
||||||
await fetch(apiUrl(`profile/public-search?${params.toString()}`), { method: "GET" }),
|
|
||||||
"searching public profile data")
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a profile
|
|
||||||
*
|
|
||||||
* @param id The ID of the profile to retrieve (optional; if omitted, retrieve for the current citizen)
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The profile (if found), undefined (if not found), or an error string
|
|
||||||
*/
|
|
||||||
retreive: async (id : string | undefined, user : LogOnSuccess) : Promise<Profile | undefined | string> => {
|
|
||||||
const url = id ? `profile/${id}` : "profile"
|
|
||||||
const resp = await fetch(apiUrl(url), reqInit("GET", user))
|
|
||||||
if (resp.status === 200) return await resp.json() as Profile
|
|
||||||
if (resp.status !== 204) return `Error retrieving profile - ${await resp.text()}`
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a profile for viewing
|
|
||||||
*
|
|
||||||
* @param id The ID of the profile to retrieve for viewing
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The profile (if found), undefined (if not found), or an error string
|
|
||||||
*/
|
|
||||||
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> =>
|
|
||||||
apiResult<ProfileForView>(await fetch(apiUrl(`profile/${id}/view`), reqInit("GET", user)), "retrieving profile"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a user's profile data
|
|
||||||
*
|
|
||||||
* @param data The profile data to be saved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if the save was successful, an error string if not
|
|
||||||
*/
|
|
||||||
save: async (data : ProfileForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
|
||||||
apiSend(await fetch(apiUrl("profile"), reqInit("POST", user, data)), "saving profile"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for profiles using the given parameters
|
|
||||||
*
|
|
||||||
* @param query The profile search parameters
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The matching profiles (if found), undefined (if API returns 404), or an error string
|
|
||||||
*/
|
|
||||||
search: async (query : ProfileSearch, user : LogOnSuccess) : Promise<ProfileSearchResult[] | string | undefined> => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (query.continentId) params.append("continentId", query.continentId)
|
|
||||||
if (query.skill) params.append("skill", query.skill)
|
|
||||||
if (query.bioExperience) params.append("bioExperience", query.bioExperience)
|
|
||||||
params.append("remoteWork", query.remoteWork)
|
|
||||||
return apiResult<ProfileSearchResult[]>(await fetch(apiUrl(`profile/search?${params.toString()}`),
|
|
||||||
reqInit("GET", user)), "searching profiles")
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count profiles in the system
|
|
||||||
*
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns A count of profiles within the entire system
|
|
||||||
*/
|
|
||||||
count: async (user : LogOnSuccess) : Promise<number | string> => {
|
|
||||||
const resp = await fetch(apiUrl("profile/count"), reqInit("GET", user))
|
|
||||||
if (resp.status === 200) {
|
|
||||||
const result = await resp.json() as Count
|
|
||||||
return result.count
|
|
||||||
}
|
|
||||||
return `Error counting profiles - ${await resp.text()}`
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the current user's employment profile
|
|
||||||
*
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns Undefined if successful, an error if not
|
|
||||||
*/
|
|
||||||
delete: async (user : LogOnSuccess) : Promise<string | undefined> =>
|
|
||||||
apiAction(await fetch(apiUrl("profile"), reqInit("DELETE", user)), "deleting profile")
|
|
||||||
},
|
|
||||||
|
|
||||||
/** API functions for success stories */
|
|
||||||
success: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve all success stories
|
|
||||||
*
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns All success stories (if any exist), undefined (if none exist), or an error
|
|
||||||
*/
|
|
||||||
list: async (user : LogOnSuccess) : Promise<StoryEntry[] | string | undefined> =>
|
|
||||||
apiResult<StoryEntry[]>(await fetch(apiUrl("successes"), reqInit("GET", user)), "retrieving success stories"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a success story by its ID
|
|
||||||
*
|
|
||||||
* @param id The success story ID to be retrieved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns The success story, or an error
|
|
||||||
*/
|
|
||||||
retrieve: async (id : string, user : LogOnSuccess) : Promise<Success | string | undefined> =>
|
|
||||||
apiResult<Success>(await fetch(apiUrl(`success/${id}`), reqInit("GET", user)), `retrieving success story ${id}`),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a success story
|
|
||||||
*
|
|
||||||
* @param data The data to be saved
|
|
||||||
* @param user The currently logged-on user
|
|
||||||
* @returns True if successful, an error string if not
|
|
||||||
*/
|
|
||||||
save: async (data : StoryForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
|
||||||
apiSend(await fetch(apiUrl("success"), reqInit("POST", user, data)), "saving success story")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export * from "./types"
|
|
|
@ -1,339 +0,0 @@
|
||||||
/** An "other contact" for a citizen */
|
|
||||||
export interface OtherContact {
|
|
||||||
/** The ID of this contact */
|
|
||||||
id : string
|
|
||||||
/** The contact type (uses server-side DU) */
|
|
||||||
contactType : string
|
|
||||||
/** The name for this contact */
|
|
||||||
name : string | undefined
|
|
||||||
/** The value of the contact (e-mail, phone number, URL, etc.) */
|
|
||||||
value : string
|
|
||||||
/** Whether this contact is visible on public employment profiles and job listings */
|
|
||||||
isPublic : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data a logged-on citizen can update */
|
|
||||||
export class AccountProfileForm {
|
|
||||||
/** The citizen's first name */
|
|
||||||
firstName = ""
|
|
||||||
/** The citizen's last name */
|
|
||||||
lastName = ""
|
|
||||||
/** The name by which the citizen wishes to be known within the site */
|
|
||||||
displayName : string | undefined
|
|
||||||
/** The new password for the citizen */
|
|
||||||
newPassword : string | undefined
|
|
||||||
/** The confirmed new password for the citizen */
|
|
||||||
newPasswordConfirm : string | undefined
|
|
||||||
/** The other contacts for this citizen */
|
|
||||||
contacts : OtherContact[] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A user of Jobs, Jobs, Jobs */
|
|
||||||
export interface Citizen {
|
|
||||||
/** The ID of the user */
|
|
||||||
id : string
|
|
||||||
/** When the user joined Jobs, Jobs, Jobs (date) */
|
|
||||||
joinedOn : string
|
|
||||||
/** When the user last logged in (date) */
|
|
||||||
lastSeenOn : string
|
|
||||||
/** The citizen's e-mail address */
|
|
||||||
email : string
|
|
||||||
/** The citizen's first name */
|
|
||||||
firstName : string
|
|
||||||
/** The citizen's last name */
|
|
||||||
lastName : string
|
|
||||||
/** The citizen's display name */
|
|
||||||
displayName : string | undefined
|
|
||||||
/** The citizen's contact information */
|
|
||||||
otherContacts : OtherContact[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data required to register as a user */
|
|
||||||
export class CitizenRegistrationForm {
|
|
||||||
/** The citizen's first name */
|
|
||||||
firstName = ""
|
|
||||||
/** The citizen's last name */
|
|
||||||
lastName = ""
|
|
||||||
/** The citizen's e-mail address */
|
|
||||||
email = ""
|
|
||||||
/** The name by which the citizen wishes to be known within the site */
|
|
||||||
displayName : string | undefined
|
|
||||||
/** The password for the citizen */
|
|
||||||
password = ""
|
|
||||||
/** The confirmed password for the citizen */
|
|
||||||
confirmPassword = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A continent */
|
|
||||||
export interface Continent {
|
|
||||||
/** The ID of the continent */
|
|
||||||
id : string
|
|
||||||
/** The name of the continent */
|
|
||||||
name : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A count */
|
|
||||||
export interface Count {
|
|
||||||
/** The count being returned */
|
|
||||||
count : number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A job listing */
|
|
||||||
export interface Listing {
|
|
||||||
/** The ID of the job listing */
|
|
||||||
id : string
|
|
||||||
/** The ID of the citizen who posted the job listing */
|
|
||||||
citizenId : string
|
|
||||||
/** When this job listing was created (date) */
|
|
||||||
createdOn : string
|
|
||||||
/** The short title of the job listing */
|
|
||||||
title : string
|
|
||||||
/** The ID of the continent on which the job is located */
|
|
||||||
continentId : string
|
|
||||||
/** The region in which the job is located */
|
|
||||||
region : string
|
|
||||||
/** Whether this listing is for remote work */
|
|
||||||
remoteWork : boolean
|
|
||||||
/** Whether this listing has expired */
|
|
||||||
isExpired : boolean
|
|
||||||
/** When this listing was last updated (date) */
|
|
||||||
updatedOn : string
|
|
||||||
/** The details of this job */
|
|
||||||
text : string
|
|
||||||
/** When this job needs to be filled (date) */
|
|
||||||
neededBy : string | undefined
|
|
||||||
/** Was this job filled as part of its appearance on Jobs, Jobs, Jobs? */
|
|
||||||
wasFilledHere : boolean | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data required to add or edit a job listing */
|
|
||||||
export class ListingForm {
|
|
||||||
/** The ID of the listing */
|
|
||||||
id = ""
|
|
||||||
/** The listing title */
|
|
||||||
title = ""
|
|
||||||
/** The ID of the continent on which this opportunity exists */
|
|
||||||
continentId = ""
|
|
||||||
/** The region in which this opportunity exists */
|
|
||||||
region = ""
|
|
||||||
/** Whether this is a remote work opportunity */
|
|
||||||
remoteWork = false
|
|
||||||
/** The text of the job listing */
|
|
||||||
text = ""
|
|
||||||
/** The date by which this job listing is needed */
|
|
||||||
neededBy : string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The form submitted to expire a listing */
|
|
||||||
export class ListingExpireForm {
|
|
||||||
/** Whether the job was filled from here */
|
|
||||||
fromHere = false
|
|
||||||
/** The success story written by the user */
|
|
||||||
successStory : string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data required to view a listing */
|
|
||||||
export interface ListingForView {
|
|
||||||
/** The listing itself */
|
|
||||||
listing : Listing
|
|
||||||
/** The continent for the listing */
|
|
||||||
continent : Continent
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The various ways job listings can be searched */
|
|
||||||
export interface ListingSearch {
|
|
||||||
/** Retrieve opportunities from this continent */
|
|
||||||
continentId : string | undefined
|
|
||||||
/** Text for a search for a specific region */
|
|
||||||
region : string | undefined
|
|
||||||
/** Whether to retrieve job listings for remote work */
|
|
||||||
remoteWork : string
|
|
||||||
/** Text to search with a job's full description */
|
|
||||||
text : string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Data used to log on */
|
|
||||||
export class LogOnForm {
|
|
||||||
/** The e-mail address of a citizen's account */
|
|
||||||
email = ""
|
|
||||||
/** The password for that account */
|
|
||||||
password = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A successful logon */
|
|
||||||
export interface LogOnSuccess {
|
|
||||||
/** The JSON Web Token (JWT) to use for API access */
|
|
||||||
jwt : string
|
|
||||||
/** The ID of the logged-in citizen (as a string) */
|
|
||||||
citizenId : string
|
|
||||||
/** The name of the logged-in citizen */
|
|
||||||
name : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A skill the job seeker possesses */
|
|
||||||
export interface Skill {
|
|
||||||
/** The ID of the skill */
|
|
||||||
id : string
|
|
||||||
/** A description of the skill */
|
|
||||||
description : string
|
|
||||||
/** Notes regarding this skill (level, duration, etc.) */
|
|
||||||
notes : string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A job seeker profile */
|
|
||||||
export interface Profile {
|
|
||||||
/** The ID of the citizen to whom this profile belongs */
|
|
||||||
id : string
|
|
||||||
/** Whether this citizen is actively seeking employment */
|
|
||||||
seekingEmployment : boolean
|
|
||||||
/** Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data */
|
|
||||||
isPublic : boolean
|
|
||||||
/** The ID of the continent on which the citizen resides */
|
|
||||||
continentId : string
|
|
||||||
/** The region in which the citizen resides */
|
|
||||||
region : string
|
|
||||||
/** Whether the citizen is looking for remote work */
|
|
||||||
remoteWork : boolean
|
|
||||||
/** Whether the citizen is looking for full-time work */
|
|
||||||
fullTime : boolean
|
|
||||||
/** The citizen's professional biography */
|
|
||||||
biography : string
|
|
||||||
/** When the citizen last updated their profile (date) */
|
|
||||||
lastUpdatedOn : string
|
|
||||||
/** The citizen's experience (topical / chronological) */
|
|
||||||
experience : string | undefined
|
|
||||||
/** Skills this citizen possesses */
|
|
||||||
skills : Skill[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data required to update a profile */
|
|
||||||
export class ProfileForm {
|
|
||||||
/** Whether the citizen to whom this profile belongs is actively seeking employment */
|
|
||||||
isSeekingEmployment = false
|
|
||||||
/** Whether this profile should appear in the public search */
|
|
||||||
isPublic = false
|
|
||||||
/** The ID of the continent on which the citizen is located */
|
|
||||||
continentId = ""
|
|
||||||
/** The area within that continent where the citizen is located */
|
|
||||||
region = ""
|
|
||||||
/** If the citizen is available for remote work */
|
|
||||||
remoteWork = false
|
|
||||||
/** If the citizen is seeking full-time employment */
|
|
||||||
fullTime = false
|
|
||||||
/** The user's professional biography */
|
|
||||||
biography = ""
|
|
||||||
/** The user's past experience */
|
|
||||||
experience : string | undefined
|
|
||||||
/** The skills for the user */
|
|
||||||
skills : Skill[] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data required to show a viewable profile */
|
|
||||||
export interface ProfileForView {
|
|
||||||
/** The profile itself */
|
|
||||||
profile : Profile
|
|
||||||
/** The citizen to whom the profile belongs */
|
|
||||||
citizen : Citizen
|
|
||||||
/** The continent for the profile */
|
|
||||||
continent : Continent
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The various ways profiles can be searched */
|
|
||||||
export interface ProfileSearch {
|
|
||||||
/** Retrieve citizens from this continent */
|
|
||||||
continentId : string | undefined
|
|
||||||
/** Text for a search within a citizen's skills */
|
|
||||||
skill : string | undefined
|
|
||||||
/** Text for a search with a citizen's professional biography and experience fields */
|
|
||||||
bioExperience : string | undefined
|
|
||||||
/** Whether to retrieve citizens who do or do not want remote work */
|
|
||||||
remoteWork : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A user matching the profile search */
|
|
||||||
export interface ProfileSearchResult {
|
|
||||||
/** The ID of the citizen */
|
|
||||||
citizenId : string
|
|
||||||
/** The citizen's display name */
|
|
||||||
displayName : string
|
|
||||||
/** Whether this citizen is currently seeking employment */
|
|
||||||
seekingEmployment : boolean
|
|
||||||
/** Whether this citizen is looking for remote work */
|
|
||||||
remoteWork : boolean
|
|
||||||
/** Whether this citizen is looking for full-time work */
|
|
||||||
fullTime : boolean
|
|
||||||
/** When this profile was last updated (date) */
|
|
||||||
lastUpdatedOn : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The parameters for a public job search */
|
|
||||||
export interface PublicSearch {
|
|
||||||
/** Retrieve citizens from this continent */
|
|
||||||
continentId : string | undefined
|
|
||||||
/** Retrieve citizens from this region */
|
|
||||||
region : string | undefined
|
|
||||||
/** Text for a search within a citizen's skills */
|
|
||||||
skill : string | undefined
|
|
||||||
/** Whether to retrieve citizens who do or do not want remote work */
|
|
||||||
remoteWork : string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A public profile search result */
|
|
||||||
export interface PublicSearchResult {
|
|
||||||
/** The name of the continent on which the citizen resides */
|
|
||||||
continent : string
|
|
||||||
/** The region in which the citizen resides */
|
|
||||||
region : string
|
|
||||||
/** Whether this citizen is seeking remote work */
|
|
||||||
remoteWork : boolean
|
|
||||||
/** The skills this citizen has identified */
|
|
||||||
skills : string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An entry in the list of success stories */
|
|
||||||
export interface StoryEntry {
|
|
||||||
/** The ID of this success story */
|
|
||||||
id : string
|
|
||||||
/** The ID of the citizen who recorded this story */
|
|
||||||
citizenId : string
|
|
||||||
/** The name of the citizen who recorded this story */
|
|
||||||
citizenName : string
|
|
||||||
/** When this story was recorded (date) */
|
|
||||||
recordedOn : string
|
|
||||||
/** Whether this story involves an opportunity that arose due to Jobs, Jobs, Jobs */
|
|
||||||
fromHere : boolean
|
|
||||||
/** Whether this report has a further story, or if it is simply a "found work" entry */
|
|
||||||
hasStory : boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The data required to provide a success story */
|
|
||||||
export class StoryForm {
|
|
||||||
/** The ID of this story */
|
|
||||||
id = ""
|
|
||||||
/** Whether the employment was obtained from Jobs, Jobs, Jobs */
|
|
||||||
fromHere = false
|
|
||||||
/** The success story */
|
|
||||||
story = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A record of success finding employment */
|
|
||||||
export interface Success {
|
|
||||||
/** The ID of the success report */
|
|
||||||
id : string
|
|
||||||
/** The ID of the citizen who wrote this success report */
|
|
||||||
citizenId : string
|
|
||||||
/** When this success report was recorded (date) */
|
|
||||||
recordedOn : string
|
|
||||||
/** Whether the success was due, at least in part, to Jobs, Jobs, Jobs */
|
|
||||||
fromHere : boolean
|
|
||||||
/** The source of this success (listing or profile) */
|
|
||||||
source : string
|
|
||||||
/** The success story */
|
|
||||||
story : string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a check is valid */
|
|
||||||
export interface Valid {
|
|
||||||
/** The validity */
|
|
||||||
valid : boolean
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
<template>
|
|
||||||
<span @click="playFile">
|
|
||||||
<slot />
|
|
||||||
<audio :id="clip"><source :src="clipSource" /></audio>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
clip: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** The full relative URL for the audio clip */
|
|
||||||
const clipSource = `/audio/${props.clip}.mp3`
|
|
||||||
|
|
||||||
/** Play the audio file */
|
|
||||||
const playFile = () => {
|
|
||||||
const audio = document.getElementById(props.clip) as HTMLAudioElement
|
|
||||||
audio.play()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
audio
|
|
||||||
display: none
|
|
||||||
span
|
|
||||||
border-bottom: dotted 1px lightgray
|
|
||||||
&:hover
|
|
||||||
cursor: pointer
|
|
||||||
</style>
|
|
|
@ -1,44 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">
|
|
||||||
<a href="#" :class="{ 'cp-c': collapsed, 'cp-o': !collapsed }" @click.prevent="toggle">{{headerText}}</a>
|
|
||||||
</h6>
|
|
||||||
<slot v-if="!collapsed" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
headerText: string
|
|
||||||
collapsed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
headerText: "Toggle",
|
|
||||||
collapsed: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "toggle") : void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** Emit the toggle event */
|
|
||||||
const toggle = () => emit("toggle", !props.collapsed)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
a.cp-c,
|
|
||||||
a.cp-o
|
|
||||||
text-decoration: none
|
|
||||||
font-weight: bold
|
|
||||||
color: black
|
|
||||||
a.cp-c:hover,
|
|
||||||
a.cp-o:hover
|
|
||||||
cursor: pointer
|
|
||||||
.cp-c::before
|
|
||||||
content: '\2b9e \00a0'
|
|
||||||
.cp-o::before
|
|
||||||
content: '\2b9f \00a0'
|
|
||||||
</style>
|
|
|
@ -1,60 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="form-floating">
|
|
||||||
<select id="continentId" :class="{ 'form-select': true, 'is-invalid': isInvalid}" :value="continentId"
|
|
||||||
@change="continentChanged">
|
|
||||||
<option value="">– {{emptyLabel}} –</option>
|
|
||||||
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
|
|
||||||
</select>
|
|
||||||
<label class="jjj-required" for="continentId">Continent</label>
|
|
||||||
</div>
|
|
||||||
<div class="invalid-feedback">Please select a continent</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useStore } from "@/store"
|
|
||||||
import { computed, onMounted, ref } from "vue"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue: string
|
|
||||||
topLabel?: string
|
|
||||||
isInvalid?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
isInvalid: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "update:modelValue", value : string) : void
|
|
||||||
(e: "touch") : void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
/** The continent ID, which this component can change */
|
|
||||||
const continentId = ref(props.modelValue)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the continent field as changed
|
|
||||||
*
|
|
||||||
* (This works around a really strange sequence where, if the "touch" call is directly wired up to the onChange event,
|
|
||||||
* the first time a value is selected, it doesn't stick (although the field is marked as touched). On second and
|
|
||||||
* subsequent times, it worked. The solution here is to grab the value and update the reactive source for the form, then
|
|
||||||
* manually set the field to touched; this restores the expected behavior. This is probably why the library doesn't hook
|
|
||||||
* into the onChange event to begin with...)
|
|
||||||
*/
|
|
||||||
const continentChanged = (e : Event) : boolean => {
|
|
||||||
continentId.value = (e.target as HTMLSelectElement).value
|
|
||||||
emit("touch")
|
|
||||||
emit("update:modelValue", continentId.value)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => await store.dispatch("ensureContinents"))
|
|
||||||
|
|
||||||
/** Accessor for the continent list */
|
|
||||||
const continents = computed(() => store.state.continents)
|
|
||||||
|
|
||||||
/** The label to use for the top entry in the list */
|
|
||||||
const emptyLabel = props.topLabel ?? "Select"
|
|
||||||
</script>
|
|
|
@ -1,18 +0,0 @@
|
||||||
<template>
|
|
||||||
<template v-if="errors.length > 0">
|
|
||||||
<p>The following error<template v-if="errors.length !== 1">s</template> occurred:</p>
|
|
||||||
<ul><li v-for="(error, idx) in errors" :key="idx">{{error}}</li></ul>
|
|
||||||
</template>
|
|
||||||
<slot v-else />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
errors: string[]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
ul li
|
|
||||||
font-family: monospace
|
|
||||||
</style>
|
|
|
@ -1,15 +0,0 @@
|
||||||
<template>
|
|
||||||
<template v-if="true">{{formatted}}</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { parseToUtc } from "./"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
date: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** The formatted date */
|
|
||||||
const formatted = format(parseToUtc(props.date), "PPP")
|
|
||||||
</script>
|
|
|
@ -1,15 +0,0 @@
|
||||||
<template>
|
|
||||||
<template v-if="true">{{formatted}}</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { parseToUtc } from "./"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
date: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** The formatted date/time */
|
|
||||||
const formatted = format(parseToUtc(props.date), "PPPp")
|
|
||||||
</script>
|
|
|
@ -1,16 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg viewbox="0 0 24 24"><path :fill="color || 'white'" :d="icon" /></svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
color?: string
|
|
||||||
icon: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
svg
|
|
||||||
width: 24px
|
|
||||||
height: 24px
|
|
||||||
</style>
|
|
|
@ -1,32 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="loading">Loading…</div>
|
|
||||||
<error-list v-else :errors="errors">
|
|
||||||
<slot />
|
|
||||||
</error-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from "vue"
|
|
||||||
import ErrorList from "./ErrorList.vue"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
load: (errors : string[]) => Promise<unknown>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** Errors encountered during loading */
|
|
||||||
const errors : string[] = []
|
|
||||||
|
|
||||||
/** Whether we are currently loading data */
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
/** Call the data load function */
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
await props.load(errors)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadData)
|
|
||||||
</script>
|
|
|
@ -1,69 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="col-12">
|
|
||||||
<nav class="nav nav-pills pb-1">
|
|
||||||
<button :class="sourceClass" @click.prevent="showMarkdown">Markdown</button>
|
|
||||||
|
|
||||||
<button :class="previewClass" @click.prevent="showPreview">Preview</button>
|
|
||||||
</nav>
|
|
||||||
<section v-if="preview" class="preview" v-html="previewHtml" aria-label="Rendered Markdown preview" />
|
|
||||||
<div v-else class="form-floating">
|
|
||||||
<textarea :id="id" class="form-control md-edit" :class="{ 'is-invalid': isInvalid }" rows="10" v-text="text"
|
|
||||||
@input="$emit('update:text', $event.target.value)"></textarea>
|
|
||||||
<div class="invalid-feedback">Please enter some text for {{label}}</div>
|
|
||||||
<label :for="id">{{label}}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from "vue"
|
|
||||||
import { toHtml } from "@/markdown"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
id: string
|
|
||||||
text: string
|
|
||||||
label: string
|
|
||||||
isInvalid?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "update:text", value : string) : void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** Whether to show the Markdown preview */
|
|
||||||
const preview = ref(false)
|
|
||||||
|
|
||||||
/** The HTML rendered for preview purposes */
|
|
||||||
const previewHtml = ref("")
|
|
||||||
|
|
||||||
/** Show the Markdown source */
|
|
||||||
const showMarkdown = () => {
|
|
||||||
preview.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show the Markdown preview */
|
|
||||||
const showPreview = () => {
|
|
||||||
previewHtml.value = toHtml(props.text)
|
|
||||||
preview.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Button classes for the selected button */
|
|
||||||
const selected = "btn btn-primary btn-sm rounded-pill"
|
|
||||||
|
|
||||||
/** Button classes for the unselected button */
|
|
||||||
const unselected = "btn btn-outline-secondary btn-sm rounded-pill"
|
|
||||||
|
|
||||||
/** The CSS class for the Markdown source button */
|
|
||||||
const sourceClass = computed(() => preview.value ? unselected : selected)
|
|
||||||
|
|
||||||
/** The CSS class for the Markdown preview button */
|
|
||||||
const previewClass = computed(() => preview.value ? selected : unselected)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
.md-edit
|
|
||||||
width: 100%
|
|
||||||
// When wrapping this with Bootstrap's floating label, it shrinks the input down to what a normal one-line input
|
|
||||||
// would be; this overrides that for the textarea in this component specifically
|
|
||||||
height: inherit !important
|
|
||||||
</style>
|
|
|
@ -1,67 +0,0 @@
|
||||||
<template>
|
|
||||||
<div id="maybeSaveModal" class="modal fade" tabindex="-1" aria-labelledby="maybeSaveLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header"><h5 id="maybeSaveLabel" class="modal-title">Unsaved Changes</h5></div>
|
|
||||||
<div class="modal-body">
|
|
||||||
You have modified the data on this page since it was last saved. What would you like to do?
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" type="button" @click.prevent="close">Stay on This Page</button>
|
|
||||||
<button class="btn btn-primary" type="button" @click.prevent="save">Save Changes</button>
|
|
||||||
<button class="btn btn-danger" type="button" @click.prevent="discard">Discard Changes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref, Ref } from "vue"
|
|
||||||
import { onBeforeRouteLeave, RouteLocationNormalized, useRouter } from "vue-router"
|
|
||||||
import { Validation } from "@vuelidate/core"
|
|
||||||
import { Modal } from "bootstrap"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
saveAction: () => Promise<unknown>
|
|
||||||
validator?: Validation
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
/** Reference to the modal dialog (we can't get it until the component is rendered) */
|
|
||||||
const modal : Ref<Modal | undefined> = ref(undefined)
|
|
||||||
|
|
||||||
/** The route to which navigation was intercepted, and will be resumed */
|
|
||||||
let nextRoute : RouteLocationNormalized
|
|
||||||
|
|
||||||
/** Close the modal window */
|
|
||||||
const close = () => modal.value?.hide()
|
|
||||||
|
|
||||||
/** Save changes and go to the next route */
|
|
||||||
const save = async () => {
|
|
||||||
await props.saveAction()
|
|
||||||
close()
|
|
||||||
router.push(nextRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discard changes and go to the next route */
|
|
||||||
const discard = () => {
|
|
||||||
if (props.validator) props.validator.$reset()
|
|
||||||
close()
|
|
||||||
router.push(nextRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
modal.value = new Modal(document.getElementById("maybeSaveModal") as HTMLElement,
|
|
||||||
{ backdrop: "static", keyboard: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Prompt for save if the user navigates away with unsaved changes */
|
|
||||||
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
|
|
||||||
if (!props.validator || !props.validator.$anyDirty) return true
|
|
||||||
nextRoute = to
|
|
||||||
modal.value?.show()
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
</script>
|
|
|
@ -1,74 +0,0 @@
|
||||||
<template>
|
|
||||||
<div 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"
|
|
||||||
@click.prevent="$emit('remove')"> − </button>
|
|
||||||
</div>
|
|
||||||
<div class="col-10 col-md-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<select :id="`contactType${contact.id}`" class="form-control" :value="contact.contactType"
|
|
||||||
@input="updateValue('contactType', $event.target.value)">
|
|
||||||
<option value="Website">Website</option>
|
|
||||||
<option value="Email">E-mail Address</option>
|
|
||||||
<option value="Phone">Phone Number</option>
|
|
||||||
</select>
|
|
||||||
<label class="jjj-label" :for="`contactType${contact.id}`">Type</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-5">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" :id="`contactName${contact.id}`" class="form-control" maxlength="1000"
|
|
||||||
placeholder="The name of this contact" :value="contact.name"
|
|
||||||
@input="updateValue('name', $event.target.value)">
|
|
||||||
<label class="jjj-label" :for="`contactName${contact.id}`">Name</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Optional; will link sites and e-mail, qualify phone numbers</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-5">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" :id="`contactValue${contact.id}`" class="form-control" maxlength="1000"
|
|
||||||
placeholder="The value forthis contact" :value="contact.value"
|
|
||||||
@input="updateValue('value', $event.target.value)">
|
|
||||||
<label class="jjj-label" :for="`contactValue${contact.id}`">Contact</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">The URL, e-mail address, or phone number</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-offset-md-2 col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" :id="`contactIsPublic${contact.id}`" class="form-check-input" value="true"
|
|
||||||
:checked="contact.isPublic">
|
|
||||||
<label class="form-check-label" :for="`contactIsPublic${contact.id}`">Public</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { OtherContact } from "@/api"
|
|
||||||
import { ref, Ref } from "vue"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: OtherContact
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "input") : void
|
|
||||||
(e: "remove") : void
|
|
||||||
(e: "update:modelValue", value: OtherContact) : void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** The contact being edited */
|
|
||||||
const contact : Ref<OtherContact> = ref({ ...props.modelValue as OtherContact })
|
|
||||||
|
|
||||||
/** Update a value in the model */
|
|
||||||
const updateValue = (key : string, value : string) => {
|
|
||||||
contact.value = { ...contact.value, [key]: value }
|
|
||||||
emit("update:modelValue", contact.value)
|
|
||||||
emit("input")
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
|
@ -1,12 +0,0 @@
|
||||||
import { parseJSON } from "date-fns"
|
|
||||||
import { utcToZonedTime } from "date-fns-tz"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a date from its JSON representation to a UTC-aligned date
|
|
||||||
*
|
|
||||||
* @param date The date string in JSON from JSON
|
|
||||||
* @returns A UTC JavaScript date
|
|
||||||
*/
|
|
||||||
export function parseToUtc (date : string) : Date {
|
|
||||||
return utcToZonedTime(parseJSON(date), Intl.DateTimeFormat().resolvedOptions().timeZone)
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
<template>
|
|
||||||
<footer>
|
|
||||||
<p class="text-muted">
|
|
||||||
Jobs, Jobs, Jobs v{{appVersion}} • <router-link to="/privacy-policy">Privacy Policy</router-link>
|
|
||||||
• <router-link to="/terms-of-service">Terms of Service</router-link>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { version } from "../../../package.json"
|
|
||||||
|
|
||||||
let appVersion : string = version
|
|
||||||
while (appVersion.endsWith(".0")) {
|
|
||||||
appVersion = appVersion.substring(0, appVersion.length - 2)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
footer
|
|
||||||
display: flex
|
|
||||||
flex-direction: row-reverse
|
|
||||||
p
|
|
||||||
padding-top: 2rem
|
|
||||||
padding-right: .5rem
|
|
||||||
font-style: italic
|
|
||||||
font-size: .8rem
|
|
||||||
</style>
|
|
|
@ -1,113 +0,0 @@
|
||||||
<template>
|
|
||||||
<nav>
|
|
||||||
<template v-if="isLoggedOn">
|
|
||||||
<router-link to="/citizen/dashboard" @click="hide">
|
|
||||||
<icon :icon="mdiViewDashboardVariant" /> Dashboard
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/help-wanted" @click="hide">
|
|
||||||
<icon :icon="mdiNewspaperVariantMultipleOutline" /> Help Wanted!
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/profile/search" @click="hide">
|
|
||||||
<icon :icon="mdiViewListOutline" /> Employment Profiles
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/success-story/list" @click="hide">
|
|
||||||
<icon :icon="mdiThumbUp" /> Success Stories
|
|
||||||
</router-link>
|
|
||||||
<div class="separator"></div>
|
|
||||||
<router-link to="/citizen/account" @click="hide">
|
|
||||||
<icon :icon="mdiAccountEdit" /> My Account
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/listings/mine" @click="hide">
|
|
||||||
<icon :icon="mdiSignText" /> My Job Listings
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/profile/edit" @click="hide">
|
|
||||||
<icon :icon="mdiPencil" /> My Employment Profile
|
|
||||||
</router-link>
|
|
||||||
<div class="separator"></div>
|
|
||||||
<router-link to="/citizen/log-off" @click="hide">
|
|
||||||
<icon :icon="mdiLogoutVariant" /> Log Off
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<router-link to="/" @click="hide">
|
|
||||||
<icon :icon="mdiHome" /> Home
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/profile/seeking" @click="hide">
|
|
||||||
<icon :icon="mdiViewListOutline" /> Job Seekers
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/citizen/log-on" @click="hide">
|
|
||||||
<icon :icon="mdiLoginVariant" /> Log On
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
<router-link to="/how-it-works" @click="hide">
|
|
||||||
<icon :icon="mdiHelpCircleOutline" /> How It Works
|
|
||||||
</router-link>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue"
|
|
||||||
import { useRouter } from "vue-router"
|
|
||||||
import { Offcanvas } from "bootstrap"
|
|
||||||
import {
|
|
||||||
mdiAccountEdit,
|
|
||||||
mdiHelpCircleOutline,
|
|
||||||
mdiHome,
|
|
||||||
mdiLoginVariant,
|
|
||||||
mdiLogoutVariant,
|
|
||||||
mdiNewspaperVariantMultipleOutline,
|
|
||||||
mdiPencil,
|
|
||||||
mdiSignText,
|
|
||||||
mdiThumbUp,
|
|
||||||
mdiViewDashboardVariant,
|
|
||||||
mdiViewListOutline
|
|
||||||
} from "@mdi/js"
|
|
||||||
|
|
||||||
import { useStore } from "@/store"
|
|
||||||
|
|
||||||
import Icon from "@/components/Icon.vue"
|
|
||||||
|
|
||||||
const store = useStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
/** Whether a user is logged in or not */
|
|
||||||
const isLoggedOn = computed(() => store.state.user !== undefined)
|
|
||||||
|
|
||||||
/** The current mobile menu */
|
|
||||||
const menu = computed(() => {
|
|
||||||
const elt = document.getElementById("mobileMenu")
|
|
||||||
return elt ? Offcanvas.getOrCreateInstance(elt) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Hide the offcanvas menu (if it exists) when a link is clicked */
|
|
||||||
const hide = () => { if (menu.value) menu.value.hide() }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
path
|
|
||||||
fill: white
|
|
||||||
path:hover
|
|
||||||
fill: black
|
|
||||||
a:link, a:visited
|
|
||||||
text-decoration: none
|
|
||||||
color: white
|
|
||||||
nav > a
|
|
||||||
display: block
|
|
||||||
width: 100%
|
|
||||||
border-radius: .25rem
|
|
||||||
padding: .5rem
|
|
||||||
margin: .5rem 0
|
|
||||||
font-size: 1rem
|
|
||||||
> i
|
|
||||||
vertical-align: top
|
|
||||||
margin-right: 1rem
|
|
||||||
&.router-link-exact-active
|
|
||||||
background-color: rgba(255, 255, 255, .2)
|
|
||||||
&:hover
|
|
||||||
background-color: rgba(255, 255, 255, .5)
|
|
||||||
color: black
|
|
||||||
text-decoration: none
|
|
||||||
nav > div.separator
|
|
||||||
border-bottom: solid 1px rgba(255, 255, 255, .75)
|
|
||||||
height: 1px
|
|
||||||
</style>
|
|
|
@ -1,49 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="showMobileMenu" id="mobileMenu" class="offcanvas offcanvas-end" tabindex="-1"
|
|
||||||
aria-labelledby="mobileMenuLabel">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<h5 id="mobileMenuLabel">Menu</h5>
|
|
||||||
<button class="btn-close text-reset" type="button" data-bs-dismiss="offcanvas" aria-label="Close" />
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body"><app-links /></div>
|
|
||||||
</div>
|
|
||||||
<aside v-else class="collapse show p-3">
|
|
||||||
<p class="home-link pb-3"><router-link to="/">Jobs, Jobs, Jobs</router-link></p>
|
|
||||||
<p> </p>
|
|
||||||
<app-links />
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
|
||||||
import AppLinks from "./AppLinks.vue"
|
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
|
||||||
|
|
||||||
/** Whether the mobile menu or the desktop menu should be shown */
|
|
||||||
const showMobileMenu = breakpoints.smaller("md")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
aside,
|
|
||||||
#mobileMenu
|
|
||||||
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
|
|
||||||
color: white
|
|
||||||
font-size: 1.2rem
|
|
||||||
aside
|
|
||||||
min-height: 100vh
|
|
||||||
width: 250px
|
|
||||||
min-width: 250px
|
|
||||||
position: sticky
|
|
||||||
top: 0
|
|
||||||
.home-link
|
|
||||||
font-size: 1.2rem
|
|
||||||
text-align: center
|
|
||||||
background-color: rgba(0, 0, 0, .4)
|
|
||||||
margin: -1rem
|
|
||||||
padding: 1rem
|
|
||||||
a:link,
|
|
||||||
a:visited
|
|
||||||
text-decoration: none
|
|
||||||
color: white
|
|
||||||
</style>
|
|
|
@ -1,89 +0,0 @@
|
||||||
<template>
|
|
||||||
<div id="toastHost" aria-live="polite" aria-atomic="true">
|
|
||||||
<div id="toasts" class="toast-container position-absolute p-3 bottom-0 start-50 translate-middle-x"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from "vue"
|
|
||||||
import { Toast } from "bootstrap"
|
|
||||||
|
|
||||||
/** Remove a toast once it's hidden */
|
|
||||||
const removeToast = (event : Event) => (event.target as HTMLDivElement).remove()
|
|
||||||
|
|
||||||
/** Create a toast, add it to the DOM, and show it */
|
|
||||||
const createToast = (level : "success" | "warning" | "danger", message : string, process : string | undefined) => {
|
|
||||||
let header : HTMLDivElement | undefined
|
|
||||||
if (level !== "success") {
|
|
||||||
// Create a heading, optionally including the process that generated the message
|
|
||||||
const heading = (typ : string) : string => {
|
|
||||||
const proc = process ? ` (${process})` : ""
|
|
||||||
return `<span class="me-auto"><strong>${typ.toUpperCase()}</strong>${proc}</span>`
|
|
||||||
}
|
|
||||||
header = document.createElement("div")
|
|
||||||
header.className = "toast-header"
|
|
||||||
header.innerHTML = heading(level === "warning" ? level : "error")
|
|
||||||
// Include a close button, as these will not auto-close
|
|
||||||
const close = document.createElement("button")
|
|
||||||
close.type = "button"
|
|
||||||
close.className = "btn-close"
|
|
||||||
close.setAttribute("data-bs-dismiss", "toast")
|
|
||||||
close.setAttribute("aria-label", "Close")
|
|
||||||
header.appendChild(close)
|
|
||||||
}
|
|
||||||
const body = document.createElement("div")
|
|
||||||
body.className = "toast-body"
|
|
||||||
body.innerHTML = message
|
|
||||||
|
|
||||||
const toastEl = document.createElement("div")
|
|
||||||
toastEl.className = `toast bg-${level} text-white`
|
|
||||||
toastEl.setAttribute("role", "alert")
|
|
||||||
toastEl.setAttribute("aria-live", "assertlive")
|
|
||||||
toastEl.setAttribute("aria-atomic", "true")
|
|
||||||
toastEl.addEventListener("hidden.bs.toast", removeToast)
|
|
||||||
if (header) toastEl.appendChild(header)
|
|
||||||
toastEl.appendChild(body)
|
|
||||||
|
|
||||||
;(document.getElementById("toasts") as HTMLDivElement).appendChild(toastEl)
|
|
||||||
new Toast(toastEl, { autohide: level === "success" }).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a success toast
|
|
||||||
*
|
|
||||||
* @param message The message to be displayed
|
|
||||||
*/
|
|
||||||
export function toastSuccess (message : string) : void {
|
|
||||||
createToast("success", message, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a warning toast
|
|
||||||
*
|
|
||||||
* @param message The message to be displayed
|
|
||||||
* @param process The process which generated the warning (optional)
|
|
||||||
*/
|
|
||||||
export function toastWarning (message : string, process : string | undefined) : void {
|
|
||||||
createToast("warning", message, process)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an error toast
|
|
||||||
*
|
|
||||||
* @param message The message to be displayed
|
|
||||||
* @param process The process which generated the error (optional)
|
|
||||||
*/
|
|
||||||
export function toastError (message : string, process : string | undefined) : void {
|
|
||||||
createToast("danger", message, process)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "AppToaster"
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
#toastHost
|
|
||||||
position: sticky
|
|
||||||
bottom: 0
|
|
||||||
</style>
|
|
|
@ -1,42 +0,0 @@
|
||||||
<template>
|
|
||||||
<nav v-if="showMobileHeader" class="navbar navbar-dark">
|
|
||||||
<span class="navbar-text"><router-link to="/">Jobs, Jobs, Jobs</router-link></span>
|
|
||||||
<button class="btn" data-bs-toggle="offcanvas" data-bs-target="#mobileMenu" aria-controls="mobileMenu">
|
|
||||||
<icon :icon="mdiMenu" />
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
<nav v-else class="navbar navbar-light bg-light">
|
|
||||||
<span> </span>
|
|
||||||
<span class="navbar-text">
|
|
||||||
(…and Jobs – <audio-clip clip="pelosi-jobs">Let’s Vote for Jobs!</audio-clip>)
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { mdiMenu } from "@mdi/js"
|
|
||||||
import { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
|
||||||
|
|
||||||
import AudioClip from "@/components/AudioClip.vue"
|
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
|
||||||
|
|
||||||
/** Whether to show the mobile or desktop header */
|
|
||||||
const showMobileHeader = breakpoints.smaller("md")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
.navbar-dark
|
|
||||||
background-image: linear-gradient(0deg, green 0%, darkgreen 70%)
|
|
||||||
padding-left: 1rem
|
|
||||||
padding-right: 1rem
|
|
||||||
button
|
|
||||||
padding: 0
|
|
||||||
.navbar-text
|
|
||||||
font-weight: bold
|
|
||||||
color: white
|
|
||||||
.navbar-light
|
|
||||||
.navbar-text
|
|
||||||
font-style: italic
|
|
||||||
padding: 0 1rem 0 0
|
|
||||||
</style>
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { createApp } from "vue"
|
|
||||||
import App from "./App.vue"
|
|
||||||
import router from "./router"
|
|
||||||
import store, { key } from "./store"
|
|
||||||
import Icon from "./components/Icon.vue"
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
.use(router)
|
|
||||||
.use(store, key)
|
|
||||||
|
|
||||||
app.component("Icon", Icon)
|
|
||||||
|
|
||||||
app.mount("#app")
|
|
|
@ -1,12 +0,0 @@
|
||||||
import { sanitize } from "dompurify"
|
|
||||||
import { marked } from "marked"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform Markdown to HTML (standardize option, sanitize the output)
|
|
||||||
*
|
|
||||||
* @param markdown The Markdown text to be rendered as HTML
|
|
||||||
* @returns The rendered HTML
|
|
||||||
*/
|
|
||||||
export function toHtml (markdown : string) : string {
|
|
||||||
return sanitize(marked(markdown, { gfm: true, smartypants: true }), { USE_PROFILES: { html: true } })
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
import {
|
|
||||||
createRouter,
|
|
||||||
createWebHistory,
|
|
||||||
RouteLocationNormalized,
|
|
||||||
RouteLocationNormalizedLoaded,
|
|
||||||
RouteRecordRaw
|
|
||||||
} from "vue-router"
|
|
||||||
import store, { Mutations } from "@/store"
|
|
||||||
|
|
||||||
/** The URL to which the user should be pointed once they have authorized with Mastodon */
|
|
||||||
export const AFTER_LOG_ON_URL = "jjj-after-log-on-url"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from the query string
|
|
||||||
*
|
|
||||||
* @param route The current route
|
|
||||||
* @param key The key of the query string value to obtain
|
|
||||||
* @returns The string value, the first of many (if included multiple times), or `undefined` if not present
|
|
||||||
*/
|
|
||||||
export function queryValue (route: RouteLocationNormalizedLoaded, key : string) : string | undefined {
|
|
||||||
const value = route.query[key]
|
|
||||||
if (value) return Array.isArray(value) && value.length > 0 ? value[0]?.toString() : value.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const routes: Array<RouteRecordRaw> = [
|
|
||||||
{
|
|
||||||
path: "/how-it-works",
|
|
||||||
name: "HowItWorks",
|
|
||||||
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue"),
|
|
||||||
meta: { auth: false, title: "How It Works" }
|
|
||||||
},
|
|
||||||
// Citizen URLs
|
|
||||||
{
|
|
||||||
path: "/citizen/account",
|
|
||||||
name: "AccountProfile",
|
|
||||||
component: () => import(/* webpackChunkName: "account" */ "../views/citizen/AccountProfile.vue"),
|
|
||||||
meta: { auth: true, title: "Account Profile" }
|
|
||||||
},
|
|
||||||
// Success Story URLs
|
|
||||||
{
|
|
||||||
path: "/success-story/list",
|
|
||||||
name: "ListStories",
|
|
||||||
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue"),
|
|
||||||
meta: { auth: false, title: "Success Stories" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/success-story/:id/edit",
|
|
||||||
name: "EditStory",
|
|
||||||
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue"),
|
|
||||||
meta: { auth: false, title: "Edit Success Story" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/success-story/:id/view",
|
|
||||||
name: "ViewStory",
|
|
||||||
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue"),
|
|
||||||
meta: { auth: false, title: "Success Story" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(process.env.BASE_URL),
|
|
||||||
// eslint-disable-next-line
|
|
||||||
scrollBehavior (to : RouteLocationNormalized, from : RouteLocationNormalizedLoaded, savedPosition : any) {
|
|
||||||
return savedPosition ?? { top: 0, left: 0 }
|
|
||||||
},
|
|
||||||
routes
|
|
||||||
})
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
router.beforeEach((to : RouteLocationNormalized, from : RouteLocationNormalized) => {
|
|
||||||
if (store.state.user === undefined && to.meta.auth) {
|
|
||||||
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
|
|
||||||
return "/citizen/log-on"
|
|
||||||
}
|
|
||||||
store.commit(Mutations.SetTitle, to.meta.title ?? "")
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
6
src/JobsJobsJobs/App/src/shims-vue.d.ts
vendored
6
src/JobsJobsJobs/App/src/shims-vue.d.ts
vendored
|
@ -1,6 +0,0 @@
|
||||||
/* eslint-disable */
|
|
||||||
declare module "*.vue" {
|
|
||||||
import type { DefineComponent } from "vue"
|
|
||||||
const component: DefineComponent<{}, {}, any>
|
|
||||||
export default component
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
/** Logs a user on to Jobs, Jobs, Jobs */
|
|
||||||
export const LogOn = "logOn"
|
|
||||||
|
|
||||||
/** Ensures that the continent list in the state has been populated */
|
|
||||||
export const EnsureContinents = "ensureContinents"
|
|
|
@ -1,75 +0,0 @@
|
||||||
import { useTitle } from "@vueuse/core"
|
|
||||||
import { InjectionKey } from "vue"
|
|
||||||
import { createStore, Store, useStore as baseUseStore } from "vuex"
|
|
||||||
import api, { Continent, LogOnSuccess } from "../api"
|
|
||||||
import * as Actions from "./actions"
|
|
||||||
import * as Mutations from "./mutations"
|
|
||||||
|
|
||||||
/** The state tracked by the application */
|
|
||||||
export interface State {
|
|
||||||
/** The document's current title */
|
|
||||||
pageTitle : string
|
|
||||||
/** The currently logged-on user */
|
|
||||||
user : LogOnSuccess | undefined
|
|
||||||
/** The state of the log on process */
|
|
||||||
logOnState : string
|
|
||||||
/** All continents (use `ensureContinents` action) */
|
|
||||||
continents : Continent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An injection key to identify this state with Vue */
|
|
||||||
export const key : InjectionKey<Store<State>> = Symbol("VueX Store")
|
|
||||||
|
|
||||||
/** Use this store in component `setup` functions */
|
|
||||||
export function useStore () : Store<State> {
|
|
||||||
return baseUseStore(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The application name */
|
|
||||||
const appName = "Jobs, Jobs, Jobs"
|
|
||||||
|
|
||||||
export default createStore({
|
|
||||||
state: () : State => {
|
|
||||||
return {
|
|
||||||
pageTitle: "",
|
|
||||||
user: undefined,
|
|
||||||
logOnState: "",
|
|
||||||
continents: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
[Mutations.SetTitle]: (state, title : string) => {
|
|
||||||
state.pageTitle = title === "" ? appName : `${title} | ${appName}`
|
|
||||||
useTitle(state.pageTitle)
|
|
||||||
},
|
|
||||||
[Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user },
|
|
||||||
[Mutations.ClearUser]: (state) => { state.user = undefined },
|
|
||||||
[Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message },
|
|
||||||
[Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents }
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
[Actions.LogOn]: async ({ commit }, { form }) => {
|
|
||||||
const logOnResult = await api.citizen.logOn(form)
|
|
||||||
if (typeof logOnResult === "string") {
|
|
||||||
commit(Mutations.SetLogOnState, logOnResult)
|
|
||||||
} else {
|
|
||||||
commit(Mutations.SetLogOnState, "")
|
|
||||||
commit(Mutations.SetUser, logOnResult)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[Actions.EnsureContinents]: async ({ state, commit }) => {
|
|
||||||
if (state.continents.length > 0) return
|
|
||||||
const theSeven = await api.continent.all()
|
|
||||||
if (typeof theSeven === "string") {
|
|
||||||
console.error(theSeven)
|
|
||||||
} else {
|
|
||||||
commit(Mutations.SetContinents, theSeven)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modules: {
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export * as Actions from "./actions"
|
|
||||||
export * as Mutations from "./mutations"
|
|
|
@ -1,14 +0,0 @@
|
||||||
/** Set the page title */
|
|
||||||
export const SetTitle = "setTitle"
|
|
||||||
|
|
||||||
/** Set the logged-on user */
|
|
||||||
export const SetUser = "setUser"
|
|
||||||
|
|
||||||
/** Clear the logged-on user */
|
|
||||||
export const ClearUser = "clearUser"
|
|
||||||
|
|
||||||
/** Set the status of the current log on action */
|
|
||||||
export const SetLogOnState = "setLogOnState"
|
|
||||||
|
|
||||||
/** Set the list of continents */
|
|
||||||
export const SetContinents = "setContinents"
|
|
|
@ -1,203 +0,0 @@
|
||||||
<template>
|
|
||||||
<article>
|
|
||||||
<h3>How It Works</h3>
|
|
||||||
<h5 class="pb-3 text-muted fst-italic">Last Updated August 29<sup>th</sup>, 2021</h5>
|
|
||||||
<p class="fst-italic">
|
|
||||||
Show me how to <a href="#listing-search">find a job</a>
|
|
||||||
• <a href="#listing">list a job opportunity</a>
|
|
||||||
• <a href="#profile-search">find people to hire</a>
|
|
||||||
• <a href="#profile">create an employment profile</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4 id="listing-search">Find a Job Listing</h4>
|
|
||||||
<p>
|
|
||||||
Active job listings are found on the <ref-page>Help Wanted!</ref-page> page. When you first bring up this page,
|
|
||||||
you will see several criteria by which you can narrow your results, though none are required. When you click the
|
|
||||||
<ref-button>Search</ref-button> button, you will see open job listings filtered by whatever criteria you
|
|
||||||
specified. Each job displays its title, its location, whether it is a remote opportunity, and (if specified) the
|
|
||||||
date by which the job needs to be filled.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Clicking the <ref-page>View</ref-page> link on a listing brings up the full view page for a listing. This page
|
|
||||||
displays all of the information from the search results, along with the citizen who posted it, and the full
|
|
||||||
details of the job. The citizen’s name is a link to their profile page at their Mastodon instance; you can
|
|
||||||
use that to get their handle, and use Mastodon’s communication facilites to inquire about the position.
|
|
||||||
</p>
|
|
||||||
<p class="fst-italic text-muted">
|
|
||||||
(If you know of a way to construct a link to Mastodon that would start a direct message, please reach out;
|
|
||||||
I’ve searched and searched, and asked NAS, but have not yet determined how to do that.)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4 id="listing">Job Listings</h4>
|
|
||||||
<h5>Create a Job Listing</h5>
|
|
||||||
<p>
|
|
||||||
The <ref-page>My Job Listings</ref-page> page shows all of the job listings you have created. To add a new one,
|
|
||||||
click the <ref-button>Add a New Listing</ref-button> button. This page allows you to specify a title for the
|
|
||||||
listing; the continent and region; whether it is a remote opportunity; the date by which a job needs to be filled;
|
|
||||||
and a full description of the position, using <a href="#markdown">Markdown</a>. Once you save the listing, it will
|
|
||||||
be visible to the other citizens here.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h5>Maintain and Share Your Job Listings</h5>
|
|
||||||
<p>
|
|
||||||
The <ref-page>My Job Listings</ref-page> page will show you all of your active job listings just below the
|
|
||||||
<ref-button>Add a Job Listing</ref-button> button. Within this table, you can edit the listing, view it, or expire
|
|
||||||
it (more on that below). The <ref-page>View</ref-page> link will show you the job listing just as other users will
|
|
||||||
see it. You can share the link from your browser on any No Agenda-affiliated Mastodon instance, and those who
|
|
||||||
click on it will be able to view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to
|
|
||||||
authorize this site’s access, but then they will get there as well.)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h5>Expire a Job Listing</h5>
|
|
||||||
<p>
|
|
||||||
Once the job is filled, or the opportunity has passed, you will want to expire the listing; this is what the
|
|
||||||
<ref-page>Expire</ref-page> link allows you to do. When you click it, you will be presented with a single question
|
|
||||||
– was the job filled due to its listing here? If not, leave that blank, click the
|
|
||||||
<ref-button>Expire</ref-button> button, and the listing will be expired. If you click that box, though, another
|
|
||||||
Markdown editor will appear, where you can share a story of the experience. This is not required, but if you put
|
|
||||||
text there, it will be recorded as a Success Story, and other users will be able to read about your success.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Once you have at least one expired job listing, the <ref-page>My Job Listing</ref-page> page will have a new
|
|
||||||
section below your active listings, where you can see your expired ones. You can still view the expired listing,
|
|
||||||
and links that you may have shared will still pull up the listing; there will be an “expired” label
|
|
||||||
beside the title, so that whoever is viewing it knows that they are reading about a job that is no longer
|
|
||||||
available.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4 id="profile-search">Searching Profiles</h4>
|
|
||||||
<p>
|
|
||||||
The <ref-page>Employment Profiles</ref-page> link at the side allows you to search for profiles by continent, the
|
|
||||||
citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If
|
|
||||||
you find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile
|
|
||||||
links to their Mastodon profile, where you can use its features to get in touch.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4 id="profile">Your Employment Profile</h4>
|
|
||||||
<p>
|
|
||||||
The employment profile is your résumé, visible to other citizens here. It also allows you to specify
|
|
||||||
your real name, if you so desire; if that is filled in, that is how you will be identified in search results,
|
|
||||||
profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your
|
|
||||||
current display name each time you log on.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h5>Completing Your Profile</h5>
|
|
||||||
<p>
|
|
||||||
The <ref-page>My Employment Profile</ref-page> page lets you establish or modify your employment profile; the
|
|
||||||
<ref-page>Dashboard</ref-page> page also has buttons that let you create, edit, and view your profile.
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
The <ref-page>Professional Biography</ref-page> is the “Objective” part of a traditional
|
|
||||||
résumé. This section supports <a href="#markdown">Markdown</a>, so you can include actual
|
|
||||||
headings, formatting, etc.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Skills are optional, but they are the place to record skills you have. Along with each skill, there is a
|
|
||||||
<ref-page>Notes</ref-page> field, which can be used to indicate the time you’ve practiced a particular
|
|
||||||
skill, the mastery you have of that skill, etc. It is free-form text, so it is all up to you how you utilize
|
|
||||||
the field.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The <ref-page>Experience</ref-page> field is intended to capture a chronological or topical employment history.
|
|
||||||
This Markdown space can be used to capture chronological history, certifications, or any other information
|
|
||||||
– however you would like it presented to fellow citizens.
|
|
||||||
<em class="text-muted">(If you would like a chronological job builder, reach out and let us know.)</em>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
If you check the <ref-page>Allow my profile to be searched publicly</ref-page> checkbox <strong>and</strong> you
|
|
||||||
are seeking employment, your continent, region, and skills fields will be searchable and displayed to public
|
|
||||||
users of the site. They will not be tied to your Mastodon handle or real name; they are there to let people peek
|
|
||||||
behind the curtain a bit, and hopefully inspire them to join us.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h5>Viewing and Sharing Your Profile</h5>
|
|
||||||
<p>
|
|
||||||
Once your profile has been established, the <ref-page>My Employment Profile</ref-page> page will have a button at
|
|
||||||
the bottom that will let you view your profile the way all other validated users will be able to see it. (There
|
|
||||||
will also be a link to this page from the <ref-page>Dashboard</ref-page>.) The URL of this page can be shared on
|
|
||||||
any No Agenda-affiliated Mastodon instance, if you would like to share it there. Just as with job listings,
|
|
||||||
existing users will go straight there, while others will get there once they authorize this application.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The name on employment profiles is a link to that user’s profile on their Mastodon instance; from there,
|
|
||||||
others can communicate further with you using the tools Mastodon provides.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h5>“I Found a Job!”</h5>
|
|
||||||
<p>
|
|
||||||
If your profile indicates that you are seeking employment, and you secure employment, that is something you will
|
|
||||||
want to update (and – congratulations!). From both the <ref-page>Dashboard</ref-page> and
|
|
||||||
<ref-page>My Employment Profile</ref-page> pages, you will see a link that encourages you to tell us about it.
|
|
||||||
Click either of those links, and you will be brought to a page that allows you to indicate whether your employment
|
|
||||||
actually came from someone finding your profile on Jobs, Jobs, Jobs, and gives you a place to write about the
|
|
||||||
experience. These stories are only viewable by validated users, so feel free to use as much (or as little)
|
|
||||||
identifying information as you’d like. You can also submit this page with all the fields blank; in that
|
|
||||||
case, your “Seeking Employment” flag is cleared, and the blank story is recorded.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
As a validated user, you can also view others success stories. Clicking <ref-page>Success Stories</ref-page> in
|
|
||||||
the sidebar will display a list of all the stories that have been recorded. If there is a story to be read, there
|
|
||||||
will be a link to read it; if you submitted the story, there will also be an <ref-page>Edit</ref-page> link.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h5>Publicly Available Information</h5>
|
|
||||||
<p>
|
|
||||||
The <ref-page>Job Seekers</ref-page> page for profile information will allow users to search for and display the
|
|
||||||
continent, region, skills, and notes of users who are seeking employment <strong>and</strong> have opted in to
|
|
||||||
their information being publicly searchable. If you are a public user, this information is always the latest we
|
|
||||||
have; check out the link at the top of the search results for how you can learn more about these fine human
|
|
||||||
resources!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4 id="markdown">A Bit about Markdown</h4>
|
|
||||||
<p>
|
|
||||||
Markdown is a plain-text way to specify formatting quite similar to that provided by word processors. The
|
|
||||||
<a href="https://daringfireball.net/projects/markdown/" target="_blank" rel="noopener">original page</a> for the
|
|
||||||
project is a good good overview of its capabilities, and the pages at
|
|
||||||
<a href="https://www.markdownguide.org/" target="_blank" rel="noopener">Markdown Guide</a> give in-depth lessons
|
|
||||||
to make the most of this language. The version of Markdown employed here supports many popular extensions, include
|
|
||||||
smart quotes (turning "a quote" into “a quote”), tables, super/subscripts, and more.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4>Help / Suggestions</h4>
|
|
||||||
<p>
|
|
||||||
This is open-source software
|
|
||||||
<a href="https://github.com/bit-badger/jobs-jobs-jobs" _target="_blank" rel="noopener">developed on Github</a>];
|
|
||||||
feel free to <a href="https://github.com/bit-badger/jobs-jobs-jobs/issues" target="_blank" rel="noopener">create
|
|
||||||
an issue there</a>, or look up @danieljsummers on No Agenda Social.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="sass" scoped>
|
|
||||||
ref-page
|
|
||||||
background-color: rgba(144, 238, 144, .25)
|
|
||||||
ref-button
|
|
||||||
border: solid 1px lightgreen
|
|
||||||
border-radius: .25rem
|
|
||||||
ref-page,
|
|
||||||
ref-button
|
|
||||||
padding: 0 .25rem
|
|
||||||
span.link
|
|
||||||
background-color: rgba(144, 238, 144, .25)
|
|
||||||
span.button
|
|
||||||
border: solid 1px lightgreen
|
|
||||||
border-radius: .25rem
|
|
||||||
span.link,
|
|
||||||
span.button
|
|
||||||
padding: 0 .25rem
|
|
||||||
</style>
|
|
|
@ -1,181 +0,0 @@
|
||||||
<template>
|
|
||||||
<article>
|
|
||||||
<h3 class="pb-3">Account Profile</h3>
|
|
||||||
<p>
|
|
||||||
This information is visible to all fellow logged-on citizens. For publicly-visible employment profiles and job
|
|
||||||
listings, the “Display Name” fields and any public contacts will be displayed.
|
|
||||||
</p>
|
|
||||||
<load-data :load="retrieveData">
|
|
||||||
<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="password" id="newPassword"
|
|
||||||
:class="{ 'form-control': true, 'is-invalid': v$.newPassword.$error }"
|
|
||||||
v-model="v$.newPassword.$model" placeholder="Password">
|
|
||||||
<div class="invalid-feedback">Password must be at least 8 characters long</div>
|
|
||||||
<label for="newPassword">New Password</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Leave blank to keep your current password</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-xl-4">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="password" id="newPasswordConfirm"
|
|
||||||
:class="{ 'form-control': true, 'is-invalid': v$.newPasswordConfirm.$error }"
|
|
||||||
v-model="v$.newPasswordConfirm.$model" placeholder="Confirm Password">
|
|
||||||
<div class="invalid-feedback">The passwords do not match</div>
|
|
||||||
<label for="newPasswordConfirm">Confirm New Password</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Leave blank to keep your current password</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<hr>
|
|
||||||
<h4 class="pb-2">
|
|
||||||
Ways to Be Contacted
|
|
||||||
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addContact">
|
|
||||||
Add a Contact Method
|
|
||||||
</button>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<contact-edit v-for="(contact, idx) in accountForm.contacts" :key="contact.id"
|
|
||||||
v-model="accountForm.contacts[idx]" @remove="removeContact(contact.id)"
|
|
||||||
@input="v$.contacts.$touch" />
|
|
||||||
<div class="col-12">
|
|
||||||
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
|
|
||||||
<button class="btn btn-primary" @click.prevent="saveAccount(false)">
|
|
||||||
<icon :icon="mdiContentSaveOutline" /> Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</load-data>
|
|
||||||
<hr>
|
|
||||||
<p class="text-muted fst-italic">
|
|
||||||
(If you want to delete your profile, or your entire account,
|
|
||||||
<router-link to="/so-long/options">see your deletion options here</router-link>.)
|
|
||||||
</p>
|
|
||||||
<maybe-save :saveAction="() => saveAccount(true)" :validator="v$" />
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive } from "vue"
|
|
||||||
import { mdiContentSaveOutline } from "@mdi/js"
|
|
||||||
import useVuelidate from "@vuelidate/core"
|
|
||||||
import { minLength, required, sameAs } from "@vuelidate/validators"
|
|
||||||
|
|
||||||
import api, { AccountProfileForm, Citizen, LogOnSuccess } from "@/api"
|
|
||||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
|
||||||
import { useStore } from "@/store"
|
|
||||||
|
|
||||||
import LoadData from "@/components/LoadData.vue"
|
|
||||||
import MaybeSave from "@/components/MaybeSave.vue"
|
|
||||||
import ContactEdit from "@/components/citizen/ContactEdit.vue"
|
|
||||||
|
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
/** The currently logged-on user */
|
|
||||||
const user = store.state.user as LogOnSuccess
|
|
||||||
|
|
||||||
/** The information available to update */
|
|
||||||
const accountForm = reactive(new AccountProfileForm())
|
|
||||||
|
|
||||||
/** The validation rules for the form */
|
|
||||||
const rules = computed(() => ({
|
|
||||||
firstName: { required },
|
|
||||||
lastName: { required },
|
|
||||||
displayName: { },
|
|
||||||
newPassword: { length: minLength(8) },
|
|
||||||
newPasswordConfirm: { matchPassword: sameAs(accountForm.newPassword) },
|
|
||||||
contacts: { }
|
|
||||||
}))
|
|
||||||
|
|
||||||
/** Initialize form validation */
|
|
||||||
const v$ = useVuelidate(rules, accountForm, { $lazy: true })
|
|
||||||
|
|
||||||
/** The ID for new contacts */
|
|
||||||
let newContactId = 0
|
|
||||||
|
|
||||||
/** Add a contact to the profile */
|
|
||||||
const addContact = () => {
|
|
||||||
accountForm.contacts.push({
|
|
||||||
id: `new${newContactId++}`,
|
|
||||||
contactType: "Website",
|
|
||||||
name: undefined,
|
|
||||||
value: "",
|
|
||||||
isPublic: false
|
|
||||||
})
|
|
||||||
v$.value.contacts.$touch()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove the given contact from the profile */
|
|
||||||
const removeContact = (contactId : string) => {
|
|
||||||
accountForm.contacts = accountForm.contacts.filter(c => c.id !== contactId)
|
|
||||||
v$.value.contacts.$touch()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retrieve the account profile */
|
|
||||||
const retrieveData = async (errors : string[]) => {
|
|
||||||
const citizenResult = await api.citizen.retrieve(user.citizenId, user)
|
|
||||||
if (typeof citizenResult === "string") {
|
|
||||||
errors.push(citizenResult)
|
|
||||||
} else if (typeof citizenResult === "undefined") {
|
|
||||||
errors.push("Citizen not found")
|
|
||||||
} else {
|
|
||||||
// Update the empty form with appropriate values
|
|
||||||
const c = citizenResult as Citizen
|
|
||||||
accountForm.firstName = c.firstName
|
|
||||||
accountForm.lastName = c.lastName
|
|
||||||
accountForm.displayName = c.displayName
|
|
||||||
accountForm.contacts = c.otherContacts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save the account profile */
|
|
||||||
const saveAccount = async (isNavigating: boolean) => {
|
|
||||||
v$.value.$touch()
|
|
||||||
if (v$.value.$error) return
|
|
||||||
// Remove any blank contacts before submitting
|
|
||||||
accountForm.contacts = accountForm.contacts.filter(c => !((c.name?.trim() ?? "") === "" && c.value.trim() === ""))
|
|
||||||
const saveResult = await api.citizen.save(accountForm, user)
|
|
||||||
if (typeof saveResult === "string") {
|
|
||||||
toastError(saveResult, "saving profile")
|
|
||||||
} else {
|
|
||||||
toastSuccess("Account Profile Saved Successfully")
|
|
||||||
if (!isNavigating) {
|
|
||||||
v$.value.$reset()
|
|
||||||
const errors: string[] = []
|
|
||||||
await retrieveData(errors)
|
|
||||||
if (errors.length > 0) {
|
|
||||||
toastError(errors[0], "retrieving updated profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
|
@ -1,40 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "esnext",
|
|
||||||
"module": "esnext",
|
|
||||||
"strict": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"importHelpers": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"types": [
|
|
||||||
"webpack-env"
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lib": [
|
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.tsx",
|
|
||||||
"src/**/*.vue",
|
|
||||||
"tests/**/*.ts",
|
|
||||||
"tests/**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
transpileDependencies: [
|
|
||||||
'vuetify'
|
|
||||||
],
|
|
||||||
outputDir: '../Server/wwwroot',
|
|
||||||
configureWebpack: {
|
|
||||||
module: {
|
|
||||||
rules: [{
|
|
||||||
test: /\.mjs$/,
|
|
||||||
include: /node_modules/,
|
|
||||||
type: "javascript/auto"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user