Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
44 changed files with 0 additions and 25209 deletions
Showing only changes of commit 683506d735 - Show all commits

View File

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View File

@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

View File

@ -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"
}
}

View File

@ -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?

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

File diff suppressed because it is too large Load Diff

View File

@ -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.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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="">&ndash; {{emptyLabel}} &ndash;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,32 +0,0 @@
<template>
<div v-if="loading">Loading&hellip;</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>

View File

@ -1,69 +0,0 @@
<template>
<div class="col-12">
<nav class="nav nav-pills pb-1">
<button :class="sourceClass" @click.prevent="showMarkdown">Markdown</button>
&nbsp;
<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>

View File

@ -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>

View File

@ -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')">&nbsp;&minus;&nbsp;</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>

View File

@ -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)
}

View File

@ -1,28 +0,0 @@
<template>
<footer>
<p class="text-muted">
Jobs, Jobs, Jobs v{{appVersion}} &bull; <router-link to="/privacy-policy">Privacy Policy</router-link>
&bull; <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>

View File

@ -1,113 +0,0 @@
<template>
<nav>
<template v-if="isLoggedOn">
<router-link to="/citizen/dashboard" @click="hide">
<icon :icon="mdiViewDashboardVariant" />&nbsp; Dashboard
</router-link>
<router-link to="/help-wanted" @click="hide">
<icon :icon="mdiNewspaperVariantMultipleOutline" />&nbsp; Help Wanted!
</router-link>
<router-link to="/profile/search" @click="hide">
<icon :icon="mdiViewListOutline" />&nbsp; Employment Profiles
</router-link>
<router-link to="/success-story/list" @click="hide">
<icon :icon="mdiThumbUp" />&nbsp; 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" />&nbsp; My Job Listings
</router-link>
<router-link to="/profile/edit" @click="hide">
<icon :icon="mdiPencil" />&nbsp; My Employment Profile
</router-link>
<div class="separator"></div>
<router-link to="/citizen/log-off" @click="hide">
<icon :icon="mdiLogoutVariant" />&nbsp; Log Off
</router-link>
</template>
<template v-else>
<router-link to="/" @click="hide">
<icon :icon="mdiHome" />&nbsp; Home
</router-link>
<router-link to="/profile/seeking" @click="hide">
<icon :icon="mdiViewListOutline" />&nbsp; Job Seekers
</router-link>
<router-link to="/citizen/log-on" @click="hide">
<icon :icon="mdiLoginVariant" />&nbsp; Log On
</router-link>
</template>
<router-link to="/how-it-works" @click="hide">
<icon :icon="mdiHelpCircleOutline" />&nbsp; 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>

View File

@ -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>&nbsp;</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>

View File

@ -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>

View File

@ -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>&nbsp;</span>
<span class="navbar-text">
(&hellip;and Jobs &ndash; <audio-clip clip="pelosi-jobs">Let&rsquo;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>

View File

@ -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")

View File

@ -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 } })
}

View File

@ -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

View File

@ -1,6 +0,0 @@
/* eslint-disable */
declare module "*.vue" {
import type { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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>
&bull; <a href="#listing">list a job opportunity</a>
&bull; <a href="#profile-search">find people to hire</a>
&bull; <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&rsquo;s name is a link to their profile page at their Mastodon instance; you can
use that to get their handle, and use Mastodon&rsquo;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&rsquo;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&rsquo;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
&ndash; 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 &ldquo;expired&rdquo; 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&rsquo;s desire for remote work, a skill, or any text in their professional biography and experience. If
you find someone with whom you&rsquo;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&eacute;sum&eacute;, 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 &ldquo;Objective&rdquo; part of a traditional
r&eacute;sum&eacute;. 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&rsquo;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
&ndash; 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&rsquo;s profile on their Mastodon instance; from there,
others can communicate further with you using the tools Mastodon provides.
</p>
<h5>&ldquo;I Found a Job!&rdquo;</h5>
<p>
If your profile indicates that you are seeking employment, and you secure employment, that is something you will
want to update (and &ndash; 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&rsquo;d like. You can also submit this page with all the fields blank; in that
case, your &ldquo;Seeking Employment&rdquo; 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 &ldquo;a quote&rdquo;), 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>

View File

@ -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 &ldquo;Display Name&rdquo; 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 &nbsp;
<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" />&nbsp; 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>

View File

@ -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"
]
}

View File

@ -1,15 +0,0 @@
module.exports = {
transpileDependencies: [
'vuetify'
],
outputDir: '../Server/wwwroot',
configureWebpack: {
module: {
rules: [{
test: /\.mjs$/,
include: /node_modules/,
type: "javascript/auto"
}]
}
}
}