Help wanted #23
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -4,3 +4,6 @@ src/**/bin
|
||||||
src/**/obj
|
src/**/obj
|
||||||
src/**/appsettings.*.json
|
src/**/appsettings.*.json
|
||||||
src/.vs
|
src/.vs
|
||||||
|
|
||||||
|
## This stores history of every deployment
|
||||||
|
src/JobsJobsJobs/Server/Properties/PublishProfiles/FolderProfile.pubxml.user
|
||||||
|
|
|
@ -3,39 +3,40 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 16
|
# Visual Studio Version 16
|
||||||
VisualStudioVersion = 16.0.30717.126
|
VisualStudioVersion = 16.0.30717.126
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Server", "JobsJobsJobs\Server\JobsJobsJobs.Server.csproj", "{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Client", "JobsJobsJobs\Client\JobsJobsJobs.Client.csproj", "{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Shared", "JobsJobsJobs\Shared\JobsJobsJobs.Shared.csproj", "{AE329284-47DA-4E76-B542-47489B271130}"
|
|
||||||
EndProject
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50B51580-9F09-41E2-BC78-DAD38C37B583}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50B51580-9F09-41E2-BC78-DAD38C37B583}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
.dockerignore = .dockerignore
|
.dockerignore = .dockerignore
|
||||||
database\12-add-real-name.sql = database\12-add-real-name.sql
|
..\.gitignore = ..\.gitignore
|
||||||
JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props
|
JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props
|
||||||
Dockerfile = Dockerfile
|
Dockerfile = Dockerfile
|
||||||
database\tables.sql = database\tables.sql
|
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}"
|
||||||
|
EndProject
|
||||||
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}"
|
||||||
|
EndProject
|
||||||
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
|
||||||
|
EndProject
|
||||||
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DataMigrate", "JobsJobsJobs\DataMigrate\DataMigrate.fsproj", "{C5774E4F-2930-4B64-8407-77BF7EB79F39}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Release|Any CPU.Build.0 = Release|Any CPU
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{AE329284-47DA-4E76-B542-47489B271130}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{AE329284-47DA-4E76-B542-47489B271130}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
@ -43,4 +44,9 @@ Global
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {5E9ECDBF-634E-43A9-8F89-625A2213831C}
|
SolutionGuid = {5E9ECDBF-634E-43A9-8F89-625A2213831C}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||||
|
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||||
|
{C5774E4F-2930-4B64-8407-77BF7EB79F39} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
|
||||||
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
1
src/JobsJobsJobs/Api/.gitignore
vendored
Normal file
1
src/JobsJobsJobs/Api/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
wwwroot
|
34
src/JobsJobsJobs/Api/Api.fsproj
Normal file
34
src/JobsJobsJobs/Api/Api.fsproj
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net5.0</TargetFramework>
|
||||||
|
<WarnOn>3390;$(WarnOn)</WarnOn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Data.fs" />
|
||||||
|
<Compile Include="Auth.fs" />
|
||||||
|
<Compile Include="Handlers.fs" />
|
||||||
|
<Compile Include="App.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Domain\Domain.fsproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include=".\wwwroot" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Giraffe" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||||
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Polly" Version="7.2.2" />
|
||||||
|
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.11.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
81
src/JobsJobsJobs/Api/App.fs
Normal file
81
src/JobsJobsJobs/Api/App.fs
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
/// The main API application for Jobs, Jobs, Jobs
|
||||||
|
module JobsJobsJobs.Api.App
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Builder
|
||||||
|
open Microsoft.AspNetCore.Hosting
|
||||||
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open Microsoft.Extensions.Hosting
|
||||||
|
open Giraffe
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
|
|
||||||
|
/// Configure the ASP.NET Core pipeline to use Giraffe
|
||||||
|
let configureApp (app : IApplicationBuilder) =
|
||||||
|
app
|
||||||
|
.UseCors(fun p -> p.AllowAnyOrigin().AllowAnyHeader() |> ignore)
|
||||||
|
.UseStaticFiles()
|
||||||
|
.UseRouting()
|
||||||
|
.UseAuthentication()
|
||||||
|
.UseAuthorization()
|
||||||
|
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
|
||||||
|
.UseEndpoints(fun e ->
|
||||||
|
e.MapGiraffeEndpoints Handlers.allEndpoints
|
||||||
|
e.MapFallbackToFile "index.html" |> ignore)
|
||||||
|
|> ignore
|
||||||
|
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open NodaTime
|
||||||
|
open Microsoft.AspNetCore.Authentication.JwtBearer
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open Microsoft.IdentityModel.Tokens
|
||||||
|
open System.Text
|
||||||
|
|
||||||
|
/// Configure dependency injection
|
||||||
|
let configureServices (svc : IServiceCollection) =
|
||||||
|
svc.AddGiraffe () |> ignore
|
||||||
|
svc.AddSingleton<IClock> SystemClock.Instance |> ignore
|
||||||
|
svc.AddLogging () |> ignore
|
||||||
|
svc.AddCors () |> ignore
|
||||||
|
|
||||||
|
let jsonCfg = JsonSerializerSettings ()
|
||||||
|
Data.Converters.all () |> List.iter jsonCfg.Converters.Add
|
||||||
|
svc.AddSingleton<Json.ISerializer> (NewtonsoftJson.Serializer jsonCfg) |> ignore
|
||||||
|
|
||||||
|
let svcs = svc.BuildServiceProvider ()
|
||||||
|
let cfg = svcs.GetRequiredService<IConfiguration> ()
|
||||||
|
|
||||||
|
svc.AddAuthentication(fun o ->
|
||||||
|
o.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||||
|
o.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme
|
||||||
|
o.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(fun o ->
|
||||||
|
o.RequireHttpsMetadata <- false
|
||||||
|
o.TokenValidationParameters <- TokenValidationParameters (
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = "https://noagendacareers.com",
|
||||||
|
ValidIssuer = "https://noagendacareers.com",
|
||||||
|
IssuerSigningKey = SymmetricSecurityKey (
|
||||||
|
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"]))))
|
||||||
|
|> ignore
|
||||||
|
svc.AddAuthorization () |> ignore
|
||||||
|
|
||||||
|
let dbCfg = cfg.GetSection "Rethink"
|
||||||
|
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
||||||
|
let conn = Data.Startup.createConnection dbCfg log
|
||||||
|
svc.AddSingleton conn |> ignore
|
||||||
|
Data.Startup.establishEnvironment dbCfg log conn |> Data.awaitIgnore
|
||||||
|
|
||||||
|
[<EntryPoint>]
|
||||||
|
let main _ =
|
||||||
|
Host.CreateDefaultBuilder()
|
||||||
|
.ConfigureWebHostDefaults(
|
||||||
|
fun webHostBuilder ->
|
||||||
|
webHostBuilder
|
||||||
|
.Configure(configureApp)
|
||||||
|
.ConfigureServices(configureServices)
|
||||||
|
|> ignore)
|
||||||
|
.Build()
|
||||||
|
.Run ()
|
||||||
|
0
|
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
108
src/JobsJobsJobs/Api/Auth.fs
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
/// Authorization / authentication functions
|
||||||
|
module JobsJobsJobs.Api.Auth
|
||||||
|
|
||||||
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
|
/// The variables we need from the account information we get from No Agenda Social
|
||||||
|
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||||
|
type MastodonAccount () =
|
||||||
|
/// The user name (what we store as naUser)
|
||||||
|
[<JsonPropertyName "username">]
|
||||||
|
member val Username = "" with get, set
|
||||||
|
/// The account name; will be the same as username for local (non-federated) accounts
|
||||||
|
[<JsonPropertyName "acct">]
|
||||||
|
member val AccountName = "" with get, set
|
||||||
|
/// The user's display name as it currently shows on No Agenda Social
|
||||||
|
[<JsonPropertyName "display_name">]
|
||||||
|
member val DisplayName = "" with get, set
|
||||||
|
/// The user's profile URL
|
||||||
|
[<JsonPropertyName "url">]
|
||||||
|
member val Url = "" with get, set
|
||||||
|
|
||||||
|
|
||||||
|
open FSharp.Control.Tasks
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open System
|
||||||
|
open System.Net.Http
|
||||||
|
open System.Net.Http.Headers
|
||||||
|
open System.Net.Http.Json
|
||||||
|
open System.Text.Json
|
||||||
|
|
||||||
|
/// Verify the authorization code with Mastodon and get the user's profile
|
||||||
|
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
|
||||||
|
|
||||||
|
use http = new HttpClient()
|
||||||
|
|
||||||
|
// Use authorization code to get an access token from NAS
|
||||||
|
use! codeResult =
|
||||||
|
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token",
|
||||||
|
{| client_id = cfg.["ClientId"]
|
||||||
|
client_secret = cfg.["Secret"]
|
||||||
|
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"]
|
||||||
|
grant_type = "authorization_code"
|
||||||
|
code = authCode
|
||||||
|
scope = "read"
|
||||||
|
|})
|
||||||
|
match codeResult.IsSuccessStatusCode with
|
||||||
|
| true ->
|
||||||
|
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
||||||
|
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
||||||
|
match tokenResponse with
|
||||||
|
| null ->
|
||||||
|
return Error "Could not parse authorization code result"
|
||||||
|
| _ ->
|
||||||
|
// Use access token to get profile from NAS
|
||||||
|
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"])
|
||||||
|
req.Headers.Authorization <- AuthenticationHeaderValue
|
||||||
|
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
||||||
|
use! profileResult = http.SendAsync req
|
||||||
|
|
||||||
|
match profileResult.IsSuccessStatusCode with
|
||||||
|
| true ->
|
||||||
|
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
||||||
|
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
||||||
|
| null ->
|
||||||
|
return Error "Could not parse profile result"
|
||||||
|
| x when x.Username <> x.AccountName ->
|
||||||
|
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
|
||||||
|
| profile ->
|
||||||
|
return Ok profile
|
||||||
|
| false ->
|
||||||
|
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
||||||
|
| false ->
|
||||||
|
let! err = codeResult.Content.ReadAsStringAsync ()
|
||||||
|
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
||||||
|
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.IdentityModel.Tokens
|
||||||
|
open System.IdentityModel.Tokens.Jwt
|
||||||
|
open System.Security.Claims
|
||||||
|
open System.Text
|
||||||
|
|
||||||
|
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
||||||
|
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
||||||
|
|
||||||
|
let tokenHandler = JwtSecurityTokenHandler ()
|
||||||
|
let token =
|
||||||
|
tokenHandler.CreateToken (
|
||||||
|
SecurityTokenDescriptor (
|
||||||
|
Subject = ClaimsIdentity [|
|
||||||
|
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
|
||||||
|
Claim (ClaimTypes.Name, Citizen.name citizen)
|
||||||
|
|],
|
||||||
|
Expires = DateTime.UtcNow.AddHours 2.,
|
||||||
|
Issuer = "https://noagendacareers.com",
|
||||||
|
Audience = "https://noagendacareers.com",
|
||||||
|
SigningCredentials = SigningCredentials (
|
||||||
|
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
|
||||||
|
SecurityAlgorithms.HmacSha256Signature)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tokenHandler.WriteToken token
|
||||||
|
|
554
src/JobsJobsJobs/Api/Data.fs
Normal file
554
src/JobsJobsJobs/Api/Data.fs
Normal file
|
@ -0,0 +1,554 @@
|
||||||
|
/// Data access functions for Jobs, Jobs, Jobs
|
||||||
|
module JobsJobsJobs.Api.Data
|
||||||
|
|
||||||
|
open FSharp.Control.Tasks
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Polly
|
||||||
|
open RethinkDb.Driver
|
||||||
|
open RethinkDb.Driver.Net
|
||||||
|
|
||||||
|
/// Shorthand for the RethinkDB R variable (how every command starts)
|
||||||
|
let private r = RethinkDB.R
|
||||||
|
|
||||||
|
/// Shorthand for await task / run sync / ignore (used in non-async contexts)
|
||||||
|
let awaitIgnore x = x |> Async.AwaitTask |> Async.RunSynchronously |> ignore
|
||||||
|
|
||||||
|
|
||||||
|
/// JSON converters used with RethinkDB persistence
|
||||||
|
module Converters =
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open Microsoft.FSharpLu.Json
|
||||||
|
open Newtonsoft.Json
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// JSON converter for citizen IDs
|
||||||
|
type CitizenIdJsonConverter() =
|
||||||
|
inherit JsonConverter<CitizenId>()
|
||||||
|
override __.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (CitizenId.toString value)
|
||||||
|
override __.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> CitizenId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for continent IDs
|
||||||
|
type ContinentIdJsonConverter() =
|
||||||
|
inherit JsonConverter<ContinentId>()
|
||||||
|
override __.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (ContinentId.toString value)
|
||||||
|
override __.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> ContinentId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for Markdown strings
|
||||||
|
type MarkdownStringJsonConverter() =
|
||||||
|
inherit JsonConverter<MarkdownString>()
|
||||||
|
override __.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) =
|
||||||
|
let (Text text) = value
|
||||||
|
writer.WriteValue text
|
||||||
|
override __.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> Text) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for listing IDs
|
||||||
|
type ListingIdJsonConverter() =
|
||||||
|
inherit JsonConverter<ListingId>()
|
||||||
|
override __.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (ListingId.toString value)
|
||||||
|
override __.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> ListingId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for skill IDs
|
||||||
|
type SkillIdJsonConverter() =
|
||||||
|
inherit JsonConverter<SkillId>()
|
||||||
|
override __.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (SkillId.toString value)
|
||||||
|
override __.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> SkillId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// JSON converter for success report IDs
|
||||||
|
type SuccessIdJsonConverter() =
|
||||||
|
inherit JsonConverter<SuccessId>()
|
||||||
|
override __.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) =
|
||||||
|
writer.WriteValue (SuccessId.toString value)
|
||||||
|
override __.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) =
|
||||||
|
(string >> SuccessId.ofString) reader.Value
|
||||||
|
|
||||||
|
/// All JSON converters needed for the application
|
||||||
|
let all () = [
|
||||||
|
CitizenIdJsonConverter () :> JsonConverter
|
||||||
|
upcast ContinentIdJsonConverter ()
|
||||||
|
upcast MarkdownStringJsonConverter ()
|
||||||
|
upcast ListingIdJsonConverter ()
|
||||||
|
upcast SkillIdJsonConverter ()
|
||||||
|
upcast SuccessIdJsonConverter ()
|
||||||
|
upcast CompactUnionJsonConverter ()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
/// Table names
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Table =
|
||||||
|
/// The user (citizen of Gitmo Nation) table
|
||||||
|
let Citizen = "citizen"
|
||||||
|
/// The continent table
|
||||||
|
let Continent = "continent"
|
||||||
|
/// The job listing table
|
||||||
|
let Listing = "listing"
|
||||||
|
/// The citizen employment profile table
|
||||||
|
let Profile = "profile"
|
||||||
|
/// The success story table
|
||||||
|
let Success = "success"
|
||||||
|
/// All tables
|
||||||
|
let all () = [ Citizen; Continent; Listing; Profile; Success ]
|
||||||
|
|
||||||
|
|
||||||
|
/// Functions run at startup
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Startup =
|
||||||
|
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open NodaTime
|
||||||
|
open NodaTime.Serialization.JsonNet
|
||||||
|
|
||||||
|
/// Create a RethinkDB connection
|
||||||
|
let createConnection (cfg : IConfigurationSection) (log : ILogger) =
|
||||||
|
|
||||||
|
// Add all required JSON converters
|
||||||
|
Converter.Serializer.ConfigureForNodaTime DateTimeZoneProviders.Tzdb |> ignore
|
||||||
|
Converters.all ()
|
||||||
|
|> List.iter Converter.Serializer.Converters.Add
|
||||||
|
// Read the configuration and create a connection
|
||||||
|
let bldr =
|
||||||
|
seq<Connection.Builder -> Connection.Builder> {
|
||||||
|
yield fun b -> match cfg.["Hostname"] with null -> b | host -> b.Hostname host
|
||||||
|
yield fun b -> match cfg.["Port"] with null -> b | port -> (int >> b.Port) port
|
||||||
|
yield fun b -> match cfg.["AuthKey"] with null -> b | key -> b.AuthKey key
|
||||||
|
yield fun b -> match cfg.["Db"] with null -> b | db -> b.Db db
|
||||||
|
yield fun b -> match cfg.["Timeout"] with null -> b | time -> (int >> b.Timeout) time
|
||||||
|
}
|
||||||
|
|> Seq.fold (fun b step -> step b) (r.Connection ())
|
||||||
|
match log.IsEnabled LogLevel.Debug with
|
||||||
|
| true -> log.LogDebug $"RethinkDB: Connecting to {bldr.Hostname}:{bldr.Port}, database {bldr.Db}"
|
||||||
|
| false -> ()
|
||||||
|
bldr.Connect () :> IConnection
|
||||||
|
|
||||||
|
/// Ensure the data, tables, and indexes that are required exist
|
||||||
|
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task {
|
||||||
|
// Ensure the database exists
|
||||||
|
match cfg.["Db"] |> Option.ofObj with
|
||||||
|
| Some database ->
|
||||||
|
let! dbs = r.DbList().RunResultAsync<string list> conn
|
||||||
|
match dbs |> List.contains database with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
log.LogInformation $"Creating database {database}..."
|
||||||
|
let! _ = r.DbCreate(database).RunWriteAsync conn
|
||||||
|
()
|
||||||
|
| None -> ()
|
||||||
|
// Ensure the tables exist
|
||||||
|
let! tables = r.TableList().RunResultAsync<string list> conn
|
||||||
|
Table.all ()
|
||||||
|
|> List.iter (
|
||||||
|
fun tbl ->
|
||||||
|
match tables |> List.contains tbl with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
log.LogInformation $"Creating {tbl} table..."
|
||||||
|
r.TableCreate(tbl).RunWriteAsync conn |> awaitIgnore)
|
||||||
|
// Ensure the indexes exist
|
||||||
|
let ensureIndexes table indexes = task {
|
||||||
|
let! tblIdxs = r.Table(table).IndexList().RunResultAsync<string list> conn
|
||||||
|
indexes
|
||||||
|
|> List.iter (
|
||||||
|
fun idx ->
|
||||||
|
match tblIdxs |> List.contains idx with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
log.LogInformation $"Creating \"{idx}\" index on {table}"
|
||||||
|
r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore)
|
||||||
|
}
|
||||||
|
do! ensureIndexes Table.Citizen [ "naUser" ]
|
||||||
|
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ]
|
||||||
|
do! ensureIndexes Table.Profile [ "continentId" ]
|
||||||
|
do! ensureIndexes Table.Success [ "citizenId" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Determine if a record type (not nullable) is null
|
||||||
|
let toOption x = match x |> box |> isNull with true -> None | false -> Some x
|
||||||
|
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private Reconnect =
|
||||||
|
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
|
/// Execute a query with a retry policy that will reconnect to RethinkDB if it has gone away
|
||||||
|
let withReconn (conn : IConnection) (f : IConnection -> Task<'T>) =
|
||||||
|
Policy
|
||||||
|
.Handle<ReqlDriverError>()
|
||||||
|
.RetryAsync(System.Action<exn, int> (fun ex _ ->
|
||||||
|
printf "Encountered RethinkDB exception: %s" ex.Message
|
||||||
|
match ex.Message.Contains "socket" with
|
||||||
|
| true ->
|
||||||
|
printf "Reconnecting to RethinkDB"
|
||||||
|
(conn :?> Connection).Reconnect false
|
||||||
|
| false -> ()))
|
||||||
|
.ExecuteAsync(fun () -> f conn)
|
||||||
|
|
||||||
|
/// Execute a query that returns one or none item, using the reconnect logic
|
||||||
|
let withReconnOption (conn : IConnection) (f : IConnection -> Task<'T>) =
|
||||||
|
fun c -> task {
|
||||||
|
let! it = f c
|
||||||
|
return toOption it
|
||||||
|
}
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Execute a query that does not return a result, using the above reconnect logic
|
||||||
|
let withReconnIgnore (conn : IConnection) (f : IConnection -> Task<'T>) =
|
||||||
|
fun c -> task {
|
||||||
|
let! _ = f c
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
|
||||||
|
let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i)%s"
|
||||||
|
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
open RethinkDb.Driver.Ast
|
||||||
|
|
||||||
|
/// Profile data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Profile =
|
||||||
|
|
||||||
|
let count conn =
|
||||||
|
r.Table(Table.Profile)
|
||||||
|
.Count()
|
||||||
|
.RunResultAsync<int64>
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Find a profile by citizen ID
|
||||||
|
let findById (citizenId : CitizenId) conn =
|
||||||
|
r.Table(Table.Profile)
|
||||||
|
.Get(citizenId)
|
||||||
|
.RunResultAsync<Profile>
|
||||||
|
|> withReconnOption conn
|
||||||
|
|
||||||
|
/// Insert or update a profile
|
||||||
|
let save (profile : Profile) conn =
|
||||||
|
r.Table(Table.Profile)
|
||||||
|
.Get(profile.id)
|
||||||
|
.Replace(profile)
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Delete a citizen's profile
|
||||||
|
let delete (citizenId : CitizenId) conn =
|
||||||
|
r.Table(Table.Profile)
|
||||||
|
.Get(citizenId)
|
||||||
|
.Delete()
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Search profiles (logged-on users)
|
||||||
|
let search (srch : ProfileSearch) conn =
|
||||||
|
fun c ->
|
||||||
|
(seq {
|
||||||
|
match srch.continentId with
|
||||||
|
| Some conId ->
|
||||||
|
yield (fun (q : ReqlExpr) ->
|
||||||
|
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
match srch.remoteWork with
|
||||||
|
| "" -> ()
|
||||||
|
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||||
|
match srch.skill with
|
||||||
|
| Some skl ->
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
||||||
|
upcast it.G("skills").Contains (ReqlFunction1(fun s ->
|
||||||
|
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
match srch.bioExperience with
|
||||||
|
| Some text ->
|
||||||
|
let txt = regexContains text
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1(fun it ->
|
||||||
|
upcast it.G("biography").Match(txt).Or (it.G("experience").Match txt))) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
}
|
||||||
|
|> Seq.toList
|
||||||
|
|> List.fold
|
||||||
|
(fun q f -> f q)
|
||||||
|
(r.Table(Table.Profile)
|
||||||
|
.EqJoin("id", r.Table Table.Citizen)
|
||||||
|
.Without(r.HashMap ("right", "id"))
|
||||||
|
.Zip () :> ReqlExpr))
|
||||||
|
.Merge(ReqlFunction1 (fun it ->
|
||||||
|
upcast r
|
||||||
|
.HashMap("displayName",
|
||||||
|
r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||||
|
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||||
|
it.G "naUser"))
|
||||||
|
.With ("citizenId", it.G "id")))
|
||||||
|
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
|
||||||
|
.OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ()))
|
||||||
|
.RunResultAsync<ProfileSearchResult list> c
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
// Search profiles (public)
|
||||||
|
let publicSearch (srch : PublicSearch) conn =
|
||||||
|
fun c ->
|
||||||
|
(seq {
|
||||||
|
match srch.continentId with
|
||||||
|
| Some conId ->
|
||||||
|
yield (fun (q : ReqlExpr) ->
|
||||||
|
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
match srch.region with
|
||||||
|
| Some reg ->
|
||||||
|
yield (fun q ->
|
||||||
|
q.Filter (ReqlFunction1 (fun it -> upcast it.G("region").Match (regexContains reg))) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
match srch.remoteWork with
|
||||||
|
| "" -> ()
|
||||||
|
| _ -> yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||||
|
match srch.skill with
|
||||||
|
| Some skl ->
|
||||||
|
yield (fun q -> q.Filter (ReqlFunction1 (fun it ->
|
||||||
|
upcast it.G("skills").Contains (ReqlFunction1(fun s ->
|
||||||
|
upcast s.G("description").Match (regexContains skl))))) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
}
|
||||||
|
|> Seq.toList
|
||||||
|
|> List.fold
|
||||||
|
(fun q f -> f q)
|
||||||
|
(r.Table(Table.Profile)
|
||||||
|
.EqJoin("continentId", r.Table Table.Continent)
|
||||||
|
.Without(r.HashMap ("right", "id"))
|
||||||
|
.Zip()
|
||||||
|
.Filter(r.HashMap ("isPublic", true)) :> ReqlExpr))
|
||||||
|
.Merge(ReqlFunction1 (fun it ->
|
||||||
|
upcast r
|
||||||
|
.HashMap("skills",
|
||||||
|
it.G("skills").Map (ReqlFunction1 (fun skill ->
|
||||||
|
upcast r.Branch(skill.G("notes").Default_("").Eq "", skill.G "description",
|
||||||
|
skill.G("description").Add(" (").Add(skill.G("notes")).Add ")"))))
|
||||||
|
.With("continent", it.G "name")))
|
||||||
|
.Pluck("continent", "region", "skills", "remoteWork")
|
||||||
|
.RunResultAsync<PublicSearchResult list> c
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Citizen data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
/// Find a citizen by their ID
|
||||||
|
let findById (citizenId : CitizenId) conn =
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.Get(citizenId)
|
||||||
|
.RunResultAsync<Citizen>
|
||||||
|
|> withReconnOption conn
|
||||||
|
|
||||||
|
/// Find a citizen by their No Agenda Social username
|
||||||
|
let findByNaUser (naUser : string) conn =
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
||||||
|
.RunResultAsync<Citizen>
|
||||||
|
|> withReconnOption conn
|
||||||
|
|
||||||
|
/// Add a citizen
|
||||||
|
let add (citizen : Citizen) conn =
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.Insert(citizen)
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Update the display name and last seen on date for a citizen
|
||||||
|
let logOnUpdate (citizen : Citizen) conn =
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.Get(citizen.id)
|
||||||
|
.Update(r.HashMap( nameof citizen.displayName, citizen.displayName)
|
||||||
|
.With (nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Delete a citizen
|
||||||
|
let delete citizenId conn =
|
||||||
|
fun c -> task {
|
||||||
|
do! Profile.delete citizenId c
|
||||||
|
let! _ =
|
||||||
|
r.Table(Table.Success)
|
||||||
|
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||||
|
.Delete()
|
||||||
|
.RunWriteAsync c
|
||||||
|
let! _ =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||||
|
.Delete()
|
||||||
|
.RunWriteAsync c
|
||||||
|
let! _ =
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.Get(citizenId)
|
||||||
|
.Delete()
|
||||||
|
.RunWriteAsync c
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Update a citizen's real name
|
||||||
|
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
||||||
|
r.Table(Table.Citizen)
|
||||||
|
.Get(citizenId)
|
||||||
|
.Update(r.HashMap (nameof realName, realName))
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
|
||||||
|
/// Continent data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Continent =
|
||||||
|
|
||||||
|
/// Get all continents
|
||||||
|
let all conn =
|
||||||
|
r.Table(Table.Continent)
|
||||||
|
.RunResultAsync<Continent list>
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Get a continent by its ID
|
||||||
|
let findById (contId : ContinentId) conn =
|
||||||
|
r.Table(Table.Continent)
|
||||||
|
.Get(contId)
|
||||||
|
.RunResultAsync<Continent>
|
||||||
|
|> withReconnOption conn
|
||||||
|
|
||||||
|
|
||||||
|
/// Job listing data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Listing =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
|
||||||
|
/// Find all job listings posted by the given citizen
|
||||||
|
let findByCitizen (citizenId : CitizenId) conn =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||||
|
.EqJoin("continentId", r.Table Table.Continent)
|
||||||
|
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
||||||
|
.RunResultAsync<ListingForView list>
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Find a listing by its ID
|
||||||
|
let findById (listingId : ListingId) conn =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.Get(listingId)
|
||||||
|
.RunResultAsync<Listing>
|
||||||
|
|> withReconnOption conn
|
||||||
|
|
||||||
|
/// Find a listing by its ID for viewing (includes continent information)
|
||||||
|
let findByIdForView (listingId : ListingId) conn =
|
||||||
|
fun c -> task {
|
||||||
|
let! listing =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.Filter(r.HashMap ("id", listingId))
|
||||||
|
.EqJoin("continentId", r.Table Table.Continent)
|
||||||
|
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
||||||
|
.RunResultAsync<ListingForView list> c
|
||||||
|
return List.tryHead listing
|
||||||
|
}
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
/// Add a listing
|
||||||
|
let add (listing : Listing) conn =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.Insert(listing)
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Update a listing
|
||||||
|
let update (listing : Listing) conn =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.Get(listing.id)
|
||||||
|
.Replace(listing)
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Expire a listing
|
||||||
|
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.Get(listingId)
|
||||||
|
.Update(r.HashMap("isExpired", true).With("wasFilledHere", fromHere).With ("updatedOn", now))
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
/// Search job listings
|
||||||
|
let search (srch : ListingSearch) conn =
|
||||||
|
fun c ->
|
||||||
|
(seq {
|
||||||
|
match srch.continentId with
|
||||||
|
| Some conId ->
|
||||||
|
yield (fun (q : ReqlExpr) ->
|
||||||
|
q.Filter (r.HashMap (nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
match srch.region with
|
||||||
|
| Some rgn ->
|
||||||
|
yield (fun q ->
|
||||||
|
q.Filter (ReqlFunction1 (fun it ->
|
||||||
|
upcast it.G(nameof srch.region).Match (regexContains rgn))) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
match srch.remoteWork with
|
||||||
|
| "" -> ()
|
||||||
|
| _ ->
|
||||||
|
yield (fun q -> q.Filter (r.HashMap (nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||||
|
match srch.text with
|
||||||
|
| Some text ->
|
||||||
|
yield (fun q ->
|
||||||
|
q.Filter (ReqlFunction1 (fun it ->
|
||||||
|
upcast it.G(nameof srch.text).Match (regexContains text))) :> ReqlExpr)
|
||||||
|
| None -> ()
|
||||||
|
}
|
||||||
|
|> Seq.toList
|
||||||
|
|> List.fold
|
||||||
|
(fun q f -> f q)
|
||||||
|
(r.Table(Table.Listing)
|
||||||
|
.GetAll(false).OptArg ("index", "isExpired") :> ReqlExpr))
|
||||||
|
.EqJoin("continentId", r.Table Table.Continent)
|
||||||
|
.Map(ReqlFunction1 (fun it -> upcast r.HashMap("listing", it.G "left").With ("continent", it.G "right")))
|
||||||
|
.RunResultAsync<ListingForView list> c
|
||||||
|
|> withReconn conn
|
||||||
|
|
||||||
|
|
||||||
|
/// Success story data access functions
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Success =
|
||||||
|
|
||||||
|
/// Find a success report by its ID
|
||||||
|
let findById (successId : SuccessId) conn =
|
||||||
|
r.Table(Table.Success)
|
||||||
|
.Get(successId)
|
||||||
|
.RunResultAsync<Success>
|
||||||
|
|> withReconnOption conn
|
||||||
|
|
||||||
|
/// Insert or update a success story
|
||||||
|
let save (success : Success) conn =
|
||||||
|
r.Table(Table.Success)
|
||||||
|
.Get(success.id)
|
||||||
|
.Replace(success)
|
||||||
|
.RunWriteAsync
|
||||||
|
|> withReconnIgnore conn
|
||||||
|
|
||||||
|
// Retrieve all success stories
|
||||||
|
let all conn =
|
||||||
|
r.Table(Table.Success)
|
||||||
|
.EqJoin("citizenId", r.Table Table.Citizen)
|
||||||
|
.Without(r.HashMap ("right", "id"))
|
||||||
|
.Zip()
|
||||||
|
.Merge(ReqlFunction1 (fun it ->
|
||||||
|
upcast r
|
||||||
|
.HashMap("citizenName",
|
||||||
|
r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName",
|
||||||
|
it.G("displayName").Default_("").Ne "", it.G "displayName",
|
||||||
|
it.G "naUser"))
|
||||||
|
.With ("hasStory", it.G("story").Default_("").Gt "")))
|
||||||
|
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
|
||||||
|
.OrderBy(r.Desc "recordedOn")
|
||||||
|
.RunResultAsync<StoryEntry list>
|
||||||
|
|> withReconn conn
|
535
src/JobsJobsJobs/Api/Handlers.fs
Normal file
535
src/JobsJobsJobs/Api/Handlers.fs
Normal file
|
@ -0,0 +1,535 @@
|
||||||
|
/// Route handlers for Giraffe endpoints
|
||||||
|
module JobsJobsJobs.Api.Handlers
|
||||||
|
|
||||||
|
open FSharp.Control.Tasks
|
||||||
|
open Giraffe
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
|
||||||
|
/// Handler to return the files required for the Vue client app
|
||||||
|
module Vue =
|
||||||
|
|
||||||
|
/// Handler that returns index.html (the Vue client app)
|
||||||
|
let app = htmlFile "wwwroot/index.html"
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for error conditions
|
||||||
|
module Error =
|
||||||
|
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
|
/// URL prefixes for the Vue app
|
||||||
|
let vueUrls = [
|
||||||
|
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
||||||
|
"/so-long"; "/success-story"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Handler that will return a status code 404 and the text "Not Found"
|
||||||
|
let notFound : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
let fac = ctx.GetService<ILoggerFactory>()
|
||||||
|
let log = fac.CreateLogger("Handler")
|
||||||
|
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||||
|
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) ->
|
||||||
|
log.LogInformation "Returning Vue app"
|
||||||
|
return! Vue.app next ctx
|
||||||
|
| _ ->
|
||||||
|
log.LogInformation "Returning 404"
|
||||||
|
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler that returns a 403 NOT AUTHORIZED response
|
||||||
|
let notAuthorized : HttpHandler =
|
||||||
|
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||||
|
|
||||||
|
/// Handler to log 500s and return a message we can display in the application
|
||||||
|
let unexpectedError (ex: exn) (log : ILogger) =
|
||||||
|
log.LogError(ex, "An unexpected error occurred")
|
||||||
|
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||||
|
|
||||||
|
|
||||||
|
/// Helper functions
|
||||||
|
[<AutoOpen>]
|
||||||
|
module Helpers =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open RethinkDb.Driver.Net
|
||||||
|
open System.Security.Claims
|
||||||
|
|
||||||
|
/// Get the NodaTime clock from the request context
|
||||||
|
let clock (ctx : HttpContext) = ctx.GetService<IClock> ()
|
||||||
|
|
||||||
|
/// Get the application configuration from the request context
|
||||||
|
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||||
|
|
||||||
|
/// Get the logger factory from the request context
|
||||||
|
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||||
|
|
||||||
|
/// Get the RethinkDB connection from the request context
|
||||||
|
let conn (ctx : HttpContext) = ctx.GetService<IConnection> ()
|
||||||
|
|
||||||
|
/// `None` if a `string option` is `None`, whitespace, or empty
|
||||||
|
let noneIfBlank (s : string option) =
|
||||||
|
s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
|
||||||
|
|
||||||
|
/// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
|
||||||
|
let noneIfEmpty = Option.ofObj >> noneIfBlank
|
||||||
|
|
||||||
|
/// Try to get the current user
|
||||||
|
let tryUser (ctx : HttpContext) =
|
||||||
|
ctx.User.FindFirst ClaimTypes.NameIdentifier
|
||||||
|
|> Option.ofObj
|
||||||
|
|> Option.map (fun x -> x.Value)
|
||||||
|
|
||||||
|
/// Require a user to be logged in
|
||||||
|
let authorize : HttpHandler =
|
||||||
|
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
|
||||||
|
|
||||||
|
/// Get the ID of the currently logged in citizen
|
||||||
|
// NOTE: if no one is logged in, this will raise an exception
|
||||||
|
let currentCitizenId = tryUser >> Option.get >> CitizenId.ofString
|
||||||
|
|
||||||
|
/// Return an empty OK response
|
||||||
|
let ok : HttpHandler = Successful.OK ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/citizen routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Citizen =
|
||||||
|
|
||||||
|
// GET: /api/citizen/log-on/[code]
|
||||||
|
let logOn authCode : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
// Step 1 - Verify with Mastodon
|
||||||
|
let cfg = (config ctx).GetSection "Auth"
|
||||||
|
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
||||||
|
|
||||||
|
match! Auth.verifyWithMastodon authCode cfg log with
|
||||||
|
| Ok account ->
|
||||||
|
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let! citizen = task {
|
||||||
|
match! Data.Citizen.findByNaUser account.Username dbConn with
|
||||||
|
| None ->
|
||||||
|
let it : Citizen =
|
||||||
|
{ id = CitizenId.create ()
|
||||||
|
naUser = account.Username
|
||||||
|
displayName = noneIfEmpty account.DisplayName
|
||||||
|
realName = None
|
||||||
|
profileUrl = account.Url
|
||||||
|
joinedOn = now
|
||||||
|
lastSeenOn = now
|
||||||
|
}
|
||||||
|
do! Data.Citizen.add it dbConn
|
||||||
|
return it
|
||||||
|
| Some citizen ->
|
||||||
|
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
||||||
|
do! Data.Citizen.logOnUpdate it dbConn
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 - Generate JWT
|
||||||
|
return!
|
||||||
|
json
|
||||||
|
{ jwt = Auth.createJwt citizen cfg
|
||||||
|
citizenId = CitizenId.toString citizen.id
|
||||||
|
name = Citizen.name citizen
|
||||||
|
} next ctx
|
||||||
|
| Error err ->
|
||||||
|
return! RequestErrors.BAD_REQUEST err next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/citizen/[id]
|
||||||
|
let get citizenId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.Citizen.findById (CitizenId citizenId) (conn ctx) with
|
||||||
|
| Some citizen -> return! json citizen next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: /api/citizen
|
||||||
|
let delete : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
do! Data.Citizen.delete (currentCitizenId ctx) (conn ctx)
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/continent routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Continent =
|
||||||
|
|
||||||
|
// GET: /api/continent/all
|
||||||
|
let all : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
let! continents = Data.Continent.all (conn ctx)
|
||||||
|
return! json continents next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/listing[s] routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Listing =
|
||||||
|
|
||||||
|
open NodaTime
|
||||||
|
open System
|
||||||
|
|
||||||
|
/// Parse the string we receive from JSON into a NodaTime local date
|
||||||
|
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
|
||||||
|
|
||||||
|
// GET: /api/listings/mine
|
||||||
|
let mine : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
|
||||||
|
return! json listings next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/listing/[id]
|
||||||
|
let get listingId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.Listing.findById (ListingId listingId) (conn ctx) with
|
||||||
|
| Some listing -> return! json listing next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/listing/view/[id]
|
||||||
|
let view listingId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with
|
||||||
|
| Some listing -> return! json listing next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /listings
|
||||||
|
let add : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
do! Data.Listing.add
|
||||||
|
{ id = ListingId.create ()
|
||||||
|
citizenId = currentCitizenId ctx
|
||||||
|
createdOn = now
|
||||||
|
title = form.title
|
||||||
|
continentId = ContinentId.ofString form.continentId
|
||||||
|
region = form.region
|
||||||
|
remoteWork = form.remoteWork
|
||||||
|
isExpired = false
|
||||||
|
updatedOn = now
|
||||||
|
text = Text form.text
|
||||||
|
neededBy = (form.neededBy |> Option.map parseDate)
|
||||||
|
wasFilledHere = None
|
||||||
|
} (conn ctx)
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: /api/listing/[id]
|
||||||
|
let update listingId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let dbConn = conn ctx
|
||||||
|
match! Data.Listing.findById (ListingId listingId) dbConn with
|
||||||
|
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||||
|
| Some listing ->
|
||||||
|
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||||
|
do! Data.Listing.update
|
||||||
|
{ listing with
|
||||||
|
title = form.title
|
||||||
|
continentId = ContinentId.ofString form.continentId
|
||||||
|
region = form.region
|
||||||
|
remoteWork = form.remoteWork
|
||||||
|
text = Text form.text
|
||||||
|
neededBy = form.neededBy |> Option.map parseDate
|
||||||
|
updatedOn = (clock ctx).GetCurrentInstant ()
|
||||||
|
} dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH: /api/listing/[id]
|
||||||
|
let expire listingId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let now = clock(ctx).GetCurrentInstant ()
|
||||||
|
match! Data.Listing.findById (ListingId listingId) dbConn with
|
||||||
|
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
|
||||||
|
| Some listing ->
|
||||||
|
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
|
||||||
|
do! Data.Listing.expire listing.id form.fromHere now dbConn
|
||||||
|
match form.successStory with
|
||||||
|
| Some storyText ->
|
||||||
|
do! Data.Success.save
|
||||||
|
{ id = SuccessId.create()
|
||||||
|
citizenId = currentCitizenId ctx
|
||||||
|
recordedOn = now
|
||||||
|
fromHere = form.fromHere
|
||||||
|
source = "listing"
|
||||||
|
story = (Text >> Some) storyText
|
||||||
|
} dbConn
|
||||||
|
| None -> ()
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/listing/search
|
||||||
|
let search : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let search = ctx.BindQueryString<ListingSearch> ()
|
||||||
|
let! results = Data.Listing.search search (conn ctx)
|
||||||
|
return! json results next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/profile routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Profile =
|
||||||
|
|
||||||
|
// GET: /api/profile
|
||||||
|
// This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
|
||||||
|
// is not an error). The "get" handler returns a 404 if a profile is not found.
|
||||||
|
let current : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.Profile.findById (currentCitizenId ctx) (conn ctx) with
|
||||||
|
| Some profile -> return! json profile next ctx
|
||||||
|
| None -> return! Successful.NO_CONTENT next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/get/[id]
|
||||||
|
let get citizenId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.Profile.findById (CitizenId citizenId) (conn ctx) with
|
||||||
|
| Some profile -> return! json profile next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/view/[id]
|
||||||
|
let view citizenId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let citId = CitizenId citizenId
|
||||||
|
let dbConn = conn ctx
|
||||||
|
match! Data.Profile.findById citId dbConn with
|
||||||
|
| Some profile ->
|
||||||
|
match! Data.Citizen.findById citId dbConn with
|
||||||
|
| Some citizen ->
|
||||||
|
match! Data.Continent.findById profile.continentId dbConn with
|
||||||
|
| Some continent ->
|
||||||
|
return!
|
||||||
|
json {
|
||||||
|
profile = profile
|
||||||
|
citizen = citizen
|
||||||
|
continent = continent
|
||||||
|
} next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/count
|
||||||
|
let count : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let! theCount = Data.Profile.count (conn ctx)
|
||||||
|
return! json { count = theCount } next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /api/profile/save
|
||||||
|
let save : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let citizenId = currentCitizenId ctx
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let! form = ctx.BindJsonAsync<ProfileForm>()
|
||||||
|
let! profile = task {
|
||||||
|
match! Data.Profile.findById citizenId dbConn with
|
||||||
|
| Some p -> return p
|
||||||
|
| None -> return { Profile.empty with id = citizenId }
|
||||||
|
}
|
||||||
|
do! Data.Profile.save
|
||||||
|
{ profile with
|
||||||
|
seekingEmployment = form.isSeekingEmployment
|
||||||
|
isPublic = form.isPublic
|
||||||
|
continentId = ContinentId.ofString form.continentId
|
||||||
|
region = form.region
|
||||||
|
remoteWork = form.remoteWork
|
||||||
|
fullTime = form.fullTime
|
||||||
|
biography = Text form.biography
|
||||||
|
lastUpdatedOn = (clock ctx).GetCurrentInstant ()
|
||||||
|
experience = noneIfBlank form.experience |> Option.map Text
|
||||||
|
skills = form.skills
|
||||||
|
|> List.map (fun s ->
|
||||||
|
{ id = match s.id.StartsWith "new" with
|
||||||
|
| true -> SkillId.create ()
|
||||||
|
| false -> SkillId.ofString s.id
|
||||||
|
description = s.description
|
||||||
|
notes = noneIfBlank s.notes
|
||||||
|
})
|
||||||
|
} dbConn
|
||||||
|
do! Data.Citizen.realNameUpdate citizenId (noneIfBlank (Some form.realName)) dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH: /api/profile/employment-found
|
||||||
|
let employmentFound : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let dbConn = conn ctx
|
||||||
|
match! Data.Profile.findById (currentCitizenId ctx) dbConn with
|
||||||
|
| Some profile ->
|
||||||
|
do! Data.Profile.save { profile with seekingEmployment = false } dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: /api/profile
|
||||||
|
let delete : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
do! Data.Profile.delete (currentCitizenId ctx) (conn ctx)
|
||||||
|
return! ok next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/search
|
||||||
|
let search : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let search = ctx.BindQueryString<ProfileSearch> ()
|
||||||
|
let! results = Data.Profile.search search (conn ctx)
|
||||||
|
return! json results next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/profile/public-search
|
||||||
|
let publicSearch : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
let search = ctx.BindQueryString<PublicSearch> ()
|
||||||
|
let! results = Data.Profile.publicSearch search (conn ctx)
|
||||||
|
return! json results next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/success routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Success =
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
// GET: /api/success/[id]
|
||||||
|
let get successId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.Success.findById (SuccessId successId) (conn ctx) with
|
||||||
|
| Some story -> return! json story next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/success/list
|
||||||
|
let all : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let! stories = Data.Success.all (conn ctx)
|
||||||
|
return! json stories next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /api/success/save
|
||||||
|
let save : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let citizenId = currentCitizenId ctx
|
||||||
|
let dbConn = conn ctx
|
||||||
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
|
let! form = ctx.BindJsonAsync<StoryForm> ()
|
||||||
|
let! success = task {
|
||||||
|
match form.id with
|
||||||
|
| "new" ->
|
||||||
|
return Some { id = SuccessId.create ()
|
||||||
|
citizenId = citizenId
|
||||||
|
recordedOn = now
|
||||||
|
fromHere = form.fromHere
|
||||||
|
source = "profile"
|
||||||
|
story = noneIfEmpty form.story |> Option.map Text
|
||||||
|
}
|
||||||
|
| successId ->
|
||||||
|
match! Data.Success.findById (SuccessId.ofString successId) dbConn with
|
||||||
|
| Some story when story.citizenId = citizenId ->
|
||||||
|
return Some { story with
|
||||||
|
fromHere = form.fromHere
|
||||||
|
story = noneIfEmpty form.story |> Option.map Text
|
||||||
|
}
|
||||||
|
| Some _ | None -> return None
|
||||||
|
}
|
||||||
|
match success with
|
||||||
|
| Some story ->
|
||||||
|
do! Data.Success.save story dbConn
|
||||||
|
return! ok next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
|
/// All available endpoints for the application
|
||||||
|
let allEndpoints = [
|
||||||
|
subRoute "/api" [
|
||||||
|
subRoute "/citizen" [
|
||||||
|
GET_HEAD [
|
||||||
|
routef "/log-on/%s" Citizen.logOn
|
||||||
|
routef "/%O" Citizen.get
|
||||||
|
]
|
||||||
|
DELETE [ route "" Citizen.delete ]
|
||||||
|
]
|
||||||
|
GET_HEAD [ route "/continents" Continent.all ]
|
||||||
|
subRoute "/listing" [
|
||||||
|
GET_HEAD [
|
||||||
|
routef "/%O" Listing.get
|
||||||
|
route "/search" Listing.search
|
||||||
|
routef "/%O/view" Listing.view
|
||||||
|
route "s/mine" Listing.mine
|
||||||
|
]
|
||||||
|
PATCH [
|
||||||
|
routef "/%O" Listing.expire
|
||||||
|
]
|
||||||
|
POST [
|
||||||
|
route "s" Listing.add
|
||||||
|
]
|
||||||
|
PUT [
|
||||||
|
routef "/%O" Listing.update
|
||||||
|
]
|
||||||
|
]
|
||||||
|
subRoute "/profile" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "" Profile.current
|
||||||
|
route "/count" Profile.count
|
||||||
|
routef "/%O" Profile.get
|
||||||
|
routef "/%O/view" Profile.view
|
||||||
|
route "/public-search" Profile.publicSearch
|
||||||
|
route "/search" Profile.search
|
||||||
|
]
|
||||||
|
PATCH [ route "/employment-found" Profile.employmentFound ]
|
||||||
|
POST [ route "" Profile.save ]
|
||||||
|
]
|
||||||
|
subRoute "/success" [
|
||||||
|
GET_HEAD [
|
||||||
|
routef "/%O" Success.get
|
||||||
|
route "es" Success.all
|
||||||
|
]
|
||||||
|
POST [ route "" Success.save ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
6
src/JobsJobsJobs/Api/appsettings.json
Normal file
6
src/JobsJobsJobs/Api/appsettings.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"Rethink": {
|
||||||
|
"Hostname": "localhost",
|
||||||
|
"Db": "jobsjobsjobs"
|
||||||
|
}
|
||||||
|
}
|
3
src/JobsJobsJobs/App/.browserslistrc
Normal file
3
src/JobsJobsJobs/App/.browserslistrc
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
5
src/JobsJobsJobs/App/.editorconfig
Normal file
5
src/JobsJobsJobs/App/.editorconfig
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
[*.{js,jsx,ts,tsx,vue}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
29
src/JobsJobsJobs/App/.eslintrc.js
Normal file
29
src/JobsJobsJobs/App/.eslintrc.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
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/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
Normal file
23
src/JobsJobsJobs/App/.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
.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?
|
5
src/JobsJobsJobs/App/babel.config.js
Normal file
5
src/JobsJobsJobs/App/babel.config.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
14537
src/JobsJobsJobs/App/package-lock.json
generated
Normal file
14537
src/JobsJobsJobs/App/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
src/JobsJobsJobs/App/package.json
Normal file
51
src/JobsJobsJobs/App/package.json
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"name": "jobs-jobs-jobs",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint",
|
||||||
|
"apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/js": "^5.9.55",
|
||||||
|
"@vuelidate/core": "^2.0.0-alpha.24",
|
||||||
|
"@vuelidate/validators": "^2.0.0-alpha.21",
|
||||||
|
"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": "^2.1.3",
|
||||||
|
"vue": "^3.2.6",
|
||||||
|
"vue-router": "^4.0.11",
|
||||||
|
"vuex": "^4.0.0-0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bootstrap": "^5.1.2",
|
||||||
|
"@types/dompurify": "^2.2.3",
|
||||||
|
"@types/marked": "^2.0.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.29.3",
|
||||||
|
"@typescript-eslint/parser": "^4.29.3",
|
||||||
|
"@vue/cli-plugin-babel": "~4.5.0",
|
||||||
|
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||||
|
"@vue/cli-plugin-router": "~4.5.0",
|
||||||
|
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||||
|
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||||
|
"@vue/cli-service": "~4.5.0",
|
||||||
|
"@vue/compiler-sfc": "^3.2.6",
|
||||||
|
"@vue/eslint-config-standard": "^6.1.0",
|
||||||
|
"@vue/eslint-config-typescript": "^7.0.0",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-plugin-import": "^2.24.2",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-promise": "^5.0.0",
|
||||||
|
"eslint-plugin-standard": "^5.0.0",
|
||||||
|
"eslint-plugin-vue": "^7.17.0",
|
||||||
|
"sass": "~1.37.0",
|
||||||
|
"sass-loader": "^10.0.0",
|
||||||
|
"typescript": "~4.3.5",
|
||||||
|
"vue-cli-plugin-pug": "~2.0.0"
|
||||||
|
}
|
||||||
|
}
|
BIN
src/JobsJobsJobs/App/public/favicon.ico
Normal file
BIN
src/JobsJobsJobs/App/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
18
src/JobsJobsJobs/App/public/index.html
Normal file
18
src/JobsJobsJobs/App/public/index.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<!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>
|
82
src/JobsJobsJobs/App/src/App.vue
Normal file
82
src/JobsJobsJobs/App/src/App.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.jjj-app
|
||||||
|
app-nav
|
||||||
|
.jjj-main
|
||||||
|
title-bar
|
||||||
|
main.container-fluid: router-view(v-slot="{ Component }"): transition(name="fade" mode="out-in")
|
||||||
|
component(:is="Component")
|
||||||
|
app-footer
|
||||||
|
app-toaster
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue"
|
||||||
|
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css"
|
||||||
|
|
||||||
|
import { Citizen } from "./api"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (the first available among real, display, or NAS handle)
|
||||||
|
*
|
||||||
|
* @param cit The citizen
|
||||||
|
* @returns The citizen's display name
|
||||||
|
*/
|
||||||
|
export function citizenName (cit : Citizen) : string {
|
||||||
|
return cit.realName ?? cit.displayName ?? cit.naUser
|
||||||
|
}
|
||||||
|
</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
|
||||||
|
// Route transitions
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active
|
||||||
|
transition: opacity 0.125s ease
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to
|
||||||
|
opacity: 0
|
||||||
|
</style>
|
369
src/JobsJobsJobs/App/src/api/index.ts
Normal file
369
src/JobsJobsJobs/App/src/api/index.ts
Normal file
|
@ -0,0 +1,369 @@
|
||||||
|
import {
|
||||||
|
Citizen,
|
||||||
|
Continent,
|
||||||
|
Count,
|
||||||
|
Listing,
|
||||||
|
ListingExpireForm,
|
||||||
|
ListingForm,
|
||||||
|
ListingForView,
|
||||||
|
ListingSearch,
|
||||||
|
LogOnSuccess,
|
||||||
|
Profile,
|
||||||
|
ProfileForm,
|
||||||
|
ProfileForView,
|
||||||
|
ProfileSearch,
|
||||||
|
ProfileSearchResult,
|
||||||
|
PublicSearch,
|
||||||
|
PublicSearchResult,
|
||||||
|
StoryEntry,
|
||||||
|
StoryForm,
|
||||||
|
Success
|
||||||
|
} 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 => `http://localhost:5000/api/${url}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create request init parameters
|
||||||
|
*
|
||||||
|
* @param method The method by which the request should be executed
|
||||||
|
* @param user The currently logged-on user
|
||||||
|
* @returns RequestInit parameters
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const reqInit = (method : string, user : LogOnSuccess, body : any | undefined = undefined) : RequestInit => {
|
||||||
|
const headers = new Headers()
|
||||||
|
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: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a citizen on
|
||||||
|
*
|
||||||
|
* @param code The authorization code from No Agenda Social
|
||||||
|
* @returns The user result, or an error
|
||||||
|
*/
|
||||||
|
logOn: async (code : string) : Promise<LogOnSuccess | string> => {
|
||||||
|
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" })
|
||||||
|
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}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 form
|
||||||
|
* @param user The currently logged-on user
|
||||||
|
* @returns True if the action was successful, an error string if not
|
||||||
|
*/
|
||||||
|
expire: async (id : string, listing : ListingExpireForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||||
|
apiSend(await fetch(apiUrl(`listing/${id}`), reqInit("PATCH", user, listing)), "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"
|
280
src/JobsJobsJobs/App/src/api/types.ts
Normal file
280
src/JobsJobsJobs/App/src/api/types.ts
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
|
||||||
|
/** A user of Jobs, Jobs, Jobs */
|
||||||
|
export interface Citizen {
|
||||||
|
/** The ID of the user */
|
||||||
|
id : string
|
||||||
|
/** The handle by which the user is known on Mastodon */
|
||||||
|
naUser : string
|
||||||
|
/** The user's display name from Mastodon (updated every login) */
|
||||||
|
displayName : string | undefined
|
||||||
|
/** The user's real name */
|
||||||
|
realName : string | undefined
|
||||||
|
/** The URL for the user's Mastodon profile */
|
||||||
|
profileUrl : string
|
||||||
|
/** When the user joined Jobs, Jobs, Jobs (date) */
|
||||||
|
joinedOn : string
|
||||||
|
/** When the user last logged in (date) */
|
||||||
|
lastSeenOn : string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 user's real name */
|
||||||
|
realName = ""
|
||||||
|
/** 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
|
||||||
|
}
|
0
src/JobsJobsJobs/App/src/assets/.gitkeep
Normal file
0
src/JobsJobsJobs/App/src/assets/.gitkeep
Normal file
27
src/JobsJobsJobs/App/src/components/AudioClip.vue
Normal file
27
src/JobsJobsJobs/App/src/components/AudioClip.vue
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<template lang="pug">
|
||||||
|
span(@click="playFile") #[slot] #[audio(:id="clip"): source(:src="clipSource")]
|
||||||
|
</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>
|
40
src/JobsJobsJobs/App/src/components/CollapsePanel.vue
Normal file
40
src/JobsJobsJobs/App/src/components/CollapsePanel.vue
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.card: .card-body
|
||||||
|
h6.card-title
|
||||||
|
a(href="#" :class="{ 'cp-c': collapsed, 'cp-o': !collapsed }" @click.prevent="toggle") {{headerText}}
|
||||||
|
slot(v-if="!collapsed")
|
||||||
|
</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>
|
58
src/JobsJobsJobs/App/src/components/ContinentList.vue
Normal file
58
src/JobsJobsJobs/App/src/components/ContinentList.vue
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.form-floating
|
||||||
|
select.form-select(id="continentId" :class="{ 'is-invalid': isInvalid}" :value="continentId"
|
||||||
|
@change="continentChanged")
|
||||||
|
option(value="") – {{emptyLabel}} –
|
||||||
|
option(v-for="c in continents" :key="c.id" :value="c.id") {{c.name}}
|
||||||
|
label.jjj-required(for="continentId") Continent
|
||||||
|
.invalid-feedback Please select a continent
|
||||||
|
</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>
|
17
src/JobsJobsJobs/App/src/components/ErrorList.vue
Normal file
17
src/JobsJobsJobs/App/src/components/ErrorList.vue
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<template lang="pug">
|
||||||
|
template(v-if="errors.length > 0")
|
||||||
|
p The following error#[template(v-if="errors.length !== 1") s] occurred:
|
||||||
|
ul: li(v-for="(error, idx) in errors" :key="idx") {{error}}
|
||||||
|
slot(v-else)
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
errors: string[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
ul li
|
||||||
|
font-family: monospace
|
||||||
|
</style>
|
15
src/JobsJobsJobs/App/src/components/FullDate.vue
Normal file
15
src/JobsJobsJobs/App/src/components/FullDate.vue
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<template lang="pug">
|
||||||
|
template(v-if="true") {{formatted}}
|
||||||
|
</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>
|
15
src/JobsJobsJobs/App/src/components/FullDateTime.vue
Normal file
15
src/JobsJobsJobs/App/src/components/FullDateTime.vue
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<template lang="pug">
|
||||||
|
template(v-if="true") {{formatted}}
|
||||||
|
</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>
|
16
src/JobsJobsJobs/App/src/components/Icon.vue
Normal file
16
src/JobsJobsJobs/App/src/components/Icon.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template lang="pug">
|
||||||
|
svg(viewbox="0 0 24 24"): path(:fill="color || 'white'" :d="icon")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
color?: string
|
||||||
|
icon: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
svg
|
||||||
|
width: 24px
|
||||||
|
height: 24px
|
||||||
|
</style>
|
63
src/JobsJobsJobs/App/src/components/ListingSearchForm.vue
Normal file
63
src/JobsJobsJobs/App/src/components/ListingSearchForm.vue
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<template lang="pug">
|
||||||
|
form.container
|
||||||
|
.row
|
||||||
|
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
|
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||||
|
.col.col-xs-12.col-sm-6.col-lg-3
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="regionSearch" placeholder="(free-form text)" :value="criteria.region"
|
||||||
|
@input="updateValue('region', $event.target.value)")
|
||||||
|
label(for="regionSearch") Region
|
||||||
|
.form-text (free-form text)
|
||||||
|
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||||
|
label.jjj-label Remote Work Opportunity?
|
||||||
|
br
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteNull" name="remoteWork" :checked="criteria.remoteWork === ''"
|
||||||
|
@click="updateValue('remoteWork', '')")
|
||||||
|
label.form-check-label(for="remoteNull") No Selection
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteYes" name="remoteWork" :checked="criteria.remoteWork === 'yes'"
|
||||||
|
@click="updateValue('remoteWork', 'yes')")
|
||||||
|
label.form-check-label(for="remoteYes") Yes
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||||
|
@click="updateValue('remoteWork', 'no')")
|
||||||
|
label.form-check-label(for="remoteNo") No
|
||||||
|
.col.col-xs-12.col-sm-6.col-lg-3
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="textSearch" placeholder="(free-form text)" :value="criteria.text"
|
||||||
|
@input="updateValue('text', $event.target.value)")
|
||||||
|
label(for="textSearch") Job Listing Text
|
||||||
|
.form-text (free-form text)
|
||||||
|
.row: .col.col-xs-12
|
||||||
|
br
|
||||||
|
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ListingSearch } from "@/api"
|
||||||
|
import { ref } from "vue"
|
||||||
|
import ContinentList from "./ContinentList.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: ListingSearch
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "search") : void
|
||||||
|
(e: "update:modelValue", value : ListingSearch) : void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||||
|
const criteria = ref({ ...props.modelValue })
|
||||||
|
|
||||||
|
/** Emit a value update */
|
||||||
|
const updateValue = (key : string, value : string) => {
|
||||||
|
criteria.value = { ...criteria.value, [key]: value }
|
||||||
|
emit("update:modelValue", criteria.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the continent ID */
|
||||||
|
const updateContinent = (c : string) => updateValue("continentId", c)
|
||||||
|
</script>
|
31
src/JobsJobsJobs/App/src/components/LoadData.vue
Normal file
31
src/JobsJobsJobs/App/src/components/LoadData.vue
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<template lang="pug">
|
||||||
|
div(v-if="loading") Loading…
|
||||||
|
error-list(v-else :errors="errors")
|
||||||
|
slot
|
||||||
|
</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>
|
66
src/JobsJobsJobs/App/src/components/MarkdownEditor.vue
Normal file
66
src/JobsJobsJobs/App/src/components/MarkdownEditor.vue
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.col-12
|
||||||
|
nav.nav.nav-pills.pb-1
|
||||||
|
button(:class="sourceClass" @click.prevent="showMarkdown") Markdown
|
||||||
|
|
|
||||||
|
button(:class="previewClass" @click.prevent="showPreview") Preview
|
||||||
|
section.preview(v-if="preview" v-html="previewHtml")
|
||||||
|
.form-floating(v-else)
|
||||||
|
textarea.form-control.md-edit(:id="id" :class="{ 'is-invalid': isInvalid }" rows="10" v-text="text"
|
||||||
|
@input="$emit('update:text', $event.target.value)")
|
||||||
|
.invalid-feedback Please enter some text for {{label}}
|
||||||
|
label(:for="id") {{label}}
|
||||||
|
</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>
|
59
src/JobsJobsJobs/App/src/components/MaybeSave.vue
Normal file
59
src/JobsJobsJobs/App/src/components/MaybeSave.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.modal.fade(id="maybeSaveModal" tabindex="-1" aria-labelledby="maybeSaveLabel" aria-hidden="true"): .modal-dialog: .modal-content
|
||||||
|
.modal-header: h5.modal-title(id="maybeSaveLabel") Unsaved Changes
|
||||||
|
.modal-body You have modified the data on this page since it was last saved. What would you like to do?
|
||||||
|
.modal-footer
|
||||||
|
button.btn.btn-secondary(type="button" @click.prevent="close") Stay on This Page
|
||||||
|
button.btn.btn-primary(type="button" @click.prevent="save") Save Changes
|
||||||
|
button.btn.btn-danger(type="button" @click.prevent="discard") Discard Changes
|
||||||
|
</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>
|
28
src/JobsJobsJobs/App/src/components/PageTitle.vue
Normal file
28
src/JobsJobsJobs/App/src/components/PageTitle.vue
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<template lang="pug">
|
||||||
|
p(v-if="false")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, watch } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** The name of the application */
|
||||||
|
const appName = "Jobs, Jobs, Jobs"
|
||||||
|
|
||||||
|
/** Set the page title based on the input title attribute */
|
||||||
|
const setTitle = () => {
|
||||||
|
if (props.title === "") {
|
||||||
|
document.title = appName
|
||||||
|
} else {
|
||||||
|
document.title = `${props.title} | ${appName}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(setTitle)
|
||||||
|
|
||||||
|
/** Change the page title when the property changes */
|
||||||
|
watch(() => props.title, setTitle)
|
||||||
|
</script>
|
12
src/JobsJobsJobs/App/src/components/index.ts
Normal file
12
src/JobsJobsJobs/App/src/components/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
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)
|
||||||
|
}
|
25
src/JobsJobsJobs/App/src/components/layout/AppFooter.vue
Normal file
25
src/JobsJobsJobs/App/src/components/layout/AppFooter.vue
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<template lang="pug">
|
||||||
|
footer: p.text-muted.
|
||||||
|
Jobs, Jobs, Jobs v{{appVersion}} • #[router-link(to="/privacy-policy") Privacy Policy]
|
||||||
|
• #[router-link(to="/terms-of-service") Terms of Service]
|
||||||
|
</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>
|
88
src/JobsJobsJobs/App/src/components/layout/AppNav.vue
Normal file
88
src/JobsJobsJobs/App/src/components/layout/AppNav.vue
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<template lang="pug">
|
||||||
|
aside.collapse.show.p-3
|
||||||
|
p.home-link.pb-3: router-link(to="/") Jobs, Jobs, Jobs
|
||||||
|
p
|
||||||
|
nav
|
||||||
|
template(v-if="isLoggedOn")
|
||||||
|
router-link(to="/citizen/dashboard") #[icon(:icon="mdiViewDashboardVariant")] Dashboard
|
||||||
|
router-link(to="/help-wanted") #[icon(:icon="mdiNewspaperVariantMultipleOutline")] Help Wanted!
|
||||||
|
router-link(to="/profile/search") #[icon(:icon="mdiViewListOutline")] Employment Profiles
|
||||||
|
router-link(to="/success-story/list") #[icon(:icon="mdiThumbUp")] Success Stories
|
||||||
|
.separator
|
||||||
|
router-link(to="/listings/mine") #[icon(:icon="mdiSignText")] My Job Listings
|
||||||
|
router-link(to="/citizen/profile") #[icon(:icon="mdiPencil")] My Employment Profile
|
||||||
|
.separator
|
||||||
|
router-link(to="/citizen/log-off") #[icon(:icon="mdiLogoutVariant")] Log Off
|
||||||
|
template(v-else)
|
||||||
|
router-link(to="/") #[icon(:icon="mdiHome")] Home
|
||||||
|
router-link(to="/profile/seeking") #[icon(:icon="mdiViewListOutline")] Job Seekers
|
||||||
|
router-link(to="/citizen/log-on") #[icon(:icon="mdiLoginVariant")] Log On
|
||||||
|
router-link(to="/how-it-works") #[icon(:icon="mdiHelpCircleOutline")] How It Works
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
import {
|
||||||
|
mdiHelpCircleOutline,
|
||||||
|
mdiHome,
|
||||||
|
mdiLoginVariant,
|
||||||
|
mdiLogoutVariant,
|
||||||
|
mdiNewspaperVariantMultipleOutline,
|
||||||
|
mdiPencil,
|
||||||
|
mdiSignText,
|
||||||
|
mdiThumbUp,
|
||||||
|
mdiViewDashboardVariant,
|
||||||
|
mdiViewListOutline
|
||||||
|
} from "@mdi/js"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** Whether a user is logged in or not */
|
||||||
|
const isLoggedOn = computed(() => store.state.user !== undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
aside
|
||||||
|
background-image: linear-gradient(180deg, darkgreen 0%, green 70%)
|
||||||
|
color: white
|
||||||
|
font-size: 1.2rem
|
||||||
|
min-height: 100vh
|
||||||
|
width: 250px
|
||||||
|
min-width: 250px
|
||||||
|
position: sticky
|
||||||
|
top: 0
|
||||||
|
path
|
||||||
|
fill: white
|
||||||
|
path:hover
|
||||||
|
fill: black
|
||||||
|
a:link, a:visited
|
||||||
|
text-decoration: none
|
||||||
|
color: white
|
||||||
|
// font-weight: 500
|
||||||
|
.home-link
|
||||||
|
font-size: 1.2rem
|
||||||
|
text-align: center
|
||||||
|
background-color: rgba(0, 0, 0, .4)
|
||||||
|
margin: -1rem
|
||||||
|
padding: 1rem
|
||||||
|
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>
|
88
src/JobsJobsJobs/App/src/components/layout/AppToaster.vue
Normal file
88
src/JobsJobsJobs/App/src/components/layout/AppToaster.vue
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<template lang="pug">
|
||||||
|
div(aria-live="polite" aria-atomic="true" id="toastHost")
|
||||||
|
.toast-container.position-absolute.p-3.bottom-0.start-50.translate-middle-x(id="toasts")
|
||||||
|
</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>
|
16
src/JobsJobsJobs/App/src/components/layout/TitleBar.vue
Normal file
16
src/JobsJobsJobs/App/src/components/layout/TitleBar.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<template lang="pug">
|
||||||
|
nav.navbar.navbar-light.bg-light
|
||||||
|
span
|
||||||
|
span.navbar-text.
|
||||||
|
(…and Jobs – #[audio-clip(clip="pelosi-jobs") Let’s Vote for Jobs!])
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AudioClip from "@/components/AudioClip.vue"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.navbar-text
|
||||||
|
font-style: italic
|
||||||
|
padding-right: 1rem
|
||||||
|
</style>
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template lang="pug">
|
||||||
|
form.container
|
||||||
|
.row
|
||||||
|
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
|
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||||
|
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
|
.form-floating
|
||||||
|
input.form-control.form-control-sm(type="text" id="region" placeholder="(free-form text)"
|
||||||
|
:value="criteria.region" @input="updateValue('region', $event.target.value)")
|
||||||
|
label(for="region") Region
|
||||||
|
.form-text (free-form text)
|
||||||
|
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||||
|
label.jjj-label Seeking Remote Work?
|
||||||
|
br
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteNull" name="remoteWork" :checked="criteria.remoteWork === ''"
|
||||||
|
@click="updateValue('remoteWork', '')")
|
||||||
|
label.form-check-label(for="remoteNull") No Selection
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteYes" name="remoteWork" :checked="criteria.remoteWork === 'yes'"
|
||||||
|
@click="updateValue('remoteWork', 'yes')")
|
||||||
|
label.form-check-label(for="remoteYes") Yes
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||||
|
@click="updateValue('remoteWork', 'no')")
|
||||||
|
label.form-check-label(for="remoteNo") No
|
||||||
|
.col.col-xs-12.col-sm-6.col-lg-3
|
||||||
|
.form-floating
|
||||||
|
input.form-control.form-control-sm(type="text" id="skillSearch" placeholder="(free-form text)"
|
||||||
|
:value="criteria.skill" @input="updateValue('skill', $event.target.value)")
|
||||||
|
label(for="skillSearch") Skill
|
||||||
|
.form-text (free-form text)
|
||||||
|
.row: .col.col-xs-12
|
||||||
|
br
|
||||||
|
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { PublicSearch } from "@/api"
|
||||||
|
import ContinentList from "../ContinentList.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: PublicSearch
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "search") : void
|
||||||
|
(e: "update:modelValue", value : PublicSearch) : void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||||
|
const criteria = ref({ ...props.modelValue })
|
||||||
|
|
||||||
|
/** Emit a value update */
|
||||||
|
const updateValue = (key : string, value : string) => {
|
||||||
|
criteria.value = { ...criteria.value, [key]: value }
|
||||||
|
emit("update:modelValue", criteria.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the continent ID */
|
||||||
|
const updateContinent = (c : string) => updateValue("continentId", c)
|
||||||
|
</script>
|
63
src/JobsJobsJobs/App/src/components/profile/SearchForm.vue
Normal file
63
src/JobsJobsJobs/App/src/components/profile/SearchForm.vue
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<template lang="pug">
|
||||||
|
form.container
|
||||||
|
.row
|
||||||
|
.col.col-xs-12.col-sm-6.col-md-4.col-lg-3
|
||||||
|
continent-list(v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent")
|
||||||
|
.col.col-xs-12.col-sm-6.col-offset-md-2.col-lg-3.col-offset-lg-0
|
||||||
|
label.jjj-label Seeking Remote Work?
|
||||||
|
br
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteNull" name="remoteWork" :checked="criteria.remoteWork === ''"
|
||||||
|
@click="updateValue('remoteWork', '')")
|
||||||
|
label.form-check-label(for="remoteNull") No Selection
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteYes" name="remoteWork" :checked="criteria.remoteWork === 'yes'"
|
||||||
|
@click="updateValue('remoteWork', 'yes')")
|
||||||
|
label.form-check-label(for="remoteYes") Yes
|
||||||
|
.form-check.form-check-inline
|
||||||
|
input.form-check-input(type="radio" id="remoteNo" name="remoteWork" :checked="criteria.remoteWork === 'no'"
|
||||||
|
@click="updateValue('remoteWork', 'no')")
|
||||||
|
label.form-check-label(for="remoteNo") No
|
||||||
|
.col.col-xs-12.col-sm-6.col-lg-3
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="skillSearch" placeholder="(free-form text)" :value="criteria.skill"
|
||||||
|
@input="updateValue('skill', $event.target.value)")
|
||||||
|
label(for="skillSearch") Skill
|
||||||
|
.form-text (free-form text)
|
||||||
|
.col.col-xs-12.col-sm-6.col-lg-3
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="bioSearch" placeholder="(free-form text)" :value="criteria.bioExperience"
|
||||||
|
@input="updateValue('bioExperience', $event.target.value)")
|
||||||
|
label(for="bioSearch") Bio / Experience
|
||||||
|
.form-text (free-form text)
|
||||||
|
.row: .col.col-xs-12
|
||||||
|
br
|
||||||
|
button.btn.btn-outline-primary(type="submit" @click.prevent="$emit('search')") Search
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { ProfileSearch } from "@/api"
|
||||||
|
import ContinentList from "../ContinentList.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: ProfileSearch
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "search") : void
|
||||||
|
(e: "update:modelValue", value : ProfileSearch) : void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||||
|
const criteria = ref({ ...props.modelValue })
|
||||||
|
|
||||||
|
/** Emit a value update */
|
||||||
|
const updateValue = (key : string, value : string) => {
|
||||||
|
criteria.value = { ...criteria.value, [key]: value }
|
||||||
|
emit("update:modelValue", criteria.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the continent ID */
|
||||||
|
const updateContinent = (c : string) => updateValue("continentId", c)
|
||||||
|
</script>
|
44
src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue
Normal file
44
src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.row.pb-3
|
||||||
|
.col.col-xs-2.col-md-1.align-self-center
|
||||||
|
button.btn.btn-sm.btn-outline-danger.rounded-pill(title="Delete" @click.prevent="$emit('remove')") −
|
||||||
|
.col.col-xs-10.col-md-6
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" :id="`skillDesc${skill.id}`" maxlength="100"
|
||||||
|
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
|
||||||
|
@input="updateValue('description', $event.target.value)")
|
||||||
|
label.jjj-label(:for="`skillDesc${skill.id}`") Skill
|
||||||
|
.form-text A skill (language, design technique, process, etc.)
|
||||||
|
.col.col-xs-12.col-md-5
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" :id="`skillNotes${skill.id}`" maxlength="100"
|
||||||
|
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"
|
||||||
|
@input="updateValue('notes', $event.target.value)")
|
||||||
|
label.jjj-label(:for="`skillNotes${skill.id}`") Notes
|
||||||
|
.form-text A further description of the skill (100 characters max)
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Ref, ref } from "vue"
|
||||||
|
import { Skill } from "@/api"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Skill
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "input") : void
|
||||||
|
(e: "remove") : void
|
||||||
|
(e: "update:modelValue", value: Skill) : void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** The skill being edited */
|
||||||
|
const skill : Ref<Skill> = ref({ ...props.modelValue as Skill })
|
||||||
|
|
||||||
|
/** Update a value in the model */
|
||||||
|
const updateValue = (key : string, value : string) => {
|
||||||
|
skill.value = { ...skill.value, [key]: value }
|
||||||
|
emit("update:modelValue", skill.value)
|
||||||
|
emit("input")
|
||||||
|
}
|
||||||
|
</script>
|
15
src/JobsJobsJobs/App/src/main.ts
Normal file
15
src/JobsJobsJobs/App/src/main.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { createApp } from "vue"
|
||||||
|
import App from "./App.vue"
|
||||||
|
import router from "./router"
|
||||||
|
import store, { key } from "./store"
|
||||||
|
import Icon from "./components/Icon.vue"
|
||||||
|
import PageTitle from "./components/PageTitle.vue"
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
.use(router)
|
||||||
|
.use(store, key)
|
||||||
|
|
||||||
|
app.component("Icon", Icon)
|
||||||
|
app.component("PageTitle", PageTitle)
|
||||||
|
|
||||||
|
app.mount("#app")
|
12
src/JobsJobsJobs/App/src/markdown.ts
Normal file
12
src/JobsJobsJobs/App/src/markdown.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
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 } })
|
||||||
|
}
|
0
src/JobsJobsJobs/App/src/plugins/.gitkeep
Normal file
0
src/JobsJobsJobs/App/src/plugins/.gitkeep
Normal file
168
src/JobsJobsJobs/App/src/router/index.ts
Normal file
168
src/JobsJobsJobs/App/src/router/index.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
createRouter,
|
||||||
|
createWebHistory,
|
||||||
|
RouteLocationNormalized,
|
||||||
|
RouteLocationNormalizedLoaded,
|
||||||
|
RouteRecordName,
|
||||||
|
RouteRecordRaw
|
||||||
|
} from "vue-router"
|
||||||
|
import store from "@/store"
|
||||||
|
import Home from "@/views/Home.vue"
|
||||||
|
import LogOn from "@/views/citizen/LogOn.vue"
|
||||||
|
|
||||||
|
/** The URL to which the user should be pointed once they have authorized with NAS */
|
||||||
|
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: "/",
|
||||||
|
name: "Home",
|
||||||
|
component: Home
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/how-it-works",
|
||||||
|
name: "HowItWorks",
|
||||||
|
component: () => import(/* webpackChunkName: "help" */ "../views/HowItWorks.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/privacy-policy",
|
||||||
|
name: "PrivacyPolicy",
|
||||||
|
component: () => import(/* webpackChunkName: "legal" */ "../views/PrivacyPolicy.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/terms-of-service",
|
||||||
|
name: "TermsOfService",
|
||||||
|
component: () => import(/* webpackChunkName: "legal" */ "../views/TermsOfService.vue")
|
||||||
|
},
|
||||||
|
// Citizen URLs
|
||||||
|
{
|
||||||
|
path: "/citizen/log-on",
|
||||||
|
name: "LogOn",
|
||||||
|
component: LogOn
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/citizen/authorized",
|
||||||
|
name: "CitizenAuthorized",
|
||||||
|
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/citizen/dashboard",
|
||||||
|
name: "Dashboard",
|
||||||
|
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/citizen/profile",
|
||||||
|
name: "EditProfile",
|
||||||
|
component: () => import(/* webpackChunkName: "profedit" */ "../views/citizen/EditProfile.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/citizen/log-off",
|
||||||
|
name: "LogOff",
|
||||||
|
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue")
|
||||||
|
},
|
||||||
|
// Job Listing URLs
|
||||||
|
{
|
||||||
|
path: "/help-wanted",
|
||||||
|
name: "HelpWanted",
|
||||||
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/listing/:id/edit",
|
||||||
|
name: "EditListing",
|
||||||
|
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/listing/:id/expire",
|
||||||
|
name: "ExpireListing",
|
||||||
|
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/listing/:id/view",
|
||||||
|
name: "ViewListing",
|
||||||
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/listings/mine",
|
||||||
|
name: "MyListings",
|
||||||
|
component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/MyListings.vue")
|
||||||
|
},
|
||||||
|
// Profile URLs
|
||||||
|
{
|
||||||
|
path: "/profile/:id/view",
|
||||||
|
name: "ViewProfile",
|
||||||
|
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/profile/search",
|
||||||
|
name: "SearchProfiles",
|
||||||
|
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/profile/seeking",
|
||||||
|
name: "PublicSearchProfiles",
|
||||||
|
component: () => import(/* webpackChunkName: "seeking" */ "../views/profile/Seeking.vue")
|
||||||
|
},
|
||||||
|
// "So Long" URLs
|
||||||
|
{
|
||||||
|
path: "/so-long/options",
|
||||||
|
name: "DeletionOptions",
|
||||||
|
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/so-long/success",
|
||||||
|
name: "DeletionSuccess",
|
||||||
|
component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue")
|
||||||
|
},
|
||||||
|
// Success Story URLs
|
||||||
|
{
|
||||||
|
path: "/success-story/list",
|
||||||
|
name: "ListStories",
|
||||||
|
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryList.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/success-story/:id/edit",
|
||||||
|
name: "EditStory",
|
||||||
|
component: () => import(/* webpackChunkName: "succedit" */ "../views/success-story/StoryEdit.vue")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/success-story/:id/view",
|
||||||
|
name: "ViewStory",
|
||||||
|
component: () => import(/* webpackChunkName: "success" */ "../views/success-story/StoryView.vue")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
/** The routes that do not require logins */
|
||||||
|
const publicRoutes : Array<RouteRecordName> = [
|
||||||
|
"Home", "HowItWorks", "PrivacyPolicy", "TermsOfService", "LogOn", "CitizenAuthorized", "PublicSearchProfiles",
|
||||||
|
"DeletionSuccess"
|
||||||
|
]
|
||||||
|
|
||||||
|
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 && !publicRoutes.includes(to.name ?? "")) {
|
||||||
|
window.localStorage.setItem(AFTER_LOG_ON_URL, to.fullPath)
|
||||||
|
return "/citizen/log-on"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
6
src/JobsJobsJobs/App/src/shims-vue.d.ts
vendored
Normal file
6
src/JobsJobsJobs/App/src/shims-vue.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue"
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
66
src/JobsJobsJobs/App/src/store/index.ts
Normal file
66
src/JobsJobsJobs/App/src/store/index.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { InjectionKey } from "vue"
|
||||||
|
import { createStore, Store, useStore as baseUseStore } from "vuex"
|
||||||
|
import api, { Continent, LogOnSuccess } from "../api"
|
||||||
|
|
||||||
|
/** The state tracked by the application */
|
||||||
|
export interface State {
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
state: () : State => {
|
||||||
|
return {
|
||||||
|
user: undefined,
|
||||||
|
logOnState: "<em>Welcome back! Verifying your No Agenda Social account…</em>",
|
||||||
|
continents: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
setUser (state, user : LogOnSuccess) {
|
||||||
|
state.user = user
|
||||||
|
},
|
||||||
|
clearUser (state) {
|
||||||
|
state.user = undefined
|
||||||
|
},
|
||||||
|
setLogOnState (state, message : string) {
|
||||||
|
state.logOnState = message
|
||||||
|
},
|
||||||
|
setContinents (state, continents : Continent[]) {
|
||||||
|
state.continents = continents
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async logOn ({ commit }, code: string) {
|
||||||
|
const logOnResult = await api.citizen.logOn(code)
|
||||||
|
if (typeof logOnResult === "string") {
|
||||||
|
commit("setLogOnState", logOnResult)
|
||||||
|
} else {
|
||||||
|
commit("setUser", logOnResult)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ensureContinents ({ state, commit }) {
|
||||||
|
if (state.continents.length > 0) return
|
||||||
|
const theSeven = await api.continent.all()
|
||||||
|
if (typeof theSeven === "string") {
|
||||||
|
console.error(theSeven)
|
||||||
|
} else {
|
||||||
|
commit("setContinents", theSeven)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
}
|
||||||
|
})
|
19
src/JobsJobsJobs/App/src/views/Home.vue
Normal file
19
src/JobsJobsJobs/App/src/views/Home.vue
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Welcome!")
|
||||||
|
p
|
||||||
|
p.
|
||||||
|
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in
|
||||||
|
finding employment. This will enable them to continue providing value-for-value to Adam and John, as they continue
|
||||||
|
their work deconstructing the misinformation that passes for news on a day-to-day basis.
|
||||||
|
p
|
||||||
|
| Do you not understand the terms in the paragraph above? No worries; just head over to
|
||||||
|
|
|
||||||
|
a(href="https://noagendashow.net" target="_blank") The Best Podcast in the Universe
|
||||||
|
= " "
|
||||||
|
| #[em #[audio-clip(clip="thats-true") (that’s true!)]] and find out what you’re missing.
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AudioClip from "@/components/AudioClip.vue"
|
||||||
|
</script>
|
168
src/JobsJobsJobs/App/src/views/HowItWorks.vue
Normal file
168
src/JobsJobsJobs/App/src/views/HowItWorks.vue
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="How It Works")
|
||||||
|
h3 How It Works
|
||||||
|
h5.pb-3.text-muted: em Last Updated August 29#[sup th], 2021
|
||||||
|
p: em.
|
||||||
|
Show me how to #[a(href="#listing-search") find a job]
|
||||||
|
#[!= " • "]#[a(href="#listing") list a job opportunity]
|
||||||
|
#[!= " • "]#[a(href="#profile-search") find people to hire]
|
||||||
|
#[!= " • "]#[a(href="#profile") create an employment profile]
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
h4#listing-search Find a Job Listing
|
||||||
|
p.
|
||||||
|
Active job listings are found on the #[span.link Help Wanted!] 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
|
||||||
|
#[span.button Search] 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.
|
||||||
|
Clicking the #[span.link View] 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 No Agenda Social; you can use that to get their handle,
|
||||||
|
and use NAS’s communication facilites to inquire about the position.
|
||||||
|
p: em.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.)
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
h4#listing Job Listings
|
||||||
|
h5 Create a Job Listing
|
||||||
|
p.
|
||||||
|
The #[span.link My Job Listings] page shows all of the job listings you have created. To add a new one, click the
|
||||||
|
#[span.button Add a New Listing] 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]. Once you save the listing, it will be visible to the other
|
||||||
|
citizens here.
|
||||||
|
|
||||||
|
h5 Maintain and Share Your Job Listings
|
||||||
|
p.
|
||||||
|
The #[span.link My Job Listings] page will show you all of your active job listings just below the
|
||||||
|
#[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on
|
||||||
|
that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share
|
||||||
|
the link from your browser over on No Agenda Social, 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.)
|
||||||
|
|
||||||
|
h5 Expire a Job Listing
|
||||||
|
p.
|
||||||
|
Once the job is filled, or the opportunity has passed, you will want to expire the listing; this is what the
|
||||||
|
#[span.link Expire] 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 #[span.button Expire] 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.
|
||||||
|
Once you have at least one expired job listing, the #[span.link My Job Listing] 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.
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
h4#profile-search Searching Profiles
|
||||||
|
p.
|
||||||
|
The #[span.link Employment Profiles] 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 No Agenda Social account, where you can use its features to get in touch.
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
h4#profile Your Employment Profile
|
||||||
|
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 No Agenda Social; this system updates your current
|
||||||
|
display name each time you log on.
|
||||||
|
|
||||||
|
h5 Completing Your Profile
|
||||||
|
p.
|
||||||
|
The #[span.link My Employment Profile] page lets you establish or modify your employment profile; the
|
||||||
|
#[span.link Dashboard] page also has buttons that let you create, edit, and view your profile.
|
||||||
|
ul
|
||||||
|
li.
|
||||||
|
The #[span.link Professional Biography] is the “Objective” part of a traditional résumé.
|
||||||
|
This section supports #[a(href="#markdown") Markdown], so you can include actual headings, formatting, etc.
|
||||||
|
li.
|
||||||
|
Skills are optional, but they are the place to record skills you have. Along with each skill, there is a
|
||||||
|
#[span.link Notes] 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.
|
||||||
|
The #[span.link Experience] 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.text-muted (If you would like a chronological job builder, reach out and let us know.)]
|
||||||
|
li.
|
||||||
|
If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] 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 No Agenda Social handle or real name; they are there to let people peek
|
||||||
|
behind the curtain a bit, and hopefully inspire them to join us.
|
||||||
|
|
||||||
|
h5 Viewing and Sharing Your Profile
|
||||||
|
p.
|
||||||
|
Once your profile has been established, the #[span.link My Employment Profile] 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 #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you
|
||||||
|
would like to share it there. Just as with job listings, existing users will go straight there, while other No
|
||||||
|
Agenda Social users will get there once they authorize this application.
|
||||||
|
p.
|
||||||
|
The name on employment profiles is a link to that user’s profile on No Agenda Social; from there, others can
|
||||||
|
communicate further with you using the tools Mastodon provides.
|
||||||
|
|
||||||
|
h5 “I Found a Job!”
|
||||||
|
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 #[span.link Dashboard] and
|
||||||
|
#[span.link My Employment Profile] 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.
|
||||||
|
As a validated user, you can also view others success stories. Clicking #[span.link Success Stories] 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 #[span.link Edit] link.
|
||||||
|
|
||||||
|
h5 Publicly Available Information
|
||||||
|
p.
|
||||||
|
The #[span.link Job Seekers] 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] 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!
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
h4#markdown A Bit about Markdown
|
||||||
|
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") original page] for the project is a good
|
||||||
|
good overview of its capabilities, and the pages at
|
||||||
|
#[a(href="https://www.markdownguide.org/" target="_blank") Markdown Guide] 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.
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
h4 Help / Suggestions
|
||||||
|
p.
|
||||||
|
This is open-source software
|
||||||
|
#[a(href="https://github.com/bit-badger/jobs-jobs-jobs" _target="_blank") developed on Github]; feel free to
|
||||||
|
#[a(href="https://github.com/bit-badger/jobs-jobs-jobs/issues" target="_blank") create an issue there], or look up
|
||||||
|
@danieljsummers on No Agenda Social.
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
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>
|
343
src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue
Normal file
343
src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue
Normal file
|
@ -0,0 +1,343 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Privacy Policy")
|
||||||
|
h3 Privacy Policy
|
||||||
|
p: em (as of February 6#[sup th], 2021)
|
||||||
|
|
||||||
|
p.
|
||||||
|
{{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This
|
||||||
|
Privacy Policy explains how your personal information is collected, used, and disclosed by {{name}}.
|
||||||
|
p.
|
||||||
|
This Privacy Policy applies to our website, and its associated subdomains (collectively, our “Service”)
|
||||||
|
alongside our application, {{name}}. By accessing or using our Service, you signify that you have read, understood,
|
||||||
|
and agree to our collection, storage, use, and disclosure of your personal information as described in this Privacy
|
||||||
|
Policy and our Terms of Service.
|
||||||
|
|
||||||
|
h4 Definitions and key terms
|
||||||
|
p.
|
||||||
|
To help explain things as clearly as possible in this Privacy Policy, every time any of these terms are referenced,
|
||||||
|
are strictly defined as:
|
||||||
|
ul
|
||||||
|
li.
|
||||||
|
Cookie: small amount of data generated by a website and saved by your web browser. It is used to identify your
|
||||||
|
browser, provide analytics, remember information about you such as your language preference or login information.
|
||||||
|
li.
|
||||||
|
Company: when this policy mentions “Company,” “we,” “us,” or
|
||||||
|
“our,” it refers to {{name}}, that is responsible for your information under this Privacy Policy.
|
||||||
|
li Country: where {{name}} or the owners/founders of {{name}} are based, in this case is US.
|
||||||
|
li.
|
||||||
|
Customer: refers to the company, organization or person that signs up to use the {{name}} Service to manage the
|
||||||
|
relationships with your consumers or service users.
|
||||||
|
li.
|
||||||
|
Device: any internet connected device such as a phone, tablet, computer or any other device that can be used to
|
||||||
|
visit {{name}} and use the services.
|
||||||
|
li.
|
||||||
|
IP address: Every device connected to the Internet is assigned a number known as an Internet protocol (IP)
|
||||||
|
address. These numbers are usually assigned in geographic blocks. An IP address can often be used to identify the
|
||||||
|
location from which a device is connecting to the Internet.
|
||||||
|
li.
|
||||||
|
Personnel: refers to those individuals who are employed by {{name}} or are under contract to perform a service on
|
||||||
|
behalf of one of the parties.
|
||||||
|
li.
|
||||||
|
Personal Data: any information that directly, indirectly, or in connection with other information — including a
|
||||||
|
personal identification number — allows for the identification or identifiability of a natural person.
|
||||||
|
li.
|
||||||
|
Service: refers to the service provided by {{name}} as described in the relative terms (if available) and on this
|
||||||
|
platform.
|
||||||
|
li.
|
||||||
|
Third-party service: refers to advertisers, contest sponsors, promotional and marketing partners, and others who
|
||||||
|
provide our content or whose products or services we think may interest you.
|
||||||
|
li.
|
||||||
|
Website: {{name}}’s site, which can be accessed via this URL:
|
||||||
|
#[router-link(to="/") https://noagendacareers.com/]
|
||||||
|
li You: a person or entity that is registered with {{name}} to use the Services.
|
||||||
|
|
||||||
|
h4 What Information Do We Collect?
|
||||||
|
p We collect information from you when you visit our website, register on our site, or fill out a form.
|
||||||
|
ul
|
||||||
|
li Name / Username
|
||||||
|
li Coarse Geographic Location
|
||||||
|
li Employment History
|
||||||
|
li No Agenda Social Account Name / Profile
|
||||||
|
|
||||||
|
h4 How Do We Use The Information We Collect?
|
||||||
|
p Any of the information we collect from you may be used in one of the following ways:
|
||||||
|
ul
|
||||||
|
li To personalize your experience (your information helps us to better respond to your individual needs)
|
||||||
|
li.
|
||||||
|
To improve our website (we continually strive to improve our website offerings based on the information and
|
||||||
|
feedback we receive from you)
|
||||||
|
li.
|
||||||
|
To improve customer service (your information helps us to more effectively respond to your customer service
|
||||||
|
requests and support needs)
|
||||||
|
|
||||||
|
h4 When does {{name}} use end user information from third parties?
|
||||||
|
p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers.
|
||||||
|
p.
|
||||||
|
End users may voluntarily provide us with information they have made available on social media websites
|
||||||
|
(specifically No Agenda Social). If you provide us with any such information, we may collect publicly available
|
||||||
|
information from the social media websites you have indicated. You can control how much of your information social
|
||||||
|
media websites make public by visiting these websites and changing your privacy settings.
|
||||||
|
|
||||||
|
h4 When does {{name}} use customer information from third parties?
|
||||||
|
p We do not utilize third party information apart from the end-user data described above.
|
||||||
|
|
||||||
|
h4 Do we share the information we collect with third parties?
|
||||||
|
p.
|
||||||
|
We may disclose personal and non-personal information about you to government or law enforcement officials or
|
||||||
|
private parties as we, in our sole discretion, believe necessary or appropriate in order to respond to claims, legal
|
||||||
|
process (including subpoenas), to protect our rights and interests or those of a third party, the safety of the
|
||||||
|
public or any person, to prevent or stop any illegal, unethical, or legally actionable activity, or to otherwise
|
||||||
|
comply with applicable court orders, laws, rules and regulations.
|
||||||
|
|
||||||
|
h4 Where and when is information collected from customers and end users?
|
||||||
|
p.
|
||||||
|
{{name}} will collect personal information that you submit to us. We may also receive personal information about you
|
||||||
|
from third parties as described above.
|
||||||
|
|
||||||
|
h4 How Do We Use Your E-mail Address?
|
||||||
|
p.
|
||||||
|
We do not collect nor use an e-mail address. If you have provided it in the free text areas of the site, other
|
||||||
|
validated users may be able to view it, but {{name}} does not search for nor utilize e-mail addresses from those
|
||||||
|
areas.
|
||||||
|
|
||||||
|
h4 How Long Do We Keep Your Information?
|
||||||
|
p.
|
||||||
|
We keep your information only so long as we need it to provide {{name}} to you and fulfill the purposes described in
|
||||||
|
this policy. When we no longer need to use your information and there is no need for us to keep it to comply with
|
||||||
|
our legal or regulatory obligations, we’ll either remove it from our systems or depersonalize it so that we
|
||||||
|
can’t identify you.
|
||||||
|
|
||||||
|
h4 How Do We Protect Your Information?
|
||||||
|
p.
|
||||||
|
We implement a variety of security measures to maintain the safety of your personal information when you enter,
|
||||||
|
submit, or access your personal information. We mandate the use of a secure server. We cannot, however, ensure or
|
||||||
|
warrant the absolute security of any information you transmit to {{name}} or guarantee that your information on the
|
||||||
|
Service may not be accessed, disclosed, altered, or destroyed by a breach of any of our physical, technical, or
|
||||||
|
managerial safeguards.
|
||||||
|
|
||||||
|
h4 Could my information be transferred to other countries?
|
||||||
|
p.
|
||||||
|
{{name}} is hosted in the US. Information collected via our website may be viewed and hosted anywhere in the world,
|
||||||
|
including countries that may not have laws of general applicability regulating the use and transfer of such data. To
|
||||||
|
the fullest extent allowed by applicable law, by using any of the above, you voluntarily consent to the trans-border
|
||||||
|
transfer and hosting of such information.
|
||||||
|
|
||||||
|
h4 Is the information collected through the {{name}} Service secure?
|
||||||
|
p.
|
||||||
|
We take precautions to protect the security of your information. We have physical, electronic, and managerial
|
||||||
|
procedures to help safeguard, prevent unauthorized access, maintain data security, and correctly use your
|
||||||
|
information. However, neither people nor security systems are foolproof, including encryption systems. In addition,
|
||||||
|
people can commit intentional crimes, make mistakes, or fail to follow policies. Therefore, while we use reasonable
|
||||||
|
efforts to protect your personal information, we cannot guarantee its absolute security. If applicable law imposes
|
||||||
|
any non-disclaimable duty to protect your personal information, you agree that intentional misconduct will be the
|
||||||
|
standards used to measure our compliance with that duty.
|
||||||
|
|
||||||
|
h4 Can I update or correct my information?
|
||||||
|
p.
|
||||||
|
The rights you have to request updates or corrections to the information {{name}} collects depend on your
|
||||||
|
relationship with {{name}}.
|
||||||
|
p.
|
||||||
|
Customers have the right to request the restriction of certain uses and disclosures of personally identifiable
|
||||||
|
information as follows. You can contact us in order to (1) update or correct your personally identifiable
|
||||||
|
information, or (3) delete the personally identifiable information maintained about you on our systems (subject to
|
||||||
|
the following paragraph), by cancelling your account. Such updates, corrections, changes and deletions will have no
|
||||||
|
effect on other information that we maintain in accordance with this Privacy Policy prior to such update,
|
||||||
|
correction, change, or deletion. You are responsible for maintaining the secrecy of your unique password and account
|
||||||
|
information at all times.
|
||||||
|
p.
|
||||||
|
{{name}} also provides ways for users to modify or remove the information we have collected from them from the
|
||||||
|
application; these actions will have the same effect as contacting us to modify or remove data.
|
||||||
|
p.
|
||||||
|
You should be aware that it is not technologically possible to remove each and every record of the information you
|
||||||
|
have provided to us from our system. The need to back up our systems to protect information from inadvertent loss
|
||||||
|
means that a copy of your information may exist in a non-erasable form that will be difficult or impossible for us
|
||||||
|
to locate. Promptly after receiving your request, all personal information stored in databases we actively use, and
|
||||||
|
other readily searchable media will be updated, corrected, changed, or deleted, as appropriate, as soon as and to
|
||||||
|
the extent reasonably and technically practicable.
|
||||||
|
p.
|
||||||
|
If you are an end user and wish to update, delete, or receive any information we have about you, you may do so by
|
||||||
|
contacting the organization of which you are a customer.
|
||||||
|
|
||||||
|
h4 Governing Law
|
||||||
|
p.
|
||||||
|
This Privacy Policy is governed by the laws of US without regard to its conflict of laws provision. You consent to
|
||||||
|
the exclusive jurisdiction of the courts in connection with any action or dispute arising between the parties under
|
||||||
|
or in connection with this Privacy Policy except for those individuals who may have rights to make claims under
|
||||||
|
Privacy Shield, or the Swiss-US framework.
|
||||||
|
p.
|
||||||
|
The laws of US, excluding its conflicts of law rules, shall govern this Agreement and your use of the website. Your
|
||||||
|
use of the website may also be subject to other local, state, national, or international laws.
|
||||||
|
p.
|
||||||
|
By using {{name}} or contacting us directly, you signify your acceptance of this Privacy Policy. If you do not agree
|
||||||
|
to this Privacy Policy, you should not engage with our website, or use our services. Continued use of the website,
|
||||||
|
direct engagement with us, or following the posting of changes to this Privacy Policy that do not significantly
|
||||||
|
affect the use or disclosure of your personal information will mean that you accept those changes.
|
||||||
|
|
||||||
|
h4 Your Consent
|
||||||
|
p.
|
||||||
|
We’ve updated our Privacy Policy to provide you with complete transparency into what is being set when you
|
||||||
|
visit our site and how it’s being used. By using our website, registering an account, or making a purchase,
|
||||||
|
you hereby consent to our Privacy Policy and agree to its terms.
|
||||||
|
|
||||||
|
h4 Links to Other Websites
|
||||||
|
p.
|
||||||
|
This Privacy Policy applies only to the Services. The Services may contain links to other websites not operated or
|
||||||
|
controlled by {{name}}. We are not responsible for the content, accuracy or opinions expressed in such websites, and
|
||||||
|
such websites are not investigated, monitored or checked for accuracy or completeness by us. Please remember that
|
||||||
|
when you use a link to go from the Services to another website, our Privacy Policy is no longer in effect. Your
|
||||||
|
browsing and interaction on any other website, including those that have a link on our platform, is subject to that
|
||||||
|
website’s own rules and policies. Such third parties may use their own cookies or other methods to collect
|
||||||
|
information about you.
|
||||||
|
|
||||||
|
h4 Cookies
|
||||||
|
p {{name}} does not use Cookies.
|
||||||
|
|
||||||
|
h4 Kids’ Privacy
|
||||||
|
p.
|
||||||
|
We do not address anyone under the age of 13. We do not knowingly collect personally identifiable information from
|
||||||
|
anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with
|
||||||
|
Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age
|
||||||
|
of 13 without verification of parental consent, We take steps to remove that information from Our servers.
|
||||||
|
|
||||||
|
h4 Changes To Our Privacy Policy
|
||||||
|
p.
|
||||||
|
We may change our Service and policies, and we may need to make changes to this Privacy Policy so that they
|
||||||
|
accurately reflect our Service and policies. Unless otherwise required by law, we will notify you (for example,
|
||||||
|
through our Service) before we make changes to this Privacy Policy and give you an opportunity to review them before
|
||||||
|
they go into effect. Then, if you continue to use the Service, you will be bound by the updated Privacy Policy. If
|
||||||
|
you do not want to agree to this or any updated Privacy Policy, you can delete your account.
|
||||||
|
|
||||||
|
h4 Third-Party Services
|
||||||
|
p.
|
||||||
|
We may display, include or make available third-party content (including data, information, applications and other
|
||||||
|
products services) or provide links to third-party websites or services (“Third-Party Services”).
|
||||||
|
p.
|
||||||
|
You acknowledge and agree that {{name}} shall not be responsible for any Third-Party Services, including their
|
||||||
|
accuracy, completeness, timeliness, validity, copyright compliance, legality, decency, quality or any other aspect
|
||||||
|
thereof. {{name}} does not assume and shall not have any liability or responsibility to you or any other person or
|
||||||
|
entity for any Third-Party Services.
|
||||||
|
p.
|
||||||
|
Third-Party Services and links thereto are provided solely as a convenience to you and you access and use them
|
||||||
|
entirely at your own risk and subject to such third parties’ terms and conditions.
|
||||||
|
|
||||||
|
h4 Tracking Technologies
|
||||||
|
p.
|
||||||
|
{{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that
|
||||||
|
token is stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is
|
||||||
|
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the
|
||||||
|
application can be used again.
|
||||||
|
|
||||||
|
h4 Information about General Data Protection Regulation (GDPR)
|
||||||
|
p.
|
||||||
|
We may be collecting and using information from you if you are from the European Economic Area (EEA), and in this
|
||||||
|
section of our Privacy Policy we are going to explain exactly how and why is this data collected, and how we
|
||||||
|
maintain this data under protection from being replicated or used in the wrong way.
|
||||||
|
|
||||||
|
h5 What is GDPR?
|
||||||
|
p.
|
||||||
|
GDPR is an EU-wide privacy and data protection law that regulates how EU residents’ data is protected by
|
||||||
|
companies and enhances the control the EU residents have, over their personal data.
|
||||||
|
p.
|
||||||
|
The GDPR is relevant to any globally operating company and not just the EU-based businesses and EU residents. Our
|
||||||
|
customers’ data is important irrespective of where they are located, which is why we have implemented GDPR controls
|
||||||
|
as our baseline standard for all our operations worldwide.
|
||||||
|
|
||||||
|
h5 What is personal data?
|
||||||
|
p.
|
||||||
|
Any data that relates to an identifiable or identified individual. GDPR covers a broad spectrum of information that
|
||||||
|
could be used on its own, or in combination with other pieces of information, to identify a person. Personal data
|
||||||
|
extends beyond a person’s name or email address. Some examples include financial information, political opinions,
|
||||||
|
genetic data, biometric data, IP addresses, physical address, sexual orientation, and ethnicity.
|
||||||
|
p The Data Protection Principles include requirements such as:
|
||||||
|
ul
|
||||||
|
li.
|
||||||
|
Personal data collected must be processed in a fair, legal, and transparent way and should only be used in a way
|
||||||
|
that a person would reasonably expect.
|
||||||
|
li.
|
||||||
|
Personal data should only be collected to fulfil a specific purpose and it should only be used for that purpose.
|
||||||
|
Organizations must specify why they need the personal data when they collect it.
|
||||||
|
li Personal data should be held no longer than necessary to fulfil its purpose.
|
||||||
|
li.
|
||||||
|
People covered by the GDPR have the right to access their own personal data. They can also request a copy of their
|
||||||
|
data, and that their data be updated, deleted, restricted, or moved to another organization.
|
||||||
|
|
||||||
|
h5 Why is GDPR important?
|
||||||
|
p.
|
||||||
|
GDPR adds some new requirements regarding how companies should protect individuals’ personal data that they
|
||||||
|
collect and process. It also raises the stakes for compliance by increasing enforcement and imposing greater fines
|
||||||
|
for breach. Beyond these facts, it’s simply the right thing to do. At {{name}} we strongly believe that your
|
||||||
|
data privacy is very important and we already have solid security and privacy practices in place that go beyond the
|
||||||
|
requirements of this regulation.
|
||||||
|
|
||||||
|
h5 Individual Data Subject’s Rights - Data Access, Portability, and Deletion
|
||||||
|
p.
|
||||||
|
We are committed to helping our customers meet the data subject rights requirements of GDPR. {{name}} processes or
|
||||||
|
stores all personal data in fully vetted, DPA compliant vendors. We do store all conversation and personal data for
|
||||||
|
up to 6 years unless your account is deleted. In which case, we dispose of all data in accordance with our Terms of
|
||||||
|
Service and Privacy Policy, but we will not hold it longer than 60 days.
|
||||||
|
p.
|
||||||
|
We are aware that if you are working with EU customers, you need to be able to provide them with the ability to
|
||||||
|
access, update, retrieve and remove personal data. We got you! We’ve been set up as self service from the
|
||||||
|
start and have always given you access to your data. Our customer support team is here for you to answer any
|
||||||
|
questions you might have about working with the API.
|
||||||
|
|
||||||
|
h4 California Residents
|
||||||
|
p.
|
||||||
|
The California Consumer Privacy Act (CCPA) requires us to disclose categories of Personal Information we collect and
|
||||||
|
how we use it, the categories of sources from whom we collect Personal Information, and the third parties with whom
|
||||||
|
we share it, which we have explained above.
|
||||||
|
p.
|
||||||
|
We are also required to communicate information about rights California residents have under California law. You may
|
||||||
|
exercise the following rights:
|
||||||
|
ul
|
||||||
|
li.
|
||||||
|
Right to Know and Access. You may submit a verifiable request for information regarding the: (1) categories of
|
||||||
|
Personal Information we collect, use, or share; (2) purposes for which categories of Personal Information are
|
||||||
|
collected or used by us; (3) categories of sources from which we collect Personal Information; and (4) specific
|
||||||
|
pieces of Personal Information we have collected about you.
|
||||||
|
li Right to Equal Service. We will not discriminate against you if you exercise your privacy rights.
|
||||||
|
li.
|
||||||
|
Right to Delete. You may submit a verifiable request to close your account and we will delete Personal Information
|
||||||
|
about you that we have collected.
|
||||||
|
li Request that a business that sells a consumer’s personal data, not sell the consumer’s personal data.
|
||||||
|
p.
|
||||||
|
If you make a request, we have one month to respond to you. If you would like to exercise any of these rights,
|
||||||
|
please contact us.
|
||||||
|
p We do not sell the Personal Information of our users.
|
||||||
|
p For more information about these rights, please contact us.
|
||||||
|
|
||||||
|
h5 California Online Privacy Protection Act (CalOPPA)
|
||||||
|
p.
|
||||||
|
CalOPPA requires us to disclose categories of Personal Information we collect and how we use it, the categories of
|
||||||
|
sources from whom we collect Personal Information, and the third parties with whom we share it, which we have
|
||||||
|
explained above.
|
||||||
|
p CalOPPA users have the following rights:
|
||||||
|
ul
|
||||||
|
li.
|
||||||
|
Right to Know and Access. You may submit a verifiable request for information regarding the: (1) categories of
|
||||||
|
Personal Information we collect, use, or share; (2) purposes for which categories of Personal Information are
|
||||||
|
collected or used by us; (3) categories of sources from which we collect Personal Information; and (4) specific
|
||||||
|
pieces of Personal Information we have collected about you.
|
||||||
|
li Right to Equal Service. We will not discriminate against you if you exercise your privacy rights.
|
||||||
|
li.
|
||||||
|
Right to Delete. You may submit a verifiable request to close your account and we will delete Personal Information
|
||||||
|
about you that we have collected.
|
||||||
|
li.
|
||||||
|
Right to request that a business that sells a consumer’s personal data, not sell the consumer’s
|
||||||
|
personal data.
|
||||||
|
p.
|
||||||
|
If you make a request, we have one month to respond to you. If you would like to exercise any of these rights,
|
||||||
|
please contact us.
|
||||||
|
p We do not sell the Personal Information of our users.
|
||||||
|
p For more information about these rights, please contact us.
|
||||||
|
|
||||||
|
h4 Contact Us
|
||||||
|
p Don’t hesitate to contact us if you have any questions.
|
||||||
|
ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works]
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/** The name of the application */
|
||||||
|
const name = "Jobs, Jobs, Jobs"
|
||||||
|
</script>
|
37
src/JobsJobsJobs/App/src/views/TermsOfService.vue
Normal file
37
src/JobsJobsJobs/App/src/views/TermsOfService.vue
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Terms of Service")
|
||||||
|
h3 Terms of Service
|
||||||
|
p: em (as of February 6#[sup th], 2021)
|
||||||
|
|
||||||
|
h4 Acceptance of Terms
|
||||||
|
p.
|
||||||
|
By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are responsible
|
||||||
|
to ensure that your use of this site complies with all applicable laws. Your continued use of this site implies your
|
||||||
|
acceptance of these terms.
|
||||||
|
|
||||||
|
h4 Description of Service and Registration
|
||||||
|
p.
|
||||||
|
Jobs, Jobs, Jobs is a service that allows individuals to enter and amend employment profiles, restricting access
|
||||||
|
to the details of these profiles to other users of
|
||||||
|
#[a(href="https://noagendasocial.com" target="_blank") No Agenda Social]. Registration is accomplished by allowing
|
||||||
|
Jobs, Jobs, Jobs to read one’s No Agenda Social profile. See our
|
||||||
|
#[router-link(to="/privacy-policy") privacy policy] for details on the personal (user) information we maintain.
|
||||||
|
|
||||||
|
h4 Liability
|
||||||
|
p.
|
||||||
|
This service is provided “as is”, and no warranty (express or implied) exists. The service and its
|
||||||
|
developers may not be held liable for any damages that may arise through the use of this service.
|
||||||
|
|
||||||
|
h4 Updates to Terms
|
||||||
|
p.
|
||||||
|
These terms and conditions may be updated at any time. When these terms are updated, users will be notified via a
|
||||||
|
notice on the dashboard page. Additionally, the date at the top of this page will be updated, and any substantive
|
||||||
|
updates will also be accompanied by a summary of those changes.
|
||||||
|
|
||||||
|
hr
|
||||||
|
|
||||||
|
p.
|
||||||
|
You may also wish to review our #[router-link(to="/privacy-policy") privacy policy] to learn how we handle your
|
||||||
|
data.
|
||||||
|
</template>
|
41
src/JobsJobsJobs/App/src/views/citizen/Authorized.vue
Normal file
41
src/JobsJobsJobs/App/src/views/citizen/Authorized.vue
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Logging on...")
|
||||||
|
p
|
||||||
|
p(v-html="message")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
import { AFTER_LOG_ON_URL } from "@/router"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** Pass the code to the API and exchange it for a user and a JWT */
|
||||||
|
const logOn = async () => {
|
||||||
|
const code = router.currentRoute.value.query.code
|
||||||
|
if (code) {
|
||||||
|
await store.dispatch("logOn", code)
|
||||||
|
if (store.state.user !== undefined) {
|
||||||
|
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
||||||
|
if (afterLogOnUrl) {
|
||||||
|
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
||||||
|
router.push(afterLogOnUrl)
|
||||||
|
} else {
|
||||||
|
router.push("/citizen/dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.commit("setLogOnState",
|
||||||
|
"Did not receive a token from No Agenda Social (perhaps you clicked “Cancel”?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(logOn)
|
||||||
|
|
||||||
|
/** Accessor for the log on state */
|
||||||
|
const message = computed(() => store.state.logOnState)
|
||||||
|
</script>
|
77
src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue
Normal file
77
src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article.container
|
||||||
|
page-title(title="Dashboard")
|
||||||
|
h3.pb-4 Welcome, {{user.name}}
|
||||||
|
load-data(:load="retrieveData"): .row.row-cols-1.row-cols-md-2
|
||||||
|
.col: .card.h-100
|
||||||
|
h5.card-header Your Profile
|
||||||
|
.card-body
|
||||||
|
h6.card-subtitle.mb-3.text-muted.fst-italic Last updated #[full-date-time(:date="profile.lastUpdatedOn")]
|
||||||
|
p.card-text(v-if="profile")
|
||||||
|
| Your profile currently lists {{profile.skills.length}}
|
||||||
|
| skill#[template(v-if="profile.skills.length !== 1") s].
|
||||||
|
span(v-if="profile.seekingEmployment")
|
||||||
|
br
|
||||||
|
br
|
||||||
|
| Your profile indicates that you are seeking employment. Once you find it,
|
||||||
|
router-link(to="/success-story/add") tell your fellow citizens about it!
|
||||||
|
p.card-text(v-else).
|
||||||
|
You do not have an employment profile established; click below (or “Edit Profile” in the menu) to
|
||||||
|
get started!
|
||||||
|
.card-footer
|
||||||
|
template(v-if="profile")
|
||||||
|
router-link.btn.btn-outline-secondary(:to="`/profile/${user.citizenId}/view`") View Profile
|
||||||
|
|
|
||||||
|
router-link.btn.btn-outline-secondary(to="/citizen/profile") Edit Profile
|
||||||
|
router-link.btn.btn-primary(v-else to="/citizen/profile") Create Profile
|
||||||
|
.col: .card.h-100
|
||||||
|
h5.card-header Other Citizens
|
||||||
|
.card-body
|
||||||
|
h6.card-subtitle.mb-3.text-muted.fst-italic
|
||||||
|
template(v-if="profileCount === 0") No
|
||||||
|
template(v-else) {{profileCount}} Total
|
||||||
|
| Employment Profile#[template(v-if="profileCount !== 1") s]
|
||||||
|
p.card-text(v-if="profileCount === 1 && profile") It looks like, for now, it’s just you…
|
||||||
|
p.card-text(v-else-if="profileCount > 0") Take a look around and see if you can help them find work!
|
||||||
|
p.card-text(v-else) You can click below, but you will not find anything…
|
||||||
|
.card-footer: router-link.btn.btn-outline-secondary(to="/profile/search") Search Profiles
|
||||||
|
p
|
||||||
|
p.
|
||||||
|
To see how this application works, check out “How It Works” in the sidebar (last updated August
|
||||||
|
29#[sup th], 2021).
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Ref, ref } from "vue"
|
||||||
|
import api, { LogOnSuccess, Profile } from "@/api"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import FullDateTime from "@/components/FullDateTime.vue"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** The currently logged-in user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The user's profile */
|
||||||
|
const profile : Ref<Profile | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** A count of profiles in the system */
|
||||||
|
const profileCount = ref(0)
|
||||||
|
|
||||||
|
const retrieveData = async (errors : string[]) => {
|
||||||
|
const profileResult = await api.profile.retreive(undefined, user)
|
||||||
|
if (typeof profileResult === "string") {
|
||||||
|
errors.push(profileResult)
|
||||||
|
} else if (typeof profileResult !== "undefined") {
|
||||||
|
profile.value = profileResult
|
||||||
|
}
|
||||||
|
const count = await api.profile.count(user)
|
||||||
|
if (typeof count === "string") {
|
||||||
|
errors.push(count)
|
||||||
|
} else {
|
||||||
|
profileCount.value = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
185
src/JobsJobsJobs/App/src/views/citizen/EditProfile.vue
Normal file
185
src/JobsJobsJobs/App/src/views/citizen/EditProfile.vue
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Edit Profile")
|
||||||
|
h3.pb-3 My Employment Profile
|
||||||
|
load-data(:load="retrieveData"): form.row.g-3
|
||||||
|
.col-12.col-sm-10.col-md-8.col-lg-6
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="realName" v-model="v$.realName.$model" maxlength="255"
|
||||||
|
placeholder="Leave blank to use your NAS display name")
|
||||||
|
label(for="realName") Real Name
|
||||||
|
.form-text Leave blank to use your NAS display name
|
||||||
|
.col-12
|
||||||
|
.form-check
|
||||||
|
input.form-check-input(type="checkbox" id="isSeeking" v-model="v$.isSeekingEmployment.$model")
|
||||||
|
label.form-check-label(for="isSeeking") I am currently seeking employment
|
||||||
|
p(v-if="profile.isSeekingEmployment"): em.
|
||||||
|
If you have found employment, consider
|
||||||
|
#[router-link(to="/success-story/new/edit") telling your fellow citizens about it!]
|
||||||
|
.col-12.col-sm-6.col-md-4
|
||||||
|
continent-list(v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
|
||||||
|
@touch="v$.continentId.$touch() || true")
|
||||||
|
.col-12.col-sm-6.col-md-8
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="region" :class="{ 'is-invalid': v$.region.$error }"
|
||||||
|
v-model="v$.region.$model" maxlength="255"
|
||||||
|
placeholder="Country, state, geographic area, etc.")
|
||||||
|
#regionFeedback.invalid-feedback Please enter a region
|
||||||
|
label.jjj-required(for="region") Region
|
||||||
|
.form-text Country, state, geographic area, etc.
|
||||||
|
markdown-editor(id="bio" label="Professional Biography" v-model:text="v$.biography.$model"
|
||||||
|
:isInvalid="v$.biography.$error")
|
||||||
|
.col-12.col-offset-md-2.col-md-4
|
||||||
|
.form-check
|
||||||
|
input.form-check-input(type="checkbox" id="isRemote" v-model="v$.remoteWork.$model")
|
||||||
|
label.form-check-label(for="isRemote") I am looking for remote work
|
||||||
|
.col-12.col-md-4
|
||||||
|
.form-check
|
||||||
|
input.form-check-input(type="checkbox" id="isFullTime" v-model="v$.fullTime.$model")
|
||||||
|
label.form-check-label(for="isFullTime") I am looking for full-time work
|
||||||
|
.col-12
|
||||||
|
hr
|
||||||
|
h4.pb-2 Skills #[button.btn.btn-sm.btn-outline-primary.rounded-pill(@click.prevent="addSkill") Add a Skill]
|
||||||
|
profile-skill-edit(v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
|
||||||
|
@remove="removeSkill(skill.id)" @input="v$.skills.$touch")
|
||||||
|
.col-12
|
||||||
|
hr
|
||||||
|
h4 Experience
|
||||||
|
p.
|
||||||
|
This application does not have a place to individually list your chronological job history; however, you can use
|
||||||
|
this area to list prior jobs, their dates, and anything else you want to include that’s not already a part
|
||||||
|
of your Professional Biography above.
|
||||||
|
markdown-editor(id="experience" label="Experience" v-model:text="v$.experience.$model")
|
||||||
|
.col-12: .form-check
|
||||||
|
input.form-check-input(type="checkbox" id="isPublic" v-model="v$.isPublic.$model")
|
||||||
|
label.form-check-label(for="isPublic") Allow my profile to be searched publicly (outside NA Social)
|
||||||
|
.col-12
|
||||||
|
p.text-danger(v-if="v$.$error") Please correct the errors above
|
||||||
|
button.btn.btn-primary(@click.prevent="saveProfile") #[icon(:icon="mdiContentSaveOutline")] Save
|
||||||
|
template(v-if="!isNew")
|
||||||
|
|
|
||||||
|
router-link.btn.btn-outline-secondary(:to="`/profile/${user.citizenId}/view`").
|
||||||
|
#[icon(color="#6c757d" :icon="mdiFileAccountOutline")] View Your User Profile
|
||||||
|
hr
|
||||||
|
p.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].)
|
||||||
|
maybe-save(:saveAction="saveProfile" :validator="v$")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, reactive } from "vue"
|
||||||
|
import { mdiContentSaveOutline, mdiFileAccountOutline } from "@mdi/js"
|
||||||
|
import useVuelidate from "@vuelidate/core"
|
||||||
|
import { required } from "@vuelidate/validators"
|
||||||
|
|
||||||
|
import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from "@/api"
|
||||||
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import ContinentList from "@/components/ContinentList.vue"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||||
|
import MaybeSave from "@/components/MaybeSave.vue"
|
||||||
|
import ProfileSkillEdit from "@/components/profile/SkillEdit.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** Whether this is a new profile */
|
||||||
|
const isNew = ref(false)
|
||||||
|
|
||||||
|
/** The starting values for a new employment profile */
|
||||||
|
const newProfile : Profile = {
|
||||||
|
id: user.citizenId,
|
||||||
|
seekingEmployment: false,
|
||||||
|
isPublic: false,
|
||||||
|
continentId: "",
|
||||||
|
region: "",
|
||||||
|
remoteWork: false,
|
||||||
|
fullTime: false,
|
||||||
|
biography: "",
|
||||||
|
lastUpdatedOn: "",
|
||||||
|
experience: undefined,
|
||||||
|
skills: []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's current profile (plus a few items, adapted for editing) */
|
||||||
|
const profile = reactive(new ProfileForm())
|
||||||
|
|
||||||
|
/** The validation rules for the form */
|
||||||
|
const rules = computed(() => ({
|
||||||
|
realName: { },
|
||||||
|
isSeekingEmployment: { },
|
||||||
|
isPublic: { },
|
||||||
|
continentId: { required },
|
||||||
|
region: { required },
|
||||||
|
remoteWork: { },
|
||||||
|
fullTime: { },
|
||||||
|
biography: { required },
|
||||||
|
experience: { },
|
||||||
|
skills: { }
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Initialize form validation */
|
||||||
|
const v$ = useVuelidate(rules, profile, { $lazy: true })
|
||||||
|
|
||||||
|
/** Retrieve the user's profile and their real name */
|
||||||
|
const retrieveData = async (errors : string[]) => {
|
||||||
|
const profileResult = await api.profile.retreive(undefined, user)
|
||||||
|
if (typeof profileResult === "string") {
|
||||||
|
errors.push(profileResult)
|
||||||
|
} else if (typeof profileResult === "undefined") {
|
||||||
|
isNew.value = true
|
||||||
|
}
|
||||||
|
const nameResult = await api.citizen.retrieve(user.citizenId, user)
|
||||||
|
if (typeof nameResult === "string") {
|
||||||
|
errors.push(nameResult)
|
||||||
|
}
|
||||||
|
if (errors.length > 0) return
|
||||||
|
// Update the empty form with appropriate values
|
||||||
|
const p = isNew.value ? newProfile : profileResult as Profile
|
||||||
|
profile.isSeekingEmployment = p.seekingEmployment
|
||||||
|
profile.isPublic = p.isPublic
|
||||||
|
profile.continentId = p.continentId
|
||||||
|
profile.region = p.region
|
||||||
|
profile.remoteWork = p.remoteWork
|
||||||
|
profile.fullTime = p.fullTime
|
||||||
|
profile.biography = p.biography
|
||||||
|
profile.experience = p.experience
|
||||||
|
profile.skills = p.skills
|
||||||
|
profile.realName = typeof nameResult !== "undefined" ? (nameResult as Citizen).realName ?? "" : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ID for new skills */
|
||||||
|
let newSkillId = 0
|
||||||
|
|
||||||
|
/** Add a skill to the profile */
|
||||||
|
const addSkill = () => {
|
||||||
|
profile.skills.push({ id: `new${newSkillId++}`, description: "", notes: undefined })
|
||||||
|
v$.value.skills.$touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the given skill from the profile */
|
||||||
|
const removeSkill = (skillId : string) => {
|
||||||
|
profile.skills = profile.skills.filter(s => s.id !== skillId)
|
||||||
|
v$.value.skills.$touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the current profile values */
|
||||||
|
const saveProfile = async () => {
|
||||||
|
v$.value.$touch()
|
||||||
|
if (v$.value.$error) return
|
||||||
|
// Remove any blank skills before submitting
|
||||||
|
profile.skills = profile.skills.filter(s => !(s.description.trim() === "" && (s.notes ?? "").trim() === ""))
|
||||||
|
const saveResult = await api.profile.save(profile, user)
|
||||||
|
if (typeof saveResult === "string") {
|
||||||
|
toastError(saveResult, "saving profile")
|
||||||
|
} else {
|
||||||
|
toastSuccess("Profile Saved Successfuly")
|
||||||
|
v$.value.$reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
22
src/JobsJobsJobs/App/src/views/citizen/LogOff.vue
Normal file
22
src/JobsJobsJobs/App/src/views/citizen/LogOff.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Logging off...")
|
||||||
|
p
|
||||||
|
p.fst-italic Logging off…
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.commit("clearUser")
|
||||||
|
toastSuccess("Log Off Successful | <strong>Have a Nice Day!</strong>")
|
||||||
|
router.push("/")
|
||||||
|
})
|
||||||
|
</script>
|
24
src/JobsJobsJobs/App/src/views/citizen/LogOn.vue
Normal file
24
src/JobsJobsJobs/App/src/views/citizen/LogOn.vue
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
p
|
||||||
|
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second…
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it
|
||||||
|
* can be called from two different places, and allow the app to support direct links to authorized content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The authorization URL to which the user should be directed */
|
||||||
|
const authUrl = (() => {
|
||||||
|
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
|
||||||
|
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU"
|
||||||
|
const client = `client_id=${id}`
|
||||||
|
const scope = "scope=read:accounts"
|
||||||
|
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized`
|
||||||
|
const respType = "response_type=code"
|
||||||
|
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
||||||
|
})()
|
||||||
|
document.location.assign(authUrl)
|
||||||
|
</script>
|
117
src/JobsJobsJobs/App/src/views/listing/HelpWanted.vue
Normal file
117
src/JobsJobsJobs/App/src/views/listing/HelpWanted.vue
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Help Wanted")
|
||||||
|
h3.pb-3 Help Wanted
|
||||||
|
p(v-if="!searched").
|
||||||
|
Enter relevant criteria to find results, or just click “Search” to see all current job listings.
|
||||||
|
collapse-panel(headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse")
|
||||||
|
listing-search-form(v-model="criteria" @search="doSearch")
|
||||||
|
error-list(:errors="errors")
|
||||||
|
p.pt-3(v-if="searching") Searching job listings…
|
||||||
|
template(v-else)
|
||||||
|
table.table.table-sm.table-hover.pt-3(v-if="results.length > 0")
|
||||||
|
thead: tr
|
||||||
|
th(scope="col") Listing
|
||||||
|
th(scope="col") Title
|
||||||
|
th(scope="col") Location
|
||||||
|
th.text-center(scope="col") Remote?
|
||||||
|
th.text-center(scope="col") Needed By
|
||||||
|
tbody: tr(v-for="it in results" :key="it.listing.id")
|
||||||
|
td: router-link(:to="`/listing/${it.listing.id}/view`") View
|
||||||
|
td {{it.listing.title}}
|
||||||
|
td {{it.continent.name}} / {{it.listing.region}}
|
||||||
|
td.text-center {{yesOrNo(it.listing.remoteWork)}}
|
||||||
|
td.text-center(v-if="it.listing.neededBy") {{formatNeededBy(it.listing.neededBy)}}
|
||||||
|
td.text-center(v-else) N/A
|
||||||
|
p.pt-3(v-else-if="searched") No job listings found for the specified criteria
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, Ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
|
import { formatNeededBy } from "./"
|
||||||
|
import { yesOrNo } from "@/App.vue"
|
||||||
|
import api, { ListingForView, ListingSearch, LogOnSuccess } from "@/api"
|
||||||
|
import { queryValue } from "@/router"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import CollapsePanel from "@/components/CollapsePanel.vue"
|
||||||
|
import ErrorList from "@/components/ErrorList.vue"
|
||||||
|
import ListingSearchForm from "@/components/ListingSearchForm.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** Any errors encountered while retrieving data */
|
||||||
|
const errors : Ref<string[]> = ref([])
|
||||||
|
|
||||||
|
/** Whether we are currently searching (retrieving data) */
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
/** Whether a search has been performed on this page since it has been loaded */
|
||||||
|
const searched = ref(false)
|
||||||
|
|
||||||
|
/** An empty set of search criteria */
|
||||||
|
const emptyCriteria = {
|
||||||
|
continentId: "",
|
||||||
|
region: undefined,
|
||||||
|
remoteWork: "",
|
||||||
|
text: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search criteria being built from the page */
|
||||||
|
const criteria : Ref<ListingSearch> = ref(emptyCriteria)
|
||||||
|
|
||||||
|
/** The current search results */
|
||||||
|
const results : Ref<ListingForView[]> = ref([])
|
||||||
|
|
||||||
|
/** Whether the search criteria should be collapsed */
|
||||||
|
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||||
|
|
||||||
|
/** Set up the page to match its requested state */
|
||||||
|
const setUpPage = async () => {
|
||||||
|
if (queryValue(route, "searched") === "true") {
|
||||||
|
searched.value = true
|
||||||
|
try {
|
||||||
|
searching.value = true
|
||||||
|
// Hold variable for ensuring continent ID is not undefined here, but excluded from search payload
|
||||||
|
const contId = queryValue(route, "continentId")
|
||||||
|
const searchParams : ListingSearch = {
|
||||||
|
continentId: contId === "" ? undefined : contId,
|
||||||
|
region: queryValue(route, "region"),
|
||||||
|
remoteWork: queryValue(route, "remoteWork") ?? "",
|
||||||
|
text: queryValue(route, "text")
|
||||||
|
}
|
||||||
|
const searchResult = await api.listings.search(searchParams, store.state.user as LogOnSuccess)
|
||||||
|
if (typeof searchResult === "string") {
|
||||||
|
errors.value.push(searchResult)
|
||||||
|
} else if (searchResult === undefined) {
|
||||||
|
errors.value.push(`The server returned a "Not Found" response (this should not happen)`)
|
||||||
|
} else {
|
||||||
|
results.value = searchResult
|
||||||
|
searchParams.continentId = searchParams.continentId ?? ""
|
||||||
|
criteria.value = searchParams
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
isCollapsed.value = searched.value && results.value.length > 0
|
||||||
|
} else {
|
||||||
|
searched.value = false
|
||||||
|
criteria.value = emptyCriteria
|
||||||
|
errors.value = []
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the page when the query string changes */
|
||||||
|
watch(() => route.query, setUpPage, { immediate: true })
|
||||||
|
|
||||||
|
/** Show or hide the search parameter panel */
|
||||||
|
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
|
||||||
|
|
||||||
|
/** Execute a search */
|
||||||
|
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
|
||||||
|
</script>
|
136
src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue
Normal file
136
src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(:title="isNew ? 'Add a Job Listing' : 'Edit Job Listing'")
|
||||||
|
h3.pb-3(v-if="isNew") Add a Job Listing
|
||||||
|
h3.pb-3(v-else) Edit Job Listing
|
||||||
|
load-data(:load="retrieveData"): form.row.g-3
|
||||||
|
.col-12.col-sm-10.col-md-8.col-lg-6
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="title" :class="{ 'is-invalid': v$.title.$error }" maxlength="255"
|
||||||
|
v-model="v$.title.$model" placeholder="The title for the job listing")
|
||||||
|
#titleFeedback.invalid-feedback Please enter a title for the job listing
|
||||||
|
label.jjj-required(for="title") Title
|
||||||
|
.form-text No need to put location here; it will always be show to seekers with continent and region
|
||||||
|
.col-12.col-sm-6.col-md-4
|
||||||
|
continent-list(v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
|
||||||
|
@touch="v$.continentId.$touch() || true")
|
||||||
|
.col-12.col-sm-6.col-md-8
|
||||||
|
.form-floating
|
||||||
|
input.form-control(type="text" id="region" :class="{ 'is-invalid': v$.region.$error }" maxlength="255"
|
||||||
|
v-model="v$.region.$model" placeholder="Country, state, geographic area, etc.")
|
||||||
|
#regionFeedback.invalid-feedback Please enter a region
|
||||||
|
label.jjj-required(for="region") Region
|
||||||
|
.form-text Country, state, geographic area, etc.
|
||||||
|
.col-12: .form-check
|
||||||
|
input.form-check-input(type="checkbox" id="isRemote" v-model="v$.remoteWork.$model")
|
||||||
|
label.form-check-label(for="isRemote") This opportunity is for remote work
|
||||||
|
markdown-editor(id="description" label="Job Description" v-model:text="v$.text.$model" :isInvalid="v$.text.$error")
|
||||||
|
.col-12.col-md-4: .form-floating
|
||||||
|
input.form-control(type="date" id="neededBy" v-model="v$.neededBy.$model"
|
||||||
|
placeholder="Date by which this position needs to be filled")
|
||||||
|
label(for="neededBy") Needed By
|
||||||
|
.col-12
|
||||||
|
p.text-danger(v-if="v$.$error") Please correct the errors above
|
||||||
|
button.btn.btn-primary(@click.prevent="saveListing(true)") #[icon(:icon="mdiContentSaveOutline")] Save
|
||||||
|
maybe-save(:saveAction="doSave" :validator="v$")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { mdiContentSaveOutline } from "@mdi/js"
|
||||||
|
import useVuelidate from "@vuelidate/core"
|
||||||
|
import { required } from "@vuelidate/validators"
|
||||||
|
|
||||||
|
import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
|
||||||
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import ContinentList from "@/components/ContinentList.vue"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||||
|
import MaybeSave from "@/components/MaybeSave.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** A new job listing */
|
||||||
|
const newListing : Listing = {
|
||||||
|
id: "",
|
||||||
|
citizenId: user.citizenId,
|
||||||
|
createdOn: "",
|
||||||
|
title: "",
|
||||||
|
continentId: "",
|
||||||
|
region: "",
|
||||||
|
remoteWork: false,
|
||||||
|
isExpired: false,
|
||||||
|
updatedOn: "",
|
||||||
|
text: "",
|
||||||
|
neededBy: undefined,
|
||||||
|
wasFilledHere: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The backing object for the form */
|
||||||
|
const listing = reactive(new ListingForm())
|
||||||
|
|
||||||
|
/** The ID of the listing requested */
|
||||||
|
const id = route.params.id as string
|
||||||
|
|
||||||
|
/** Is this a new job listing? */
|
||||||
|
const isNew = computed(() => id === "new")
|
||||||
|
|
||||||
|
/** Validation rules for the form */
|
||||||
|
const rules = computed(() => ({
|
||||||
|
id: { },
|
||||||
|
title: { required },
|
||||||
|
continentId: { required },
|
||||||
|
region: { required },
|
||||||
|
remoteWork: { },
|
||||||
|
text: { required },
|
||||||
|
neededBy: { }
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Initialize form validation */
|
||||||
|
const v$ = useVuelidate(rules, listing, { $lazy: true })
|
||||||
|
|
||||||
|
/** Retrieve the listing being edited (or set up the form for a new listing) */
|
||||||
|
const retrieveData = async (errors : string[]) => {
|
||||||
|
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
|
||||||
|
if (typeof listResult === "string") {
|
||||||
|
errors.push(listResult)
|
||||||
|
} else if (typeof listResult === "undefined") {
|
||||||
|
errors.push("Job listing not found")
|
||||||
|
} else {
|
||||||
|
listing.id = listResult.id
|
||||||
|
listing.title = listResult.title
|
||||||
|
listing.continentId = listResult.continentId
|
||||||
|
listing.region = listResult.region
|
||||||
|
listing.remoteWork = listResult.remoteWork
|
||||||
|
listing.text = listResult.text
|
||||||
|
listing.neededBy = listResult.neededBy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the job listing */
|
||||||
|
const saveListing = async (navigate : boolean) => {
|
||||||
|
v$.value.$touch()
|
||||||
|
if (v$.value.$error) return
|
||||||
|
const apiFunc = isNew.value ? api.listings.add : api.listings.update
|
||||||
|
if (listing.neededBy === "") listing.neededBy = undefined
|
||||||
|
const result = await apiFunc(listing, user)
|
||||||
|
if (typeof result === "string") {
|
||||||
|
toastError(result, "saving job listing")
|
||||||
|
} else {
|
||||||
|
toastSuccess(`Job Listing ${isNew.value ? "Add" : "Updat"}ed Successfully`)
|
||||||
|
v$.value.$reset()
|
||||||
|
if (navigate) router.push("/listings/mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parameterless save function (used to save when navigating away) */
|
||||||
|
const doSave = async () => await saveListing(false)
|
||||||
|
</script>
|
95
src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue
Normal file
95
src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Expire Job Listing")
|
||||||
|
load-data(:load="retrieveListing")
|
||||||
|
h3.pb-3 Expire Job Listing ({{listing.title}})
|
||||||
|
p: em.
|
||||||
|
Expiring this listing will remove it from search results. You will be able to see it via your “My Job
|
||||||
|
Listings” page, but you will not be able to “un-expire” it.
|
||||||
|
form.row.g-3
|
||||||
|
.col-12: .form-check
|
||||||
|
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model")
|
||||||
|
label.form-check-label(for="fromHere") This job was filled due to its listing here
|
||||||
|
template(v-if="expiration.fromHere")
|
||||||
|
.col-12: p.
|
||||||
|
Consider telling your fellow citizens about your experience! Comments entered here will be visible to
|
||||||
|
logged-on users here, but not to the general public.
|
||||||
|
markdown-editor(id="successStory" label="Your Success Story" v-model:text="v$.successStory.$model")
|
||||||
|
.col-12
|
||||||
|
button.btn.btn-primary(@click.prevent="expireListing").
|
||||||
|
#[icon(:icon="mdiTextBoxRemoveOutline")] Expire Listing
|
||||||
|
maybe-save(:saveAction="doSave" :validator="v$")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, Ref, ref } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { mdiTextBoxRemoveOutline } from "@mdi/js"
|
||||||
|
import useVuelidate from "@vuelidate/core"
|
||||||
|
|
||||||
|
import api, { Listing, ListingExpireForm, LogOnSuccess } from "@/api"
|
||||||
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||||
|
import MaybeSave from "@/components/MaybeSave.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The ID of the listing being expired */
|
||||||
|
const listingId = route.params.id as string
|
||||||
|
|
||||||
|
/** The listing being expired */
|
||||||
|
const listing : Ref<Listing | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** The data needed to expire a job listing */
|
||||||
|
const expiration = reactive(new ListingExpireForm())
|
||||||
|
expiration.successStory = ""
|
||||||
|
|
||||||
|
/** The validation rules for the form */
|
||||||
|
const rules = computed(() => ({
|
||||||
|
fromHere: { },
|
||||||
|
successStory: { }
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Initialize form validation */
|
||||||
|
const v$ = useVuelidate(rules, expiration, { $lazy: true })
|
||||||
|
|
||||||
|
/** Retrieve the job listing being expired */
|
||||||
|
const retrieveListing = async (errors : string[]) => {
|
||||||
|
const listingResp = await api.listings.retreive(listingId, user)
|
||||||
|
if (typeof listingResp === "string") {
|
||||||
|
errors.push(listingResp)
|
||||||
|
} else if (typeof listingResp === "undefined") {
|
||||||
|
errors.push("Listing not found")
|
||||||
|
} else {
|
||||||
|
listing.value = listingResp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expire the listing */
|
||||||
|
const expireListing = async (navigate : boolean) => {
|
||||||
|
v$.value.$touch()
|
||||||
|
if (v$.value.$error) return
|
||||||
|
if ((expiration.successStory ?? "").trim() === "") expiration.successStory = undefined
|
||||||
|
const expireResult = await api.listings.expire(listingId, expiration, user)
|
||||||
|
if (typeof expireResult === "string") {
|
||||||
|
toastError(expireResult, "expiring job listing")
|
||||||
|
} else {
|
||||||
|
toastSuccess(`Job Listing Expired${expiration.successStory ? " and Success Story Recorded" : ""} Successfully`)
|
||||||
|
v$.value.$reset()
|
||||||
|
if (navigate) {
|
||||||
|
router.push("/listings/mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-parameter save function (used for save-on-navigate) */
|
||||||
|
const doSave = async () => await expireListing(false)
|
||||||
|
</script>
|
73
src/JobsJobsJobs/App/src/views/listing/ListingView.vue
Normal file
73
src/JobsJobsJobs/App/src/views/listing/ListingView.vue
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(:title="title")
|
||||||
|
load-data(:load="retrieveListing")
|
||||||
|
h3
|
||||||
|
| {{it.listing.title}}
|
||||||
|
.jjj-heading-label(v-if="it.listing.isExpired")
|
||||||
|
| #[span.badge.bg-warning.text-dark Expired]
|
||||||
|
template(v-if="it.listing.wasFilledHere") #[span.badge.bg-success Filled via Jobs, Jobs, Jobs]
|
||||||
|
h4.pb-3.text-muted {{it.continent.name}} / {{it.listing.region}}
|
||||||
|
p
|
||||||
|
template(v-if="it.listing.neededBy").
|
||||||
|
#[strong #[em NEEDED BY {{neededBy(it.listing.neededBy)}}]] •
|
||||||
|
| Listed by #[a(:href="profileUrl" target="_blank") {{citizenName(citizen)}}]
|
||||||
|
hr
|
||||||
|
div(v-html="details")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, Ref } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
|
import { formatNeededBy } from "./"
|
||||||
|
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
|
||||||
|
import { citizenName } from "@/App.vue"
|
||||||
|
import { toHtml } from "@/markdown"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The requested job listing */
|
||||||
|
const it : Ref<ListingForView | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** The citizen who posted this job listing */
|
||||||
|
const citizen : Ref<Citizen | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** Retrieve the job listing and supporting data */
|
||||||
|
const retrieveListing = async (errors : string[]) => {
|
||||||
|
const listingResp = await api.listings.retreiveForView(route.params.id as string, user)
|
||||||
|
if (typeof listingResp === "string") {
|
||||||
|
errors.push(listingResp)
|
||||||
|
} else if (typeof listingResp === "undefined") {
|
||||||
|
errors.push("Job Listing not found")
|
||||||
|
} else {
|
||||||
|
it.value = listingResp
|
||||||
|
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
|
||||||
|
if (typeof citizenResp === "string") {
|
||||||
|
errors.push(citizenResp)
|
||||||
|
} else if (typeof citizenResp === "undefined") {
|
||||||
|
errors.push("Listing Citizen not found (this should not happen)")
|
||||||
|
} else {
|
||||||
|
citizen.value = citizenResp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page title (changes once the listing is loaded) */
|
||||||
|
const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing` : "Loading Job Listing...")
|
||||||
|
|
||||||
|
/** The HTML details of the job listing */
|
||||||
|
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
|
||||||
|
|
||||||
|
/** The NAS profile URL for the citizen who posted this job listing */
|
||||||
|
const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "")
|
||||||
|
|
||||||
|
/** The needed by date, formatted in SHOUTING MODE */
|
||||||
|
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()
|
||||||
|
</script>
|
74
src/JobsJobsJobs/App/src/views/listing/MyListings.vue
Normal file
74
src/JobsJobsJobs/App/src/views/listing/MyListings.vue
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="My Job Listings")
|
||||||
|
h3.pb-3 My Job Listings
|
||||||
|
p: router-link.btn.btn-outline-primary(to="/listing/new/edit") Add a New Job Listing
|
||||||
|
load-data(:load="getListings")
|
||||||
|
h4.pb-2(v-if="expired.length > 0") Active Job Listings
|
||||||
|
table.pb-3.table.table-sm.table-hover.pt-3(v-if="active.length > 0")
|
||||||
|
thead: tr
|
||||||
|
th(scope="col") Action
|
||||||
|
th(scope="col") Title
|
||||||
|
th(scope="col") Continent / Region
|
||||||
|
th(scope="col") Created
|
||||||
|
th(scope="col") Updated
|
||||||
|
tbody: tr(v-for="it in active" :key="it.listing.id")
|
||||||
|
td
|
||||||
|
router-link(:to="`/listing/${it.listing.id}/edit`") Edit
|
||||||
|
= " ~ "
|
||||||
|
router-link(:to="`/listing/${it.listing.id}/view`") View
|
||||||
|
= " ~ "
|
||||||
|
router-link(:to="`/listing/${it.listing.id}/expire`") Expire
|
||||||
|
td {{it.listing.title}}
|
||||||
|
td {{it.continent.name}} / {{it.listing.region}}
|
||||||
|
td: full-date-time(:date="it.listing.createdOn")
|
||||||
|
td: full-date-time(:date="it.listing.updatedOn")
|
||||||
|
p.pb-3.fst-italic(v-else) You have no active job listings
|
||||||
|
template(v-if="expired.length > 0")
|
||||||
|
h4.pb-2 Expired Job Listings
|
||||||
|
table.table.table-sm.table-hover.pt-3
|
||||||
|
thead: tr
|
||||||
|
th(scope="col") Action
|
||||||
|
th(scope="col") Title
|
||||||
|
th(scope="col") Filled Here?
|
||||||
|
th(scope="col") Expired
|
||||||
|
tbody: tr(v-for="it in expired" :key="it.listing.id")
|
||||||
|
td
|
||||||
|
router-link(:to="`/listing/${it.listing.id}/view`") View
|
||||||
|
td {{it.listing.title}}
|
||||||
|
td {{yesOrNo(it.listing.wasFilledHere)}}
|
||||||
|
td: full-date-time(:date="it.listing.updatedOn")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, Ref, ref } from "vue"
|
||||||
|
import api, { ListingForView, LogOnSuccess } from "@/api"
|
||||||
|
import { yesOrNo } from "@/App.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import FullDateTime from "@/components/FullDateTime.vue"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** The listings for the user */
|
||||||
|
const listings : Ref<ListingForView[]> = ref([])
|
||||||
|
|
||||||
|
/** The active (non-expired) listings entered by this user */
|
||||||
|
const active = computed(() => listings.value.filter(it => !it.listing.isExpired))
|
||||||
|
|
||||||
|
/** The expired listings entered by this user */
|
||||||
|
const expired = computed(() => listings.value.filter(it => it.listing.isExpired))
|
||||||
|
|
||||||
|
/** Retrieve the job listing posted by the current citizen */
|
||||||
|
const getListings = async (errors : string[]) => {
|
||||||
|
const listResult = await api.listings.mine(store.state.user as LogOnSuccess)
|
||||||
|
if (typeof listResult === "string") {
|
||||||
|
errors.push(listResult)
|
||||||
|
} else if (typeof listResult === "undefined") {
|
||||||
|
errors.push("API call returned 404 (this should not happen)")
|
||||||
|
} else {
|
||||||
|
listings.value = listResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
11
src/JobsJobsJobs/App/src/views/listing/index.ts
Normal file
11
src/JobsJobsJobs/App/src/views/listing/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the needed by date for display
|
||||||
|
*
|
||||||
|
* @param neededBy The defined needed by date
|
||||||
|
* @returns The date to display
|
||||||
|
*/
|
||||||
|
export function formatNeededBy (neededBy : string) : string {
|
||||||
|
return format(Date.parse(`${neededBy}T00:00:00`), "PPP")
|
||||||
|
}
|
117
src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue
Normal file
117
src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Search Profiles")
|
||||||
|
h3.pb-3 Search Profiles
|
||||||
|
p(v-if="!searched").
|
||||||
|
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||||
|
collapse-panel(headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse")
|
||||||
|
profile-search-form(v-model="criteria" @search="doSearch")
|
||||||
|
error-list(:errors="errors")
|
||||||
|
p.pt-3(v-if="searching") Searching profiles…
|
||||||
|
template(v-else)
|
||||||
|
table.table.table-sm.table-hover.pt-3(v-if="results.length > 0")
|
||||||
|
thead: tr
|
||||||
|
th(scope="col") Profile
|
||||||
|
th(scope="col") Name
|
||||||
|
th.text-center(scope="col") Seeking?
|
||||||
|
th.text-center(scope="col") Remote?
|
||||||
|
th.text-center(scope="col") Full-Time?
|
||||||
|
th(scope="col") Last Updated
|
||||||
|
tbody: tr(v-for="profile in results" :key="profile.citzenId")
|
||||||
|
td: router-link(:to="`/profile/${profile.citizenId}/view`") View
|
||||||
|
td(:class="{ 'fw-bold' : profile.seekingEmployment }") {{profile.displayName}}
|
||||||
|
td.text-center {{yesOrNo(profile.seekingEmployment)}}
|
||||||
|
td.text-center {{yesOrNo(profile.remoteWork)}}
|
||||||
|
td.text-center {{yesOrNo(profile.fullTime)}}
|
||||||
|
td: full-date(:date="profile.lastUpdatedOn")
|
||||||
|
p.pt-3(v-else-if="searched") No results found for the specified criteria
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent, Ref, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { yesOrNo } from "@/App.vue"
|
||||||
|
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from "@/api"
|
||||||
|
import { queryValue } from "@/router"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import CollapsePanel from "@/components/CollapsePanel.vue"
|
||||||
|
import ErrorList from "@/components/ErrorList.vue"
|
||||||
|
import FullDate from "@/components/FullDate.vue"
|
||||||
|
import ProfileSearchForm from "@/components/profile/SearchForm.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** Any errors encountered while retrieving data */
|
||||||
|
const errors : Ref<string[]> = ref([])
|
||||||
|
|
||||||
|
/** Whether we are currently searching (retrieving data) */
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
/** Whether a search has been performed on this page since it has been loaded */
|
||||||
|
const searched = ref(false)
|
||||||
|
|
||||||
|
/** An empty set of search criteria */
|
||||||
|
const emptyCriteria = {
|
||||||
|
continentId: "",
|
||||||
|
skill: undefined,
|
||||||
|
bioExperience: undefined,
|
||||||
|
remoteWork: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search criteria being built from the page */
|
||||||
|
const criteria : Ref<ProfileSearch> = ref(emptyCriteria)
|
||||||
|
|
||||||
|
/** The current search results */
|
||||||
|
const results : Ref<ProfileSearchResult[]> = ref([])
|
||||||
|
|
||||||
|
/** Whether the search criteria should be collapsed */
|
||||||
|
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||||
|
|
||||||
|
/** Set up the page to match its requested state */
|
||||||
|
const setUpPage = async () => {
|
||||||
|
if (queryValue(route, "searched") === "true") {
|
||||||
|
searched.value = true
|
||||||
|
try {
|
||||||
|
searching.value = true
|
||||||
|
// Hold variable for ensuring continent ID is not undefined here, but excluded from search payload
|
||||||
|
const contId = queryValue(route, "continentId")
|
||||||
|
const searchParams : ProfileSearch = {
|
||||||
|
continentId: contId === "" ? undefined : contId,
|
||||||
|
skill: queryValue(route, "skill"),
|
||||||
|
bioExperience: queryValue(route, "bioExperience"),
|
||||||
|
remoteWork: queryValue(route, "remoteWork") ?? ""
|
||||||
|
}
|
||||||
|
const searchResult = await api.profile.search(searchParams, store.state.user as LogOnSuccess)
|
||||||
|
if (typeof searchResult === "string") {
|
||||||
|
errors.value.push(searchResult)
|
||||||
|
} else if (searchResult === undefined) {
|
||||||
|
errors.value.push(`The server returned a "Not Found" response (this should not happen)`)
|
||||||
|
} else {
|
||||||
|
results.value = searchResult
|
||||||
|
searchParams.continentId = searchParams.continentId ?? ""
|
||||||
|
criteria.value = searchParams
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
isCollapsed.value = searched.value && results.value.length > 0
|
||||||
|
} else {
|
||||||
|
searched.value = false
|
||||||
|
criteria.value = emptyCriteria
|
||||||
|
errors.value = []
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the page when the query string changes */
|
||||||
|
watch(() => route.query, setUpPage, { immediate: true })
|
||||||
|
|
||||||
|
/** Show and hide the search parameter panel */
|
||||||
|
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
|
||||||
|
|
||||||
|
/** Execute a search */
|
||||||
|
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
|
||||||
|
</script>
|
82
src/JobsJobsJobs/App/src/views/profile/ProfileView.vue
Normal file
82
src/JobsJobsJobs/App/src/views/profile/ProfileView.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(:title="title")
|
||||||
|
load-data(:load="retrieveProfile")
|
||||||
|
h2
|
||||||
|
a(:href="it.citizen.profileUrl" target="_blank") {{citizenName(it.citizen)}}
|
||||||
|
.jjj-heading-label(v-if="it.profile.seekingEmployment")
|
||||||
|
| #[span.badge.bg-dark Currently Seeking Employment]
|
||||||
|
h4.pb-3 {{it.continent.name}}, {{it.profile.region}}
|
||||||
|
p(v-html="workTypes")
|
||||||
|
hr
|
||||||
|
div(v-html="bioHtml")
|
||||||
|
template(v-if="it.profile.skills.length > 0")
|
||||||
|
hr
|
||||||
|
h4.pb-3 Skills
|
||||||
|
ul
|
||||||
|
li(v-for="(skill, idx) in it.profile.skills" :key="idx").
|
||||||
|
{{skill.description}}#[template(v-if="skill.notes") ({{skill.notes}})]
|
||||||
|
template(v-if="it.profile.experience")
|
||||||
|
hr
|
||||||
|
h4.pb-3 Experience / Employment History
|
||||||
|
div(v-html="expHtml")
|
||||||
|
template(v-if="user.citizenId === it.citizen.id")
|
||||||
|
br
|
||||||
|
br
|
||||||
|
router-link.btn.btn-primary(to="/citizen/profile") #[icon(:icon="mdiPencil")] Edit Your Profile
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, Ref } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { mdiPencil } from "@mdi/js"
|
||||||
|
|
||||||
|
import api, { LogOnSuccess, ProfileForView } from "@/api"
|
||||||
|
import { citizenName } from "@/App.vue"
|
||||||
|
import { toHtml } from "@/markdown"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The requested profile */
|
||||||
|
const it : Ref<ProfileForView | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** The work types for the top of the page */
|
||||||
|
const workTypes = computed(() => {
|
||||||
|
const parts : string[] = []
|
||||||
|
if (it.value) {
|
||||||
|
const p = it.value.profile
|
||||||
|
parts.push(`${p.fullTime ? "I" : "Not i"}nterested in full-time employment`)
|
||||||
|
parts.push(`${p.remoteWork ? "I" : "Not i"}nterested in remote opportunities`)
|
||||||
|
}
|
||||||
|
return parts.join(" • ")
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Retrieve the profile and supporting data */
|
||||||
|
const retrieveProfile = async (errors : string[]) => {
|
||||||
|
const profileResp = await api.profile.retreiveForView(route.params.id as string, user)
|
||||||
|
if (typeof profileResp === "string") {
|
||||||
|
errors.push(profileResp)
|
||||||
|
} else if (typeof profileResp === "undefined") {
|
||||||
|
errors.push("Profile not found")
|
||||||
|
} else {
|
||||||
|
it.value = profileResp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The title of the page (changes once the profile is loaded) */
|
||||||
|
const title = computed(() => it.value
|
||||||
|
? `Employment profile for ${citizenName(it.value.citizen)}`
|
||||||
|
: "Loading Profile...")
|
||||||
|
|
||||||
|
/** The HTML version of the citizen's professional biography */
|
||||||
|
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))
|
||||||
|
|
||||||
|
/** The HTML version of the citizens Experience section */
|
||||||
|
const expHtml = computed(() => toHtml(it.value?.profile.experience ?? ""))
|
||||||
|
</script>
|
113
src/JobsJobsJobs/App/src/views/profile/Seeking.vue
Normal file
113
src/JobsJobsJobs/App/src/views/profile/Seeking.vue
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="People Seeking Work")
|
||||||
|
h3.pb-3 People Seeking Work
|
||||||
|
p(v-if="!searched").
|
||||||
|
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||||
|
collapse-panel(headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse")
|
||||||
|
profile-public-search-form(v-model="criteria" @search="doSearch")
|
||||||
|
error-list(:errors="errors")
|
||||||
|
p(v-if="searching") Searching profiles…
|
||||||
|
template(v-else)
|
||||||
|
template(v-if="results.length > 0")
|
||||||
|
p.pb-3.pt-3.
|
||||||
|
These profiles match your search criteria. To learn more about these people, join the merry band of human
|
||||||
|
resources in the #[a(href="https://noagendashow.net" target="_blank") No Agenda] tribe!
|
||||||
|
table.table.table-sm.table-hover
|
||||||
|
thead: tr
|
||||||
|
th(scope="col") Continent
|
||||||
|
th.text-center(scope="col") Region
|
||||||
|
th.text-center(scope="col") Remote?
|
||||||
|
th.text-center(scope="col") Skills
|
||||||
|
tbody: tr(v-for="(profile, idx) in results" :key="idx")
|
||||||
|
td {{profile.continent}}
|
||||||
|
td {{profile.region}}
|
||||||
|
td.text-center {{yesOrNo(profile.remoteWork)}}
|
||||||
|
td: template(v-for="(skill, idx) in profile.skills" :key="idx") {{skill}}#[br]
|
||||||
|
p.pt-3(v-else-if="searched") No results found for the specified criteria
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Ref, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { yesOrNo } from "@/App.vue"
|
||||||
|
import api, { PublicSearch, PublicSearchResult } from "@/api"
|
||||||
|
import { queryValue } from "@/router"
|
||||||
|
|
||||||
|
import CollapsePanel from "@/components/CollapsePanel.vue"
|
||||||
|
import ErrorList from "@/components/ErrorList.vue"
|
||||||
|
import ProfilePublicSearchForm from "@/components/profile/PublicSearchForm.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** Whether a search has been performed */
|
||||||
|
const searched = ref(false)
|
||||||
|
|
||||||
|
/** Indicates whether a request for matching profiles is in progress */
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
/** Error messages encountered while searching for profiles */
|
||||||
|
const errors : Ref<string[]> = ref([])
|
||||||
|
|
||||||
|
/** An empty set of search criteria */
|
||||||
|
const emptyCriteria = {
|
||||||
|
continentId: "",
|
||||||
|
region: undefined,
|
||||||
|
skill: undefined,
|
||||||
|
remoteWork: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search criteria being built from the page */
|
||||||
|
const criteria : Ref<PublicSearch> = ref(emptyCriteria)
|
||||||
|
|
||||||
|
/** The search results */
|
||||||
|
const results : Ref<PublicSearchResult[]> = ref([])
|
||||||
|
|
||||||
|
/** Whether the search results are collapsed */
|
||||||
|
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||||
|
|
||||||
|
/** Set up the page to match its requested state */
|
||||||
|
const setUpPage = async () => {
|
||||||
|
if (queryValue(route, "searched") === "true") {
|
||||||
|
searched.value = true
|
||||||
|
try {
|
||||||
|
searching.value = true
|
||||||
|
const contId = queryValue(route, "continentId")
|
||||||
|
const searchParams : PublicSearch = {
|
||||||
|
continentId: contId === "" ? undefined : contId,
|
||||||
|
region: queryValue(route, "region"),
|
||||||
|
skill: queryValue(route, "skill"),
|
||||||
|
remoteWork: queryValue(route, "remoteWork") ?? ""
|
||||||
|
}
|
||||||
|
const searchResult = await api.profile.publicSearch(searchParams)
|
||||||
|
if (typeof searchResult === "string") {
|
||||||
|
errors.value.push(searchResult)
|
||||||
|
} else if (searchResult === undefined) {
|
||||||
|
errors.value.push(`The server returned a "Not Found" response (this should not happen)`)
|
||||||
|
} else {
|
||||||
|
results.value = searchResult
|
||||||
|
searchParams.continentId = searchParams.continentId ?? ""
|
||||||
|
criteria.value = searchParams
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
isCollapsed.value = searched.value && results.value.length > 0
|
||||||
|
} else {
|
||||||
|
searched.value = false
|
||||||
|
criteria.value = emptyCriteria
|
||||||
|
errors.value = []
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the page when the query string changes */
|
||||||
|
watch(() => route.query, setUpPage, { immediate: true })
|
||||||
|
|
||||||
|
/** Open and closed the search parameter panel */
|
||||||
|
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
|
||||||
|
|
||||||
|
/** Execute a search */
|
||||||
|
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
|
||||||
|
</script>
|
57
src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue
Normal file
57
src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Account Deletion Options")
|
||||||
|
h3.pb-3 Account Deletion Options
|
||||||
|
h4.pb-3 Option 1 – Delete Your Profile
|
||||||
|
p.
|
||||||
|
Utilizing this option will remove your current employment profile and skills. This will preserve any success stories
|
||||||
|
you may have written, and preserves this application’s knowledge of you. This is what you want to use if you
|
||||||
|
want to clear out your profile and start again (and remove the current one from others’ view).
|
||||||
|
p.text-center: button.btn.btn-danger(@click.prevent="deleteProfile") Delete Your Profile
|
||||||
|
hr
|
||||||
|
h4.pb-3 Option 2 – Delete Your Account
|
||||||
|
p.
|
||||||
|
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
|
||||||
|
account. This is what you want to use if you want to disappear from this application. Clicking the button below
|
||||||
|
#[strong will not] affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs.
|
||||||
|
p: em.
|
||||||
|
(This will not revoke this application’s permissions on No Agenda Social; you will have to remove this
|
||||||
|
yourself. The confirmation message has a link where you can do this; once the page loads, find the
|
||||||
|
#[strong Jobs, Jobs, Jobs] entry, and click the #[strong × Revoke] link for that entry.)
|
||||||
|
p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import api, { LogOnSuccess } from "@/api"
|
||||||
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** Delete the profile only; redirect to home page on success */
|
||||||
|
const deleteProfile = async () => {
|
||||||
|
const resp = await api.profile.delete(store.state.user as LogOnSuccess)
|
||||||
|
if (typeof resp === "string") {
|
||||||
|
toastError(resp, "Deleting Profile")
|
||||||
|
} else {
|
||||||
|
toastSuccess("Profile Deleted Successfully")
|
||||||
|
router.push("/citizen/dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete everything pertaining to the user's account */
|
||||||
|
const deleteAccount = async () => {
|
||||||
|
const resp = await api.citizen.delete(store.state.user as LogOnSuccess)
|
||||||
|
if (typeof resp === "string") {
|
||||||
|
toastError(resp, "Deleting Account")
|
||||||
|
} else {
|
||||||
|
store.commit("clearUser")
|
||||||
|
toastSuccess("Account Deleted Successfully")
|
||||||
|
router.push("/so-long/success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
11
src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue
Normal file
11
src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Account Deletion Success")
|
||||||
|
h3.pb-3 Account Deletion Success
|
||||||
|
p.
|
||||||
|
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
|
||||||
|
application, find it in #[a(href="https://noagendasocial.com/oauth/authorized_applications") this list] and click
|
||||||
|
#[strong × Revoke]. Otherwise, clicking “Log On” in the left-hand menu will create a new, empty
|
||||||
|
account without prompting you further.
|
||||||
|
p Thank you for participating, and thank you for your courage. #GitmoNation
|
||||||
|
</template>
|
112
src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue
Normal file
112
src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(:title="title")
|
||||||
|
h3.pb-3 {{title}}
|
||||||
|
load-data(:load="retrieveStory")
|
||||||
|
p(v-if="isNew").
|
||||||
|
Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us
|
||||||
|
about it below! #[em (These will be visible to other users, but not to the general public.)]
|
||||||
|
form.row.g-3
|
||||||
|
.col-12: .form-check
|
||||||
|
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model")
|
||||||
|
label.form-check-label(for="fromHere") I found my employment here
|
||||||
|
markdown-editor(id="story" label="The Success Story" v-model:text="v$.story.$model")
|
||||||
|
.col-12
|
||||||
|
button.btn.btn-primary(type="submit" @click.prevent="saveStory(true)").
|
||||||
|
#[icon(:icon="mdiContentSaveOutline")] Save
|
||||||
|
p(v-if="isNew"): em (Saving this will set “Seeking Employment” to “No” on your profile.)
|
||||||
|
maybe-save(:saveAction="doSave" :validator="v$")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { mdiContentSaveOutline } from "@mdi/js"
|
||||||
|
import useVuelidate from "@vuelidate/core"
|
||||||
|
|
||||||
|
import api, { LogOnSuccess, StoryForm } from "@/api"
|
||||||
|
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||||
|
import MaybeSave from "@/components/MaybeSave.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The ID of the story being edited */
|
||||||
|
const id = route.params.id as string
|
||||||
|
|
||||||
|
/** Whether this is a new story */
|
||||||
|
const isNew = computed(() => id === "new")
|
||||||
|
|
||||||
|
/** The page title */
|
||||||
|
const title = computed(() => isNew.value ? "Tell Your Success Story" : "Edit Success Story")
|
||||||
|
|
||||||
|
/** The form for editing the story */
|
||||||
|
const story = reactive(new StoryForm())
|
||||||
|
|
||||||
|
/** Validator rules */
|
||||||
|
const rules = computed(() => ({
|
||||||
|
fromHere: { },
|
||||||
|
story: { }
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** The validator */
|
||||||
|
const v$ = useVuelidate(rules, story, { $lazy: true })
|
||||||
|
|
||||||
|
/** Retrieve the specified story */
|
||||||
|
const retrieveStory = async (errors : string[]) => {
|
||||||
|
if (isNew.value) {
|
||||||
|
story.id = "new"
|
||||||
|
} else {
|
||||||
|
const storyResult = await api.success.retrieve(id, user)
|
||||||
|
if (typeof storyResult === "string") {
|
||||||
|
errors.push(storyResult)
|
||||||
|
} else if (typeof storyResult === "undefined") {
|
||||||
|
errors.push("Story not found")
|
||||||
|
} else if (storyResult.citizenId !== user.citizenId) {
|
||||||
|
errors.push("Quit messing around")
|
||||||
|
} else {
|
||||||
|
story.id = storyResult.id
|
||||||
|
story.fromHere = storyResult.fromHere
|
||||||
|
story.story = storyResult.story ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the success story */
|
||||||
|
const saveStory = async (navigate : boolean) => {
|
||||||
|
const saveResult = await api.success.save(story, user)
|
||||||
|
if (typeof saveResult === "string") {
|
||||||
|
toastError(saveResult, "saving success story")
|
||||||
|
} else {
|
||||||
|
if (isNew.value) {
|
||||||
|
const foundResult = await api.profile.markEmploymentFound(user)
|
||||||
|
if (typeof foundResult === "string") {
|
||||||
|
toastError(foundResult, "clearing employment flag")
|
||||||
|
} else {
|
||||||
|
toastSuccess("Success Story saved and Seeking Employment flag cleared successfully")
|
||||||
|
v$.value.$reset()
|
||||||
|
if (navigate) {
|
||||||
|
router.push("/success-story/list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toastSuccess("Success Story saved successfully")
|
||||||
|
v$.value.$reset()
|
||||||
|
if (navigate) {
|
||||||
|
router.push("/success-story/list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-parameter save function (used for save-on-navigate) */
|
||||||
|
const doSave = async () => await saveStory(false)
|
||||||
|
</script>
|
53
src/JobsJobsJobs/App/src/views/success-story/StoryList.vue
Normal file
53
src/JobsJobsJobs/App/src/views/success-story/StoryList.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Success Stories")
|
||||||
|
h3.pb-3 Success Stories
|
||||||
|
load-data(:load="retrieveStories")
|
||||||
|
table.table.table-sm.table-hover(v-if="stories?.length > 0")
|
||||||
|
thead: tr
|
||||||
|
th(scope="col") Story
|
||||||
|
th(scope="col") From
|
||||||
|
th(scope="col") Found Here?
|
||||||
|
th(scope="col") Recorded On
|
||||||
|
tbody: tr(v-for="story in stories" :key="story.id")
|
||||||
|
td
|
||||||
|
router-link(v-if="story.hasStory" :to="`/success-story/${story.id}/view`") View
|
||||||
|
em(v-else) None
|
||||||
|
template(v-if="story.citizenId === user.citizenId")
|
||||||
|
| ~ #[router-link(:to="`/success-story/${story.id}/edit`") Edit]
|
||||||
|
td {{story.citizenName}}
|
||||||
|
td
|
||||||
|
strong(v-if="story.fromHere") Yes
|
||||||
|
template(v-else) No
|
||||||
|
td: full-date(:date="story.recordedOn")
|
||||||
|
p(v-else) There are no success stories recorded #[em (yet)]
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, Ref } from "vue"
|
||||||
|
import api, { LogOnSuccess, StoryEntry } from "@/api"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import FullDate from "@/components/FullDate.vue"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The success stories to be displayed */
|
||||||
|
const stories : Ref<StoryEntry[] | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** Get all currently recorded stories */
|
||||||
|
const retrieveStories = async (errors : string[]) => {
|
||||||
|
const listResult = await api.success.list(user)
|
||||||
|
if (typeof listResult === "string") {
|
||||||
|
errors.push(listResult)
|
||||||
|
} else if (typeof listResult === "undefined") {
|
||||||
|
stories.value = []
|
||||||
|
} else {
|
||||||
|
stories.value = listResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
64
src/JobsJobsJobs/App/src/views/success-story/StoryView.vue
Normal file
64
src/JobsJobsJobs/App/src/views/success-story/StoryView.vue
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
<template lang="pug">
|
||||||
|
article
|
||||||
|
page-title(title="Success Story")
|
||||||
|
load-data(:load="retrieveStory")
|
||||||
|
h3
|
||||||
|
| {{citizenName}}’s Success Story
|
||||||
|
.jjj-heading-label(v-if="story.fromHere")
|
||||||
|
| #[span.badge.bg-success Via {{profileOrListing}} on Jobs, Jobs, Jobs]
|
||||||
|
h4.pb-3.text-muted: full-date-time(:date="story.recordedOn")
|
||||||
|
div(v-if="story.story" v-html="successStory")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, Ref, ref } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
|
import api, { LogOnSuccess, Success } from "@/api"
|
||||||
|
import { citizenName as citName } from "@/App.vue"
|
||||||
|
import { toHtml } from "@/markdown"
|
||||||
|
import { useStore } from "@/store"
|
||||||
|
|
||||||
|
import FullDateTime from "@/components/FullDateTime.vue"
|
||||||
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** The currently logged-on user */
|
||||||
|
const user = store.state.user as LogOnSuccess
|
||||||
|
|
||||||
|
/** The story to be displayed */
|
||||||
|
const story : Ref<Success | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
/** The citizen's name (real, display, or NAS, whichever is found first) */
|
||||||
|
const citizenName = ref("")
|
||||||
|
|
||||||
|
/** Retrieve the success story */
|
||||||
|
const retrieveStory = async (errors : string []) => {
|
||||||
|
const storyResponse = await api.success.retrieve(route.params.id as string, user)
|
||||||
|
if (typeof storyResponse === "string") {
|
||||||
|
errors.push(storyResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof storyResponse === "undefined") {
|
||||||
|
errors.push("Success story not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
story.value = storyResponse
|
||||||
|
const citResponse = await api.citizen.retrieve(story.value.citizenId, user)
|
||||||
|
if (typeof citResponse === "string") {
|
||||||
|
errors.push(citResponse)
|
||||||
|
} else if (typeof citResponse === "undefined") {
|
||||||
|
errors.push("Citizen not found")
|
||||||
|
} else {
|
||||||
|
citizenName.value = citName(citResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this success is from an employment profile or a job listing */
|
||||||
|
const profileOrListing = computed(() => story.value?.source === "profile" ? "employment profile" : "job listing")
|
||||||
|
|
||||||
|
/** The HTML success story */
|
||||||
|
const successStory = computed(() => toHtml(story.value?.story ?? ""))
|
||||||
|
</script>
|
40
src/JobsJobsJobs/App/tsconfig.json
Normal file
40
src/JobsJobsJobs/App/tsconfig.json
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
6
src/JobsJobsJobs/App/vue.config.js
Normal file
6
src/JobsJobsJobs/App/vue.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
transpileDependencies: [
|
||||||
|
'vuetify'
|
||||||
|
],
|
||||||
|
outputDir: '../Api/wwwroot'
|
||||||
|
}
|
|
@ -1,10 +0,0 @@
|
||||||
<Router AppAssembly="@typeof(Program).Assembly">
|
|
||||||
<Found Context="routeData">
|
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
|
||||||
</Found>
|
|
||||||
<NotFound>
|
|
||||||
<LayoutView Layout="@typeof(MainLayout)">
|
|
||||||
<p>Sorry, there's nothing at this address.</p>
|
|
||||||
</LayoutView>
|
|
||||||
</NotFound>
|
|
||||||
</Router>
|
|
|
@ -1,122 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using Microsoft.JSInterop;
|
|
||||||
using NodaTime;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Information about a user
|
|
||||||
/// </summary>
|
|
||||||
public record UserInfo(CitizenId Id, string Name);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Client-side application state for Jobs, Jobs, Jobs
|
|
||||||
/// </summary>
|
|
||||||
public class AppState
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The application version, as a nice display string
|
|
||||||
/// </summary>
|
|
||||||
public static Lazy<string> Version => new(() =>
|
|
||||||
{
|
|
||||||
var version = Assembly.GetExecutingAssembly().GetName().Version!;
|
|
||||||
var display = $"v{version.Major}.{version.Minor}";
|
|
||||||
if (version.Build > 0) display += $".{version.Build}";
|
|
||||||
return display;
|
|
||||||
});
|
|
||||||
|
|
||||||
public event Action OnChange = () => { };
|
|
||||||
|
|
||||||
private UserInfo? _user = null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The information of the currently logged-in user
|
|
||||||
/// </summary>
|
|
||||||
public UserInfo? User
|
|
||||||
{
|
|
||||||
get => _user;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_user = value;
|
|
||||||
NotifyChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string _jwt = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The JSON Web Token (JWT) for the currently logged-on user
|
|
||||||
/// </summary>
|
|
||||||
public string Jwt
|
|
||||||
{
|
|
||||||
get => _jwt;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_jwt = value;
|
|
||||||
NotifyChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<Continent>? _continents = null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a list of continents (only retrieves once per application load)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="http">The HTTP client to use to obtain continents the first time</param>
|
|
||||||
/// <returns>The list of continents</returns>
|
|
||||||
/// <exception cref="ApplicationException">If the continents cannot be loaded</exception>
|
|
||||||
public async Task<IEnumerable<Continent>> GetContinents(HttpClient http)
|
|
||||||
{
|
|
||||||
if (_continents == null)
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, this);
|
|
||||||
var continentResult = await ServerApi.RetrieveMany<Continent>(http, "continent/all");
|
|
||||||
|
|
||||||
if (continentResult.IsOk)
|
|
||||||
{
|
|
||||||
_continents = continentResult.Ok;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new ApplicationException($"Could not load continents - {continentResult.Error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _continents;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DateTimeZone? _tz = null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the time zone for the current user's browser
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="js">The JS interop runtime for the application</param>
|
|
||||||
/// <returns>The time zone based on the user's browser</returns>
|
|
||||||
public async Task<DateTimeZone> GetTimeZone(IJSRuntime js)
|
|
||||||
{
|
|
||||||
if (_tz == null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(await js.InvokeAsync<string>("getTimeZone"));
|
|
||||||
}
|
|
||||||
catch (Exception) { }
|
|
||||||
}
|
|
||||||
if (_tz == null)
|
|
||||||
{
|
|
||||||
// Either the zone wasn't found, or the user's browser denied us access to it; there's not much to do
|
|
||||||
// here but set it to UTC and move on
|
|
||||||
_tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull("Etc/UTC")!;
|
|
||||||
}
|
|
||||||
return _tz;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AppState() { }
|
|
||||||
|
|
||||||
private void NotifyChanged() => OnChange.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Blazored.Toast" Version="3.1.2" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="5.0.1" PrivateAssets="all" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="2.2.0" />
|
|
||||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
|
||||||
<PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Shared\JobsJobsJobs.Shared.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="wwwroot\audio\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Update="wwwroot\audio\pelosi-jobs.mp3">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
<Content Update="wwwroot\audio\thats-true.mp3">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,11 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
|
||||||
<PropertyGroup>
|
|
||||||
<RazorPage_SelectedScaffolderID>RazorPageScaffolder</RazorPage_SelectedScaffolderID>
|
|
||||||
<RazorPage_SelectedScaffolderCategoryPath>root/Common/RazorPage</RazorPage_SelectedScaffolderCategoryPath>
|
|
||||||
<ActiveDebugProfile>JobsJobsJobs</ActiveDebugProfile>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
|
||||||
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
|
@ -1,8 +0,0 @@
|
||||||
@page "/citizen/authorized"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject AppState state
|
|
||||||
|
|
||||||
<PageTitle Title="Logging on..." />
|
|
||||||
|
|
||||||
<p>@Message</p>
|
|
|
@ -1,40 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.Citizens
|
|
||||||
{
|
|
||||||
public partial class Authorized : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The message to be displayed on this page
|
|
||||||
/// </summary>
|
|
||||||
private string Message { get; set; } = "Logging you on with No Agenda Social...";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
// Exchange authorization code for a JWT
|
|
||||||
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
|
|
||||||
if (query.TryGetValue("code", out var authCode))
|
|
||||||
{
|
|
||||||
var logOnResult = await ServerApi.LogOn(http, authCode);
|
|
||||||
|
|
||||||
if (logOnResult.IsOk)
|
|
||||||
{
|
|
||||||
var logOn = logOnResult.Ok;
|
|
||||||
state.User = new UserInfo(logOn.CitizenId, logOn.Name);
|
|
||||||
state.Jwt = logOn.Jwt;
|
|
||||||
nav.NavigateTo("/citizen/dashboard");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Message = logOnResult.Error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Message = "Did not receive a token from No Agenda Social (perhaps you clicked \"Cancel\"?)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,46 +0,0 @@
|
||||||
@page "/citizen/dashboard"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject AppState state
|
|
||||||
|
|
||||||
<PageTitle Title="Dashboard" />
|
|
||||||
|
|
||||||
<h3>Welcome, @state.User!.Name!</h3>
|
|
||||||
|
|
||||||
<Loading OnLoad=@LoadProfile Message=@(new MarkupString("Retrieving your employment profile…"))>
|
|
||||||
@if (Profile != null)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently
|
|
||||||
lists @Profile.Skills.Count skill@(Profile.Skills.Count != 1 ? "s" : "").
|
|
||||||
</p>
|
|
||||||
<p><a href="/profile/view/@state.User.Id">View Your Employment Profile</a></p>
|
|
||||||
@if (Profile.SeekingEmployment)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
Your profile indicates that you are seeking employment. Once you find it,
|
|
||||||
<a href="/success-story/add">tell your fellow citizens about it!</a>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
You do not have an employment profile established; click “Edit Profile” in the menu to get
|
|
||||||
started!
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<hr>
|
|
||||||
<p>
|
|
||||||
There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment
|
|
||||||
profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation.
|
|
||||||
@if (ProfileCount > 0)
|
|
||||||
{
|
|
||||||
<text>Take a look around and see if you can help them find work!</text>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</Loading>
|
|
||||||
<hr>
|
|
||||||
<p>
|
|
||||||
To see how this application works, check out “How It Works” in the sidebar (last updated June
|
|
||||||
14<sup>th</sup>, 2021).
|
|
||||||
</p>
|
|
|
@ -1,58 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using JobsJobsJobs.Shared.Api;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.Citizens
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The first page a user sees after signing in
|
|
||||||
/// </summary>
|
|
||||||
public partial class Dashboard : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The user's profile
|
|
||||||
/// </summary>
|
|
||||||
private Profile? Profile { get; set; } = null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The number of profiles
|
|
||||||
/// </summary>
|
|
||||||
private int ProfileCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Load the user's profile information
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errors">A collection to report errors that may be encountered</param>
|
|
||||||
public async Task LoadProfile(ICollection<string> errors)
|
|
||||||
{
|
|
||||||
if (state.User != null)
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var profileTask = ServerApi.RetrieveProfile(http, state);
|
|
||||||
var profileCountTask = ServerApi.RetrieveOne<Count>(http, "profile/count");
|
|
||||||
|
|
||||||
await Task.WhenAll(profileTask, profileCountTask);
|
|
||||||
|
|
||||||
if (profileTask.Result.IsOk)
|
|
||||||
{
|
|
||||||
Profile = profileTask.Result.Ok;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(profileTask.Result.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profileCountTask.Result.IsOk)
|
|
||||||
{
|
|
||||||
ProfileCount = profileCountTask.Result.Ok?.Value ?? 0;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(profileCountTask.Result.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
@page "/citizen/profile"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject AppState state
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject IToastService toast
|
|
||||||
|
|
||||||
<PageTitle Title="Edit Profile" />
|
|
||||||
|
|
||||||
<h3>Employment Profile</h3>
|
|
||||||
|
|
||||||
<Loading OnLoad=@SetUpProfile Message=@(new MarkupString("Loading Your Profile…"))>
|
|
||||||
<EditForm Model=@ProfileForm OnValidSubmit=@SaveProfile>
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col col-xs-12 col-sm-10 col-md-8 col-lg-6">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="realName" class="jjj-label">Real Name</label>
|
|
||||||
<InputText id="realName" @bind-Value=@ProfileForm.RealName class="form-control"
|
|
||||||
placeholder="Leave blank to use your NAS display name" />
|
|
||||||
<ValidationMessage For=@(() => ProfileForm.RealName) />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="seeking" class="form-check-input" @bind-Value=@ProfileForm.IsSeekingEmployment />
|
|
||||||
<label for="seeking" class="form-check-label">I am currently seeking employment</label>
|
|
||||||
@if (IsSeeking)
|
|
||||||
{
|
|
||||||
<em> If you have found employment, consider
|
|
||||||
<a href="/success-story/add">telling your fellow citizens about it</a>
|
|
||||||
</em>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col col-xs-12 col-sm-6 col-md-4">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="continentId" class="jjj-required">Continent</label>
|
|
||||||
<InputSelect id="continentId" @bind-Value=@ProfileForm.ContinentId class="form-control">
|
|
||||||
<option>– Select –</option>
|
|
||||||
@foreach (var (id, name) in Continents)
|
|
||||||
{
|
|
||||||
<option value="@id">@name</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
<ValidationMessage For=@(() => ProfileForm.ContinentId) />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col col-xs-12 col-sm-6 col-md-8">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="region" class="jjj-required">Region</label>
|
|
||||||
<InputText id="region" @bind-Value=@ProfileForm.Region class="form-control"
|
|
||||||
placeholder="Country, state, geographic area, etc." />
|
|
||||||
<ValidationMessage For=@(() => ProfileForm.Region) />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bio" class="jjj-required">Professional Biography</label>
|
|
||||||
<MarkdownEditor Id="bio" @bind-Text=@ProfileForm.Biography />
|
|
||||||
<ValidationMessage For=@(() => ProfileForm.Biography) />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="isRemote" class="form-check-input" @bind-Value=@ProfileForm.RemoteWork />
|
|
||||||
<label for="isRemote" class="form-check-label">I am looking for remote work</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col col-xs-12 col-sm-12 col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="isFull" class="form-check-input" @bind-Value=@ProfileForm.FullTime />
|
|
||||||
<label for="isFull" class="form-check-label">I am looking for full-time work</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<h4>
|
|
||||||
Skills
|
|
||||||
<button type="button" class="btn btn-outline-primary" @onclick=@AddNewSkill>Add a Skill</button>
|
|
||||||
</h4>
|
|
||||||
@foreach (var skill in ProfileForm.Skills)
|
|
||||||
{
|
|
||||||
<SkillEdit Skill=@skill OnRemove=@RemoveSkill />
|
|
||||||
}
|
|
||||||
<hr>
|
|
||||||
<h4>Experience</h4>
|
|
||||||
<p>
|
|
||||||
This application does not have a place to individually list your chronological job history; however, you can
|
|
||||||
use this area to list prior jobs, their dates, and anything else you want to include that’s not already a
|
|
||||||
part of your Professional Biography above.
|
|
||||||
</p>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<MarkdownEditor Id="experience" @bind-Text=@ProfileForm.Experience />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="isPublic" class="form-check-input" @bind-Value=@ProfileForm.IsPublic />
|
|
||||||
<label for="isPublic" class="form-check-label">
|
|
||||||
Allow my profile to be searched publicly (outside NA Social)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<br>
|
|
||||||
<button type="submit" class="btn btn-outline-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EditForm>
|
|
||||||
@if (!IsNew)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
<br><a href="/profile/view/@state.User!.Id"><span class="oi oi-file"></span> View Your User Profile</a>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<p>
|
|
||||||
<br>If you want to delete your profile, or your entire account,
|
|
||||||
<a href="/so-long/options">see your deletion options here</a>.
|
|
||||||
</p>
|
|
||||||
</Loading>
|
|
|
@ -1,134 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using JobsJobsJobs.Shared.Api;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.Citizens
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Profile edit page (called EditProfile so as not to create naming conflicts)
|
|
||||||
/// </summary>
|
|
||||||
public partial class EditProfile : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Counter for IDs when "Add a Skill" button is clicked
|
|
||||||
/// </summary>
|
|
||||||
private int _newSkillCounter = 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the citizen is seeking employment at the time the profile is loaded (used to show success story
|
|
||||||
/// link)
|
|
||||||
/// </summary>
|
|
||||||
private bool IsSeeking { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The form for this page
|
|
||||||
/// </summary>
|
|
||||||
private ProfileForm ProfileForm { get; set; } = new ProfileForm();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// All continents
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this is a new profile or not
|
|
||||||
/// </summary>
|
|
||||||
private bool IsNew { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set up the data needed to add or edit the user's profile
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errors">The collection where errors can be reported</param>
|
|
||||||
public async Task SetUpProfile(ICollection<string> errors)
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var continentTask = state.GetContinents(http);
|
|
||||||
var profileTask = ServerApi.RetrieveProfile(http, state);
|
|
||||||
var citizenTask = ServerApi.RetrieveOne<Citizen>(http, $"citizen/get/{state.User!.Id}");
|
|
||||||
|
|
||||||
await Task.WhenAll(continentTask, profileTask, citizenTask);
|
|
||||||
|
|
||||||
Continents = continentTask.Result;
|
|
||||||
|
|
||||||
if (profileTask.Result.IsOk)
|
|
||||||
{
|
|
||||||
if (profileTask.Result.Ok == null)
|
|
||||||
{
|
|
||||||
ProfileForm = new ProfileForm();
|
|
||||||
IsNew = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ProfileForm = ProfileForm.FromProfile(profileTask.Result.Ok);
|
|
||||||
IsSeeking = profileTask.Result.Ok.SeekingEmployment;
|
|
||||||
}
|
|
||||||
if (ProfileForm.Skills.Count == 0) AddNewSkill();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(profileTask.Result.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (citizenTask.Result.IsOk)
|
|
||||||
{
|
|
||||||
ProfileForm.RealName = citizenTask.Result.Ok!.RealName ?? "";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(citizenTask.Result.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add a new skill to the form
|
|
||||||
/// </summary>
|
|
||||||
private void AddNewSkill() =>
|
|
||||||
ProfileForm.Skills.Add(new SkillForm { Id = $"new{_newSkillCounter++}" });
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove the skill for the given ID
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="skillId">The ID of the skill to remove</param>
|
|
||||||
private void RemoveSkill(string skillId) =>
|
|
||||||
ProfileForm.Skills.Remove(ProfileForm.Skills.First(s => s.Id == skillId));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save changes to the current profile
|
|
||||||
/// </summary>
|
|
||||||
public async Task SaveProfile()
|
|
||||||
{
|
|
||||||
// Remove any skills left blank
|
|
||||||
var blankSkills = ProfileForm.Skills
|
|
||||||
.Where(s => string.IsNullOrEmpty(s.Description) && string.IsNullOrEmpty(s.Notes))
|
|
||||||
.ToList();
|
|
||||||
foreach (var blankSkill in blankSkills) ProfileForm.Skills.Remove(blankSkill);
|
|
||||||
|
|
||||||
var res = await http.PostAsJsonAsync("/api/profile/save", ProfileForm);
|
|
||||||
if (res.IsSuccessStatusCode && state.User != null)
|
|
||||||
{
|
|
||||||
var citizen = await ServerApi.RetrieveOne<Citizen>(http, $"citizen/get/{state.User.Id}");
|
|
||||||
|
|
||||||
if (citizen.IsOk)
|
|
||||||
{
|
|
||||||
state.User = state.User with { Name = citizen.Ok!.CitizenName };
|
|
||||||
toast.ShowSuccess("Profile Saved Successfully");
|
|
||||||
nav.NavigateTo($"/profile/view/{state.User!.Id}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toast.ShowError(citizen.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var error = await res.Content.ReadAsStringAsync();
|
|
||||||
if (!string.IsNullOrEmpty(error)) error = $"- {error}";
|
|
||||||
toast.ShowError($"{(int)res.StatusCode} {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
@page "/citizen/log-off"
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject AppState state
|
|
||||||
@inject IToastService toast
|
|
||||||
@code {
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
state.Jwt = "";
|
|
||||||
state.User = null;
|
|
||||||
toast.ShowSuccess("Have a Nice Day!", "Log Off Successful");
|
|
||||||
nav.NavigateTo("/");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,81 +0,0 @@
|
||||||
@page "/how-it-works"
|
|
||||||
|
|
||||||
<PageTitle Title="How It Works" />
|
|
||||||
|
|
||||||
<h3>How It Works</h3>
|
|
||||||
|
|
||||||
<h4>Completing Your Profile</h4>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
The “View Your Employment Profile” link (which you”ll see on this page, once your profile is
|
|
||||||
established) shows your profile the way all other validated users will be able to see it. While this site does not
|
|
||||||
perform communication with others over No Agenda Social, the name on employment profiles is a link to that
|
|
||||||
user’s profile; from there, others can communicate further with you using the tools Mastodon provides.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The “Professional Biography” and “Experience” sections support Markdown, 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">original page</a> for the project is a good
|
|
||||||
overview of its capabilities, and the pages at
|
|
||||||
<a href="https://www.markdownguide.org/" target="_blank">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.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Skills are optional, but they are the place to record skills you have. Along with the skill, there is a
|
|
||||||
“Notes” section, 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 “Experience” field is intended to capture a chronological or topical employment history; with this
|
|
||||||
“quick-n-dirty” implementation, this Markdown box can be used to capture that information however you
|
|
||||||
would like it presented to fellow citizens.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
If you check the “Allow my profile to be searched publicly” 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 No Agenda Social 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>
|
|
||||||
|
|
||||||
<h4>Searching Profiles</h4>
|
|
||||||
<p>
|
|
||||||
The “View Profiles” 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 No Agenda
|
|
||||||
Social account, where you can use its features to get in touch.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Finding Employment</h4>
|
|
||||||
<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 Dashboard and the Edit Profile 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 “story” is
|
|
||||||
recorded.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
As a validated user, you can also view others success stories. Clicking “Success Stories” 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 “Edit” link.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Publicly Available Information</h4>
|
|
||||||
<p>
|
|
||||||
The “Job Seekers” 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>
|
|
||||||
|
|
||||||
<h4>Help / Suggestions</h4>
|
|
||||||
<p>
|
|
||||||
This is open-source software
|
|
||||||
<a href="https://github.com/bit-badger/jobs-jobs-jobs" _target="_blank">developed on Github</a>; feel free to
|
|
||||||
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues" target="_blank">create an issue there</a>, or look up
|
|
||||||
@("@")danieljsummers on No Agenda Social.
|
|
||||||
</p>
|
|
|
@ -1,23 +0,0 @@
|
||||||
@page "/"
|
|
||||||
@inject IJSRuntime js
|
|
||||||
|
|
||||||
<PageTitle Title="Welcome!" />
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in finding
|
|
||||||
employment. This will enable them to continue providing value-for-value to Adam and John, as they continue their work
|
|
||||||
deconstructing the misinformation that passes for news on a day-to-day basis.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Do you not understand the terms in the paragraph above? No worries; just head over to
|
|
||||||
<a href="https://noagendashow.net">
|
|
||||||
The Best Podcast in the Universe
|
|
||||||
</a> <em><a class="audio" @onclick=@PlayTrue>(that’s true!)</a></em> and find out what you’re missing.
|
|
||||||
</p>
|
|
||||||
<audio id="thatstrue">
|
|
||||||
<source src="/audio/thats-true.mp3">
|
|
||||||
</audio>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
async void PlayTrue() => await js.InvokeVoidAsync("Audio.play", "thatstrue");
|
|
||||||
}
|
|
|
@ -1,413 +0,0 @@
|
||||||
@page "/privacy-policy"
|
|
||||||
|
|
||||||
<PageTitle Title="Privacy Policy" />
|
|
||||||
|
|
||||||
<h3>Privacy Policy</h3>
|
|
||||||
<p><em>(as of February 6<sup>th</sup>, 2021)</em></p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
@Name (“we,” “our,” or “us”) is committed to protecting your privacy. This Privacy Policy explains how your personal
|
|
||||||
information is collected, used, and disclosed by @Name.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
This Privacy Policy applies to our website, and its associated subdomains (collectively, our “Service”) alongside
|
|
||||||
our application, @Name. By accessing or using our Service, you signify that you have read, understood, and agree to
|
|
||||||
our collection, storage, use, and disclosure of your personal information as described in this Privacy Policy and our
|
|
||||||
Terms of Service.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Definitions and key terms</h4>
|
|
||||||
<p>
|
|
||||||
To help explain things as clearly as possible in this Privacy Policy, every time any of these terms are referenced,
|
|
||||||
are strictly defined as:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Cookie: small amount of data generated by a website and saved by your web browser. It is used to identify your
|
|
||||||
browser, provide analytics, remember information about you such as your language preference or login information.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Company: when this policy mentions “Company,” “we,” “us,” or “our,” it refers to @Name, that is responsible for
|
|
||||||
your information under this Privacy Policy.
|
|
||||||
</li>
|
|
||||||
<li>Country: where @Name or the owners/founders of @Name are based, in this case is US.</li>
|
|
||||||
<li>
|
|
||||||
Customer: refers to the company, organization or person that signs up to use the @Name Service to manage the
|
|
||||||
relationships with your consumers or service users.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Device: any internet connected device such as a phone, tablet, computer or any other device that can be used to
|
|
||||||
visit @Name and use the services.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
IP address: Every device connected to the Internet is assigned a number known as an Internet protocol (IP) address.
|
|
||||||
These numbers are usually assigned in geographic blocks. An IP address can often be used to identify the location
|
|
||||||
from which a device is connecting to the Internet.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Personnel: refers to those individuals who are employed by @Name or are under contract to perform a service on
|
|
||||||
behalf of one of the parties.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Personal Data: any information that directly, indirectly, or in connection with other information — including a
|
|
||||||
personal identification number — allows for the identification or identifiability of a natural person.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Service: refers to the service provided by @Name as described in the relative terms (if available) and on this
|
|
||||||
platform.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Third-party service: refers to advertisers, contest sponsors, promotional and marketing partners, and others who
|
|
||||||
provide our content or whose products or services we think may interest you.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Website: @Name’s site, which can be accessed via this URL: <a href="/">https://noagendacareers.com/</a>
|
|
||||||
</li>
|
|
||||||
<li>You: a person or entity that is registered with @Name to use the Services.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>What Information Do We Collect?</h4>
|
|
||||||
<p>
|
|
||||||
We collect information from you when you visit our website, register on our site, or fill out a form.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>Name / Username</li>
|
|
||||||
<li>Coarse Geographic Location</li>
|
|
||||||
<li>Employment History</li>
|
|
||||||
<li>No Agenda Social Account Name / Profile</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>How Do We Use The Information We Collect?</h4>
|
|
||||||
<p>
|
|
||||||
Any of the information we collect from you may be used in one of the following ways:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>To personalize your experience (your information helps us to better respond to your individual needs)</li>
|
|
||||||
<li>
|
|
||||||
To improve our website (we continually strive to improve our website offerings based on the information and
|
|
||||||
feedback we receive from you)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To improve customer service (your information helps us to more effectively respond to your customer service
|
|
||||||
requests and support needs)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>When does @Name use end user information from third parties?</h4>
|
|
||||||
<p>@Name will collect End User Data necessary to provide the @Name services to our customers.</p>
|
|
||||||
<p>
|
|
||||||
End users may voluntarily provide us with information they have made available on social media websites (specifically
|
|
||||||
No Agenda Social). If you provide us with any such information, we may collect publicly available information from
|
|
||||||
the social media websites you have indicated. You can control how much of your information social media websites make
|
|
||||||
public by visiting these websites and changing your privacy settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>When does @Name use customer information from third parties?</h4>
|
|
||||||
<p>We do not utilize third party information apart from the end-user data described above.</p>
|
|
||||||
|
|
||||||
<h4>Do we share the information we collect with third parties?</h4>
|
|
||||||
<p>
|
|
||||||
We may disclose personal and non-personal information about you to government or law enforcement officials or private
|
|
||||||
parties as we, in our sole discretion, believe necessary or appropriate in order to respond to claims, legal process
|
|
||||||
(including subpoenas), to protect our rights and interests or those of a third party, the safety of the public or any
|
|
||||||
person, to prevent or stop any illegal, unethical, or legally actionable activity, or to otherwise comply with
|
|
||||||
applicable court orders, laws, rules and regulations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Where and when is information collected from customers and end users?</h4>
|
|
||||||
<p>
|
|
||||||
@Name will collect personal information that you submit to us. We may also receive personal information about you
|
|
||||||
from third parties as described above.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>How Do We Use Your E-mail Address?</h4>
|
|
||||||
<p>
|
|
||||||
We do not collect nor use an e-mail address. If you have provided it in the free text areas of the site, other
|
|
||||||
validated users may be able to view it, but @Name does not search for nor utilize e-mail addresses from those areas.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>How Long Do We Keep Your Information?</h4>
|
|
||||||
<p>
|
|
||||||
We keep your information only so long as we need it to provide @Name to you and fulfill the purposes described in
|
|
||||||
this policy. When we no longer need to use your information and there is no need for us to keep it to comply with our
|
|
||||||
legal or regulatory obligations, we’ll either remove it from our systems or depersonalize it so that we can’t
|
|
||||||
identify you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>How Do We Protect Your Information?</h4>
|
|
||||||
<p>
|
|
||||||
We implement a variety of security measures to maintain the safety of your personal information when you enter,
|
|
||||||
submit, or access your personal information. We mandate the use of a secure server. We cannot, however, ensure or
|
|
||||||
warrant the absolute security of any information you transmit to @Name or guarantee that your information on the
|
|
||||||
Service may not be accessed, disclosed, altered, or destroyed by a breach of any of our physical, technical, or
|
|
||||||
managerial safeguards.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Could my information be transferred to other countries?</h4>
|
|
||||||
<p>
|
|
||||||
@Name is hosted in the US. Information collected via our website may be viewed and hosted anywhere in the world,
|
|
||||||
including countries that may not have laws of general applicability regulating the use and transfer of such data. To
|
|
||||||
the fullest extent allowed by applicable law, by using any of the above, you voluntarily consent to the trans-border
|
|
||||||
transfer and hosting of such information.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Is the information collected through the @Name Service secure?</h4>
|
|
||||||
<p>
|
|
||||||
We take precautions to protect the security of your information. We have physical, electronic, and managerial
|
|
||||||
procedures to help safeguard, prevent unauthorized access, maintain data security, and correctly use your
|
|
||||||
information. However, neither people nor security systems are foolproof, including encryption systems. In addition,
|
|
||||||
people can commit intentional crimes, make mistakes, or fail to follow policies. Therefore, while we use reasonable
|
|
||||||
efforts to protect your personal information, we cannot guarantee its absolute security. If applicable law imposes
|
|
||||||
any non-disclaimable duty to protect your personal information, you agree that intentional misconduct will be the
|
|
||||||
standards used to measure our compliance with that duty.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Can I update or correct my information?</h4>
|
|
||||||
<p>
|
|
||||||
The rights you have to request updates or corrections to the information @Name collects depend on your relationship
|
|
||||||
with @Name.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Customers have the right to request the restriction of certain uses and disclosures of personally identifiable
|
|
||||||
information as follows. You can contact us in order to (1) update or correct your personally identifiable
|
|
||||||
information, or (3) delete the personally identifiable information maintained about you on our systems (subject to
|
|
||||||
the following paragraph), by cancelling your account. Such updates, corrections, changes and deletions will have no
|
|
||||||
effect on other information that we maintain in accordance with this Privacy Policy prior to such update, correction,
|
|
||||||
change, or deletion. You are responsible for maintaining the secrecy of your unique password and account information
|
|
||||||
at all times.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
@Name also provides ways for users to modify or remove the information we have collected from them from the
|
|
||||||
application; these actions will have the same effect as contacting us to modify or remove data.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You should be aware that it is not technologically possible to remove each and every record of the information you
|
|
||||||
have provided to us from our system. The need to back up our systems to protect information from inadvertent loss
|
|
||||||
means that a copy of your information may exist in a non-erasable form that will be difficult or impossible for us to
|
|
||||||
locate. Promptly after receiving your request, all personal information stored in databases we actively use, and
|
|
||||||
other readily searchable media will be updated, corrected, changed, or deleted, as appropriate, as soon as and to the
|
|
||||||
extent reasonably and technically practicable.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you are an end user and wish to update, delete, or receive any information we have about you, you may do so by
|
|
||||||
contacting the organization of which you are a customer.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Governing Law</h4>
|
|
||||||
<p>
|
|
||||||
This Privacy Policy is governed by the laws of US without regard to its conflict of laws provision. You consent to
|
|
||||||
the exclusive jurisdiction of the courts in connection with any action or dispute arising between the parties under
|
|
||||||
or in connection with this Privacy Policy except for those individuals who may have rights to make claims under
|
|
||||||
Privacy Shield, or the Swiss-US framework.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The laws of US, excluding its conflicts of law rules, shall govern this Agreement and your use of the website. Your
|
|
||||||
use of the website may also be subject to other local, state, national, or international laws.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
By using @Name or contacting us directly, you signify your acceptance of this Privacy Policy. If you do not agree to
|
|
||||||
this Privacy Policy, you should not engage with our website, or use our services. Continued use of the website,
|
|
||||||
direct engagement with us, or following the posting of changes to this Privacy Policy that do not significantly
|
|
||||||
affect the use or disclosure of your personal information will mean that you accept those changes.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Your Consent</h4>
|
|
||||||
<p>
|
|
||||||
We’ve updated our Privacy Policy to provide you with complete transparency into what is being set when you
|
|
||||||
visit our site and how it’s being used. By using our website, registering an account, or making a purchase, you
|
|
||||||
hereby consent to our Privacy Policy and agree to its terms.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Links to Other Websites</h4>
|
|
||||||
<p>
|
|
||||||
This Privacy Policy applies only to the Services. The Services may contain links to other websites not operated or
|
|
||||||
controlled by @Name. We are not responsible for the content, accuracy or opinions expressed in such websites, and
|
|
||||||
such websites are not investigated, monitored or checked for accuracy or completeness by us. Please remember that
|
|
||||||
when you use a link to go from the Services to another website, our Privacy Policy is no longer in effect. Your
|
|
||||||
browsing and interaction on any other website, including those that have a link on our platform, is subject to that
|
|
||||||
website’s own rules and policies. Such third parties may use their own cookies or other methods to collect
|
|
||||||
information about you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Cookies</h4>
|
|
||||||
<p>@Name does not use Cookies.</p>
|
|
||||||
|
|
||||||
<h4>Kids' Privacy</h4>
|
|
||||||
<p>
|
|
||||||
We do not address anyone under the age of 13. We do not knowingly collect personally identifiable information from
|
|
||||||
anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with
|
|
||||||
Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age
|
|
||||||
of 13 without verification of parental consent, We take steps to remove that information from Our servers.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Changes To Our Privacy Policy</h4>
|
|
||||||
<p>
|
|
||||||
We may change our Service and policies, and we may need to make changes to this Privacy Policy so that they
|
|
||||||
accurately reflect our Service and policies. Unless otherwise required by law, we will notify you (for example,
|
|
||||||
through our Service) before we make changes to this Privacy Policy and give you an opportunity to review them before
|
|
||||||
they go into effect. Then, if you continue to use the Service, you will be bound by the updated Privacy Policy. If
|
|
||||||
you do not want to agree to this or any updated Privacy Policy, you can delete your account.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Third-Party Services</h4>
|
|
||||||
<p>
|
|
||||||
We may display, include or make available third-party content (including data, information, applications and other
|
|
||||||
products services) or provide links to third-party websites or services (“Third-Party Services”).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You acknowledge and agree that @Name shall not be responsible for any Third-Party Services, including their accuracy,
|
|
||||||
completeness, timeliness, validity, copyright compliance, legality, decency, quality or any other aspect thereof.
|
|
||||||
@Name does not assume and shall not have any liability or responsibility to you or any other person or entity for any
|
|
||||||
Third-Party Services.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Third-Party Services and links thereto are provided solely as a convenience to you and you access and use them
|
|
||||||
entirely at your own risk and subject to such third parties' terms and conditions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Tracking Technologies</h4>
|
|
||||||
<p>
|
|
||||||
@Name does not use any tracking technologies. When an authorization code is received from No Agenda Social, that
|
|
||||||
token is stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is
|
|
||||||
refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the
|
|
||||||
application can be used again.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Information about General Data Protection Regulation (GDPR)</h4>
|
|
||||||
<p>
|
|
||||||
We may be collecting and using information from you if you are from the European Economic Area (EEA), and in this
|
|
||||||
section of our Privacy Policy we are going to explain exactly how and why is this data collected, and how we maintain
|
|
||||||
this data under protection from being replicated or used in the wrong way.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>What is GDPR?</h4>
|
|
||||||
<p>
|
|
||||||
GDPR is an EU-wide privacy and data protection law that regulates how EU residents’ data is protected by
|
|
||||||
companies and enhances the control the EU residents have, over their personal data.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The GDPR is relevant to any globally operating company and not just the EU-based businesses and EU residents. Our
|
|
||||||
customers’ data is important irrespective of where they are located, which is why we have implemented GDPR controls
|
|
||||||
as our baseline standard for all our operations worldwide.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>What is personal data?</h4>
|
|
||||||
<p>
|
|
||||||
Any data that relates to an identifiable or identified individual. GDPR covers a broad spectrum of information that
|
|
||||||
could be used on its own, or in combination with other pieces of information, to identify a person. Personal data
|
|
||||||
extends beyond a person’s name or email address. Some examples include financial information, political opinions,
|
|
||||||
genetic data, biometric data, IP addresses, physical address, sexual orientation, and ethnicity.
|
|
||||||
</p>
|
|
||||||
<p>The Data Protection Principles include requirements such as:</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Personal data collected must be processed in a fair, legal, and transparent way and should only be used in a way
|
|
||||||
that a person would reasonably expect.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Personal data should only be collected to fulfil a specific purpose and it should only be used for that purpose.
|
|
||||||
Organizations must specify why they need the personal data when they collect it.
|
|
||||||
</li>
|
|
||||||
<li>Personal data should be held no longer than necessary to fulfil its purpose.</li>
|
|
||||||
<li>
|
|
||||||
People covered by the GDPR have the right to access their own personal data. They can also request a copy of their
|
|
||||||
data, and that their data be updated, deleted, restricted, or moved to another organization.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Why is GDPR important?</h4>
|
|
||||||
<p>
|
|
||||||
GDPR adds some new requirements regarding how companies should protect individuals’ personal data that they
|
|
||||||
collect and process. It also raises the stakes for compliance by increasing enforcement and imposing greater fines
|
|
||||||
for breach. Beyond these facts, it’s simply the right thing to do. At @Name we strongly believe that your data
|
|
||||||
privacy is very important and we already have solid security and privacy practices in place that go beyond the
|
|
||||||
requirements of this regulation.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Individual Data Subject’s Rights - Data Access, Portability, and Deletion</h4>
|
|
||||||
<p>
|
|
||||||
We are committed to helping our customers meet the data subject rights requirements of GDPR. @Name processes or
|
|
||||||
stores all personal data in fully vetted, DPA compliant vendors. We do store all conversation and personal data for
|
|
||||||
up to 6 years unless your account is deleted. In which case, we dispose of all data in accordance with our Terms of
|
|
||||||
Service and Privacy Policy, but we will not hold it longer than 60 days.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We are aware that if you are working with EU customers, you need to be able to provide them with the ability to
|
|
||||||
access, update, retrieve and remove personal data. We got you! We've been set up as self service from the start and
|
|
||||||
have always given you access to your data. Our customer support team is here for you to answer any questions you
|
|
||||||
might have about working with the API.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>California Residents</h4>
|
|
||||||
<p>
|
|
||||||
The California Consumer Privacy Act (CCPA) requires us to disclose categories of Personal Information we collect and
|
|
||||||
how we use it, the categories of sources from whom we collect Personal Information, and the third parties with whom
|
|
||||||
we share it, which we have explained above.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We are also required to communicate information about rights California residents have under California law. You may
|
|
||||||
exercise the following rights:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Right to Know and Access. You may submit a verifiable request for information regarding the: (1) categories of
|
|
||||||
Personal Information we collect, use, or share; (2) purposes for which categories of Personal Information are
|
|
||||||
collected or used by us; (3) categories of sources from which we collect Personal Information; and (4) specific
|
|
||||||
pieces of Personal Information we have collected about you.
|
|
||||||
</li>
|
|
||||||
<li>Right to Equal Service. We will not discriminate against you if you exercise your privacy rights.</li>
|
|
||||||
<li>
|
|
||||||
Right to Delete. You may submit a verifiable request to close your account and we will delete Personal Information
|
|
||||||
about you that we have collected.
|
|
||||||
</li>
|
|
||||||
<li>Request that a business that sells a consumer's personal data, not sell the consumer's personal data.</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
If you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please
|
|
||||||
contact us.
|
|
||||||
</p>
|
|
||||||
<p>We do not sell the Personal Information of our users.</p>
|
|
||||||
<p>For more information about these rights, please contact us.</p>
|
|
||||||
|
|
||||||
<h4>California Online Privacy Protection Act (CalOPPA)</h4>
|
|
||||||
<p>
|
|
||||||
CalOPPA requires us to disclose categories of Personal Information we collect and how we use it, the categories of
|
|
||||||
sources from whom we collect Personal Information, and the third parties with whom we share it, which we have
|
|
||||||
explained above.
|
|
||||||
</p>
|
|
||||||
<p>CalOPPA users have the following rights:</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Right to Know and Access. You may submit a verifiable request for information regarding the: (1) categories of
|
|
||||||
Personal Information we collect, use, or share; (2) purposes for which categories of Personal Information are
|
|
||||||
collected or used by us; (3) categories of sources from which we collect Personal Information; and (4) specific
|
|
||||||
pieces of Personal Information we have collected about you.
|
|
||||||
</li>
|
|
||||||
<li>Right to Equal Service. We will not discriminate against you if you exercise your privacy rights.</li>
|
|
||||||
<li>
|
|
||||||
Right to Delete. You may submit a verifiable request to close your account and we will delete Personal Information
|
|
||||||
about you that we have collected.
|
|
||||||
</li>
|
|
||||||
<li>Right to request that a business that sells a consumer's personal data, not sell the consumer's personal data.</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
If you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please
|
|
||||||
contact us.
|
|
||||||
</p>
|
|
||||||
<p>We do not sell the Personal Information of our users.</p>
|
|
||||||
<p>For more information about these rights, please contact us.</p>
|
|
||||||
|
|
||||||
<h4>Contact Us</h4>
|
|
||||||
<p>Don't hesitate to contact us if you have any questions.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Via this Link: <a href="/how-it-works">https://noagendacareers.com/how-it-works</a></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the application
|
|
||||||
/// </summary>
|
|
||||||
private string Name = "Jobs, Jobs, Jobs";
|
|
||||||
}
|
|
|
@ -1,57 +0,0 @@
|
||||||
@page "/profile/search"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject AppState state
|
|
||||||
|
|
||||||
<PageTitle Title="Search Profiles" />
|
|
||||||
<h3>Search Profiles</h3>
|
|
||||||
|
|
||||||
<ErrorList Errors=@ErrorMessages>
|
|
||||||
@if (Searching)
|
|
||||||
{
|
|
||||||
<p>Searching profiles...</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!Searched)
|
|
||||||
{
|
|
||||||
<p>Enter one or more criteria to filter results, or just click “Search” to list all profiles.</p>
|
|
||||||
}
|
|
||||||
<Collapsible HeaderText="Search Criteria" Collapsed=@(Searched && SearchResults.Any())>
|
|
||||||
<ProfileSearchForm Criteria=@Criteria OnSearch=@DoSearch Continents=@Continents />
|
|
||||||
</Collapsible>
|
|
||||||
<br>
|
|
||||||
@if (SearchResults.Any())
|
|
||||||
{
|
|
||||||
<table class="table table-sm table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Profile</th>
|
|
||||||
<th scope="col">Name</th>
|
|
||||||
<th scope="col" class="text-center">Seeking?</th>
|
|
||||||
<th scope="col" class="text-center">Remote?</th>
|
|
||||||
<th scope="col" class="text-center">Full-Time?</th>
|
|
||||||
<th scope="col">Last Updated</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var profile in SearchResults)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td><a href="/profile/view/@profile.CitizenId">View</a></td>
|
|
||||||
<td class=@IsSeeking(profile)>@profile.DisplayName</td>
|
|
||||||
<td class="text-center">@YesOrNo(profile.SeekingEmployment)</td>
|
|
||||||
<td class="text-center">@YesOrNo(profile.RemoteWork)</td>
|
|
||||||
<td class="text-center">@YesOrNo(profile.FullTime)</td>
|
|
||||||
<td><FullDate TheDate=@profile.LastUpdated /></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
else if (Searched)
|
|
||||||
{
|
|
||||||
<p>No results found for the specified criteria</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ErrorList>
|
|
|
@ -1,139 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using JobsJobsJobs.Shared.Api;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.Profiles
|
|
||||||
{
|
|
||||||
public partial class Search : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Whether a search has been performed
|
|
||||||
/// </summary>
|
|
||||||
private bool Searched { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Indicates whether a request for matching profiles is in progress
|
|
||||||
/// </summary>
|
|
||||||
private bool Searching { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The search criteria
|
|
||||||
/// </summary>
|
|
||||||
private ProfileSearch Criteria { get; set; } = new ProfileSearch();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Error messages encountered while searching for profiles
|
|
||||||
/// </summary>
|
|
||||||
private IList<string> ErrorMessages { get; } = new List<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// All continents
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The search results
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<ProfileSearchResult> SearchResults { get; set; } = Enumerable.Empty<ProfileSearchResult>();
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
Continents = await state.GetContinents(http);
|
|
||||||
|
|
||||||
// Determine if we have searched before
|
|
||||||
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
|
|
||||||
|
|
||||||
if (query.TryGetValue("Searched", out var searched))
|
|
||||||
{
|
|
||||||
Searched = Convert.ToBoolean(searched);
|
|
||||||
void setPart(string part, Action<string> func)
|
|
||||||
{
|
|
||||||
if (query.TryGetValue(part, out var partValue)) func(partValue);
|
|
||||||
}
|
|
||||||
setPart(nameof(Criteria.ContinentId), x => Criteria.ContinentId = x);
|
|
||||||
setPart(nameof(Criteria.Skill), x => Criteria.Skill = x);
|
|
||||||
setPart(nameof(Criteria.BioExperience), x => Criteria.BioExperience = x);
|
|
||||||
setPart(nameof(Criteria.RemoteWork), x => Criteria.RemoteWork = x);
|
|
||||||
|
|
||||||
await RetrieveProfiles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Do a search
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>This navigates with the parameters in the URL; this should trigger a search</remarks>
|
|
||||||
private async Task DoSearch()
|
|
||||||
{
|
|
||||||
var query = SearchQuery();
|
|
||||||
query.Add("Searched", "True");
|
|
||||||
nav.NavigateTo(QueryHelpers.AddQueryString("/profile/search", query));
|
|
||||||
await RetrieveProfiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retreive profiles matching the current search criteria
|
|
||||||
/// </summary>
|
|
||||||
private async Task RetrieveProfiles()
|
|
||||||
{
|
|
||||||
Searching = true;
|
|
||||||
|
|
||||||
var searchResult = await ServerApi.RetrieveMany<ProfileSearchResult>(http,
|
|
||||||
QueryHelpers.AddQueryString("profile/search", SearchQuery()));
|
|
||||||
|
|
||||||
if (searchResult.IsOk)
|
|
||||||
{
|
|
||||||
SearchResults = searchResult.Ok;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ErrorMessages.Add(searchResult.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
Searched = true;
|
|
||||||
Searching = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return a CSS class if the user is actively seeking work
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="profile">The result in question</param>
|
|
||||||
/// <returns>A string with the appropriate CSS class, if actively seeking work</returns>
|
|
||||||
private static string? IsSeeking(ProfileSearchResult profile) =>
|
|
||||||
profile.SeekingEmployment ? "font-weight-bold" : null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return "Yes" for true and "No" for false
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="condition">The condition in question</param>
|
|
||||||
/// <returns>"Yes" for true, "No" for false</returns>
|
|
||||||
private static string YesOrNo(bool condition) => condition ? "Yes" : "No";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a search query string from the currently-entered criteria
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The query string for the currently-entered criteria</returns>
|
|
||||||
private IDictionary<string, string?> SearchQuery()
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<string, string?>();
|
|
||||||
if (Criteria.IsEmptySearch) return dict;
|
|
||||||
|
|
||||||
void part(string name, Func<ProfileSearch, string?> func)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(func(Criteria))) dict.Add(name, func(Criteria));
|
|
||||||
}
|
|
||||||
|
|
||||||
part(nameof(Criteria.ContinentId), it => it.ContinentId);
|
|
||||||
part(nameof(Criteria.Skill), it => it.Skill);
|
|
||||||
part(nameof(Criteria.BioExperience), it => it.BioExperience);
|
|
||||||
part(nameof(Criteria.RemoteWork), it => it.RemoteWork);
|
|
||||||
|
|
||||||
return dict;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,62 +0,0 @@
|
||||||
@page "/profile/seeking"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject AppState state
|
|
||||||
|
|
||||||
<PageTitle Title="People Seeking Work" />
|
|
||||||
<h3>People Seeking Work</h3>
|
|
||||||
|
|
||||||
<ErrorList Errors=@ErrorMessages>
|
|
||||||
@if (Searching)
|
|
||||||
{
|
|
||||||
<p>Searching profiles...</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!Searched)
|
|
||||||
{
|
|
||||||
<p>Enter one or more criteria to filter results, or just click “Search” to list all profiles.</p>
|
|
||||||
}
|
|
||||||
<Collapsible HeaderText="Search Criteria" Collapsed=@(Searched && SearchResults.Any())>
|
|
||||||
<PublicSearchForm Criteria=@Criteria OnSearch=@DoSearch Continents=@Continents />
|
|
||||||
</Collapsible>
|
|
||||||
<br>
|
|
||||||
@if (SearchResults.Any())
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
These profiles match your search criteria. To learn more about these people, join the merry band of human
|
|
||||||
resources in the <a href="https://noagendashow.net" target="_blank">No Agenda</a> tribe!
|
|
||||||
</p>
|
|
||||||
<table class="table table-sm table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Continent</th>
|
|
||||||
<th scope="col" class="text-center">Region</th>
|
|
||||||
<th scope="col" class="text-center">Remote?</th>
|
|
||||||
<th scope="col" class="text-center">Skills</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var profile in SearchResults)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@profile.Continent</td>
|
|
||||||
<td>@profile.Region</td>
|
|
||||||
<td class="text-center">@YesOrNo(profile.RemoteWork)</td>
|
|
||||||
<td>
|
|
||||||
@foreach (var skill in profile.Skills)
|
|
||||||
{
|
|
||||||
@skill.Replace(" ()", "")<br>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
else if (Searched)
|
|
||||||
{
|
|
||||||
<p>No results found for the specified criteria</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ErrorList>
|
|
|
@ -1,134 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using JobsJobsJobs.Shared.Api;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.Profiles
|
|
||||||
{
|
|
||||||
public partial class Seeking : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Whether a search has been performed
|
|
||||||
/// </summary>
|
|
||||||
private bool Searched { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Indicates whether a request for matching profiles is in progress
|
|
||||||
/// </summary>
|
|
||||||
private bool Searching { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The search criteria
|
|
||||||
/// </summary>
|
|
||||||
private PublicSearch Criteria { get; set; } = new PublicSearch();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Error messages encountered while searching for profiles
|
|
||||||
/// </summary>
|
|
||||||
private IList<string> ErrorMessages { get; } = new List<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// All continents
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The search results
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<PublicSearchResult> SearchResults { get; set; } = Enumerable.Empty<PublicSearchResult>();
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
Continents = await state.GetContinents(http);
|
|
||||||
|
|
||||||
// Determine if we have searched before
|
|
||||||
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
|
|
||||||
|
|
||||||
if (query.TryGetValue("Searched", out var searched))
|
|
||||||
{
|
|
||||||
Searched = Convert.ToBoolean(searched);
|
|
||||||
void setPart(string part, Action<string> func)
|
|
||||||
{
|
|
||||||
if (query.TryGetValue(part, out var partValue)) func(partValue);
|
|
||||||
}
|
|
||||||
setPart(nameof(Criteria.ContinentId), x => Criteria.ContinentId = x);
|
|
||||||
setPart(nameof(Criteria.Region), x => Criteria.Region = x);
|
|
||||||
setPart(nameof(Criteria.Skill), x => Criteria.Skill = x);
|
|
||||||
setPart(nameof(Criteria.RemoteWork), x => Criteria.RemoteWork = x);
|
|
||||||
|
|
||||||
await RetrieveProfiles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Do a search
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>This navigates with the parameters in the URL; this should trigger a search</remarks>
|
|
||||||
private async Task DoSearch()
|
|
||||||
{
|
|
||||||
var query = SearchQuery();
|
|
||||||
query.Add("Searched", "True");
|
|
||||||
nav.NavigateTo(QueryHelpers.AddQueryString("/profile/seeking", query));
|
|
||||||
await RetrieveProfiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retreive profiles matching the current search criteria
|
|
||||||
/// </summary>
|
|
||||||
private async Task RetrieveProfiles()
|
|
||||||
{
|
|
||||||
Searching = true;
|
|
||||||
|
|
||||||
var searchResult = await ServerApi.RetrieveMany<PublicSearchResult>(http,
|
|
||||||
QueryHelpers.AddQueryString("profile/public-search", SearchQuery()));
|
|
||||||
|
|
||||||
if (searchResult.IsOk)
|
|
||||||
{
|
|
||||||
SearchResults = searchResult.Ok;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ErrorMessages.Add(searchResult.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
Searched = true;
|
|
||||||
Searching = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? IsSeeking(ProfileSearchResult profile) =>
|
|
||||||
profile.SeekingEmployment ? "font-weight-bold" : null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return "Yes" for true and "No" for false
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="condition">The condition in question</param>
|
|
||||||
/// <returns>"Yes" for true, "No" for false</returns>
|
|
||||||
private static string YesOrNo(bool condition) => condition ? "Yes" : "No";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a search query string from the currently-entered criteria
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The query string for the currently-entered criteria</returns>
|
|
||||||
private IDictionary<string, string?> SearchQuery()
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<string, string?>();
|
|
||||||
if (Criteria.IsEmptySearch) return dict;
|
|
||||||
|
|
||||||
void part(string name, Func<PublicSearch, string?> func)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(func(Criteria))) dict.Add(name, func(Criteria));
|
|
||||||
}
|
|
||||||
|
|
||||||
part(nameof(Criteria.ContinentId), it => it.ContinentId);
|
|
||||||
part(nameof(Criteria.Region), it => it.Region);
|
|
||||||
part(nameof(Criteria.Skill), it => it.Skill);
|
|
||||||
part(nameof(Criteria.RemoteWork), it => it.RemoteWork);
|
|
||||||
|
|
||||||
return dict;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
@page "/profile/view/{Id}"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject AppState state
|
|
||||||
|
|
||||||
<Loading OnLoad=@RetrieveProfile>
|
|
||||||
<PageTitle Title=@($"Employment Profile for {Citizen.CitizenName}") />
|
|
||||||
<h2><a href="@Citizen.ProfileUrl" target="_blank">@Citizen.CitizenName</a></h2>
|
|
||||||
<h4>@Profile.Continent!.Name, @Profile.Region</h4>
|
|
||||||
<p>@WorkTypes</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
@(new MarkupString(Profile.Biography.ToHtml()))
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
@if (Profile.Skills.Count > 0)
|
|
||||||
{
|
|
||||||
<hr>
|
|
||||||
<h4>Skills</h4>
|
|
||||||
<ul>
|
|
||||||
@foreach (var skill in Profile.Skills)
|
|
||||||
{
|
|
||||||
var notes = skill.Notes == null ? "" : $" ({skill.Notes})";
|
|
||||||
<li>@skill.Description@notes</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Profile.Experience != null)
|
|
||||||
{
|
|
||||||
<hr>
|
|
||||||
<h4>Experience / Employment History</h4>
|
|
||||||
<div>
|
|
||||||
@(new MarkupString(Profile.Experience.ToHtml()))
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Id == state.User!.Id.ToString())
|
|
||||||
{
|
|
||||||
<hr>
|
|
||||||
<p><a href="/citizen/profile"><span class="oi oi-pencil"></span> Edit Your Profile</a></p>
|
|
||||||
}
|
|
||||||
</Loading>
|
|
|
@ -1,90 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.Profiles
|
|
||||||
{
|
|
||||||
public partial class View : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The citizen whose profile is being displayed
|
|
||||||
/// </summary>
|
|
||||||
private Citizen Citizen { get; set; } = default!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The profile to display
|
|
||||||
/// </summary>
|
|
||||||
private Profile Profile { get; set; } = default!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The work types for the top of the page
|
|
||||||
/// </summary>
|
|
||||||
private MarkupString WorkTypes
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
IEnumerable<string> parts()
|
|
||||||
{
|
|
||||||
if (Profile.SeekingEmployment)
|
|
||||||
{
|
|
||||||
yield return "<strong><em>CURRENTLY SEEKING EMPLOYMENT</em></strong>";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
yield return "Not actively seeking employment";
|
|
||||||
}
|
|
||||||
yield return $"{(Profile.FullTime ? "I" : "Not i")}nterested in full-time employment";
|
|
||||||
yield return $"{(Profile.RemoteWork ? "I" : "Not i")}nterested in remote opportunities";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new MarkupString(string.Join(" • ", parts()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of the citizen whose profile should be displayed
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string Id { get; set; } = default!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve the requested profile
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errors">A collection to report errors that may occur</param>
|
|
||||||
public async Task RetrieveProfile(ICollection<string> errors)
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var citizenTask = ServerApi.RetrieveOne<Citizen>(http, $"citizen/get/{Id}");
|
|
||||||
var profileTask = ServerApi.RetrieveOne<Profile>(http, $"profile/get/{Id}");
|
|
||||||
|
|
||||||
await Task.WhenAll(citizenTask, profileTask);
|
|
||||||
|
|
||||||
if (citizenTask.Result.IsOk && citizenTask.Result.Ok != null)
|
|
||||||
{
|
|
||||||
Citizen = citizenTask.Result.Ok;
|
|
||||||
}
|
|
||||||
else if (citizenTask.Result.IsOk)
|
|
||||||
{
|
|
||||||
errors.Add("Citizen not found");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(citizenTask.Result.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profileTask.Result.IsOk && profileTask.Result.Ok != null)
|
|
||||||
{
|
|
||||||
Profile = profileTask.Result.Ok;
|
|
||||||
}
|
|
||||||
else if (profileTask.Result.IsOk)
|
|
||||||
{
|
|
||||||
errors.Add("Profile not found");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(profileTask.Result.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
@page "/so-long/options"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject AppState state
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject IToastService toast
|
|
||||||
|
|
||||||
<PageTitle Title="Account Deletion Options" />
|
|
||||||
|
|
||||||
<h3>Account Deletion Options</h3>
|
|
||||||
|
|
||||||
<h4>Option 1 – Delete Your Profile</h4>
|
|
||||||
<p>
|
|
||||||
Utilizing this option will remove your current employment profile and skills. This will preserve any success stories
|
|
||||||
you may have written, and preserves this application’s knowledge of you. This is what you want to use if you
|
|
||||||
want to clear out your profile and start again (and remove the current one from others’ view).
|
|
||||||
</p>
|
|
||||||
<p class="text-center">
|
|
||||||
<button class="btn btn-danger" @onclick=@DeleteProfile>Delete Your Profile</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h4>Option 2 – Delete Your Account</h4>
|
|
||||||
<p>
|
|
||||||
This option will make it like you never visited this site. It will delete your profile, skills, success stories, and
|
|
||||||
account. This is what you want to use if you want to disappear from this application. Clicking the button below
|
|
||||||
<strong>will not</strong> affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs,
|
|
||||||
Jobs.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<em>
|
|
||||||
(This will not revoke this application’s permissions on No Agenda Social; you will have to remove this
|
|
||||||
yourself. The confirmation message has a link where you can do this; once the page loads, find the
|
|
||||||
<strong>Jobs, Jobs, Jobs</strong> entry, and click the <strong>× Revoke</strong> link for that entry.)
|
|
||||||
</em>
|
|
||||||
</p>
|
|
||||||
<p class="text-center">
|
|
||||||
<button class="btn btn-danger" @onclick=@DeleteAccount>Delete Your Entire Account</button>
|
|
||||||
</p>
|
|
|
@ -1,54 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.SoLong
|
|
||||||
{
|
|
||||||
public partial class Options : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Extract an error phrase from a response similar to <code>404 - Not Found</code>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="response">The HTTP response</param>
|
|
||||||
/// <returns>The formatted error code</returns>
|
|
||||||
private static string ErrorPhrase(HttpResponseMessage response) =>
|
|
||||||
$"{response.StatusCode}{(string.IsNullOrEmpty(response.ReasonPhrase) ? "" : $" - {response.ReasonPhrase }")}";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete the profile only; redirect to home page on success
|
|
||||||
/// </summary>
|
|
||||||
private async Task DeleteProfile()
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var result = await http.DeleteAsync("/api/profile/");
|
|
||||||
if (result.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
toast.ShowSuccess("Profile Deleted Successfully");
|
|
||||||
nav.NavigateTo("/citizen/dashboard");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toast.ShowError(ErrorPhrase(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete everything pertaining to the user's account
|
|
||||||
/// </summary>
|
|
||||||
private async Task DeleteAccount()
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var result = await http.DeleteAsync("/api/citizen/");
|
|
||||||
if (result.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
state.Jwt = "";
|
|
||||||
state.User = null;
|
|
||||||
nav.NavigateTo("/so-long/success");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toast.ShowError(ErrorPhrase(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
@page "/so-long/success"
|
|
||||||
|
|
||||||
<PageTitle Title="Account Deletion Success" />
|
|
||||||
|
|
||||||
<h3>Account Deletion Success</h3>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Your account has been successfully deleted. To revoke the permissions you have previously granted to this
|
|
||||||
application, find it in <a href="https://noagendasocial.com/oauth/authorized_applications">this list</a> and click
|
|
||||||
<strong>× Revoke</strong>. Otherwise, clicking “Log On” will create a new, empty account without
|
|
||||||
prompting you further.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Thank you for participating, and thank you for your courage. #GitmoNation
|
|
||||||
</p>
|
|
|
@ -1,49 +0,0 @@
|
||||||
@page "/success-story/add"
|
|
||||||
@page "/success-story/edit/{Id}"
|
|
||||||
@inject HttpClient http
|
|
||||||
@inject AppState state
|
|
||||||
@inject NavigationManager nav
|
|
||||||
@inject IToastService toast
|
|
||||||
|
|
||||||
<PageTitle Title=@Title />
|
|
||||||
<h3>@Title</h3>
|
|
||||||
|
|
||||||
<Loading OnLoad=@RetrieveStory>
|
|
||||||
@if (IsNew)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us
|
|
||||||
about it below! <em>(These will be visible to other users, but not to the general public.)</em>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<EditForm Model=@Form OnValidSubmit=@SaveStory>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="fromHere" class="form-check-input" @bind-Value=@Form.FromHere />
|
|
||||||
<label for="fromHere" class="form-check-label">I found my employment here</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="story" class="jjj-label">The Success Story</label>
|
|
||||||
<MarkdownEditor Id="story" @bind-Text=@Form.Story />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<br>
|
|
||||||
<button type="submit" class="btn btn-outline-primary">Save</button>
|
|
||||||
@if (IsNew)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
<em>(Saving this will set “Seeking Employment” to “No” on your profile.)</em>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EditForm>
|
|
||||||
</Loading>
|
|
|
@ -1,124 +0,0 @@
|
||||||
using JobsJobsJobs.Shared;
|
|
||||||
using JobsJobsJobs.Shared.Api;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client.Pages.SuccessStory
|
|
||||||
{
|
|
||||||
public partial class EditStory : ComponentBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of the success story being edited
|
|
||||||
/// </summary>
|
|
||||||
[Parameter]
|
|
||||||
public string? Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The page title / header
|
|
||||||
/// </summary>
|
|
||||||
public string Title => IsNew ? "Tell Your Success Story" : "Edit Success Story";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The form with information for the success story
|
|
||||||
/// </summary>
|
|
||||||
private StoryForm Form { get; set; } = new StoryForm();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convenience property for showing new
|
|
||||||
/// </summary>
|
|
||||||
private bool IsNew => Form.Id == "new";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve the story
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errors">A collection to use in reporting errors that may occur</param>
|
|
||||||
public async Task RetrieveStory(ICollection<string> errors)
|
|
||||||
{
|
|
||||||
if (Id != null)
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var story = await ServerApi.RetrieveOne<Success>(http, $"success/{Id}");
|
|
||||||
if (story.IsOk && story.Ok != null)
|
|
||||||
{
|
|
||||||
if (story.Ok.CitizenId == state.User!.Id)
|
|
||||||
{
|
|
||||||
Form = new StoryForm
|
|
||||||
{
|
|
||||||
Id = story.Ok.Id.ToString(),
|
|
||||||
FromHere = story.Ok.FromHere,
|
|
||||||
Story = story.Ok.Story?.Text ?? ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add("Stop messing with the URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (story.IsOk)
|
|
||||||
{
|
|
||||||
errors.Add($"The success story {Id} does not exist");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errors.Add(story.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save the success story
|
|
||||||
/// </summary>
|
|
||||||
private async Task SaveStory()
|
|
||||||
{
|
|
||||||
ServerApi.SetJwt(http, state);
|
|
||||||
var res = await http.PostAsJsonAsync("/api/success/save", Form);
|
|
||||||
|
|
||||||
if (res.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
res = await http.PatchAsync("/api/profile/employment-found", new StringContent(""));
|
|
||||||
if (res.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
SaveSuccessful();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await SaveFailed(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
SaveSuccessful();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await SaveFailed(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handle success notifications if saving succeeded
|
|
||||||
/// </summary>
|
|
||||||
private void SaveSuccessful()
|
|
||||||
{
|
|
||||||
toast.ShowSuccess("Story Saved Successfully");
|
|
||||||
nav.NavigateTo("/success-story/list");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handle failure notifications is saving was not successful
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="res">The HTTP response</param>
|
|
||||||
private async Task SaveFailed(HttpResponseMessage res)
|
|
||||||
{
|
|
||||||
var error = await res.Content.ReadAsStringAsync();
|
|
||||||
if (!string.IsNullOrEmpty(error)) error = $"- {error}";
|
|
||||||
toast.ShowError($"{(int)res.StatusCode} {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user