Help wanted #23
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -4,3 +4,6 @@ src/**/bin
 | 
			
		||||
src/**/obj
 | 
			
		||||
src/**/appsettings.*.json
 | 
			
		||||
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
 | 
			
		||||
VisualStudioVersion = 16.0.30717.126
 | 
			
		||||
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}"
 | 
			
		||||
	ProjectSection(SolutionItems) = preProject
 | 
			
		||||
		.dockerignore = .dockerignore
 | 
			
		||||
		database\12-add-real-name.sql = database\12-add-real-name.sql
 | 
			
		||||
		..\.gitignore = ..\.gitignore
 | 
			
		||||
		JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props
 | 
			
		||||
		Dockerfile = Dockerfile
 | 
			
		||||
		database\tables.sql = database\tables.sql
 | 
			
		||||
	EndProjectSection
 | 
			
		||||
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
 | 
			
		||||
	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 | 
			
		||||
		Debug|Any CPU = Debug|Any CPU
 | 
			
		||||
		Release|Any CPU = Release|Any CPU
 | 
			
		||||
	EndGlobalSection
 | 
			
		||||
	GlobalSection(ProjectConfigurationPlatforms) = postSolution
 | 
			
		||||
		{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 | 
			
		||||
		{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Debug|Any CPU.Build.0 = Debug|Any CPU
 | 
			
		||||
		{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Release|Any CPU.ActiveCfg = Release|Any CPU
 | 
			
		||||
		{35AEECBF-489D-41C5-9BA3-6E43AD7A8196}.Release|Any CPU.Build.0 = Release|Any CPU
 | 
			
		||||
		{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 | 
			
		||||
		{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Debug|Any CPU.Build.0 = Debug|Any CPU
 | 
			
		||||
		{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Release|Any CPU.ActiveCfg = Release|Any CPU
 | 
			
		||||
		{16771650-F0F4-4B9C-8C63-D9BFFC94F90A}.Release|Any CPU.Build.0 = Release|Any CPU
 | 
			
		||||
		{AE329284-47DA-4E76-B542-47489B271130}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 | 
			
		||||
		{AE329284-47DA-4E76-B542-47489B271130}.Debug|Any CPU.Build.0 = Debug|Any CPU
 | 
			
		||||
		{AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.ActiveCfg = Release|Any CPU
 | 
			
		||||
		{AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.Build.0 = Release|Any CPU
 | 
			
		||||
		{C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 | 
			
		||||
		{C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.Build.0 = Debug|Any CPU
 | 
			
		||||
		{C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.ActiveCfg = Release|Any CPU
 | 
			
		||||
		{C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.Build.0 = Release|Any CPU
 | 
			
		||||
		{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 | 
			
		||||
		{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU
 | 
			
		||||
		{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU
 | 
			
		||||
		{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU
 | 
			
		||||
		{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 | 
			
		||||
		{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Debug|Any CPU.Build.0 = Debug|Any CPU
 | 
			
		||||
		{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.ActiveCfg = Release|Any CPU
 | 
			
		||||
		{C5774E4F-2930-4B64-8407-77BF7EB79F39}.Release|Any CPU.Build.0 = Release|Any CPU
 | 
			
		||||
	EndGlobalSection
 | 
			
		||||
	GlobalSection(SolutionProperties) = preSolution
 | 
			
		||||
		HideSolutionNode = FALSE
 | 
			
		||||
@ -43,4 +44,9 @@ Global
 | 
			
		||||
	GlobalSection(ExtensibilityGlobals) = postSolution
 | 
			
		||||
		SolutionGuid = {5E9ECDBF-634E-43A9-8F89-625A2213831C}
 | 
			
		||||
	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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user