Environment swap (#21)
Moved the server-side to an F# API with RethinkDB; the client is now a Vue 3 app
This commit is contained in:
parent
d1c41d0329
commit
ce96973c51
|
@ -3,41 +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
|
||||
..\.gitignore = ..\.gitignore
|
||||
database\12-add-real-name.sql = database\12-add-real-name.sql
|
||||
database\16-job-listing.sql = database\16-job-listing.sql
|
||||
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
|
||||
|
@ -45,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>
|
80
src/JobsJobsJobs/Api/App.fs
Normal file
80
src/JobsJobsJobs/Api/App.fs
Normal file
|
@ -0,0 +1,80 @@
|
|||
/// 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()
|
||||
.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
|
||||
|
477
src/JobsJobsJobs/Api/Data.fs
Normal file
477
src/JobsJobsJobs/Api/Data.fs
Normal file
|
@ -0,0 +1,477 @@
|
|||
/// 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" ]
|
||||
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
|
||||
|
||||
/// A retry policy where we will reconnect to RethinkDB if it has gone away
|
||||
let withReconn (conn : IConnection) =
|
||||
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 -> ()))
|
||||
|
||||
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
|
||||
let regexContains (it : string) =
|
||||
System.Text.RegularExpressions.Regex.Escape it
|
||||
|> sprintf "(?i).*%s.*"
|
||||
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
/// Profile data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let count conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Profile)
|
||||
.Count()
|
||||
.RunResultAsync<int64> conn)
|
||||
|
||||
/// Find a profile by citizen ID
|
||||
let findById (citizenId : CitizenId) conn = task {
|
||||
let! profile =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Profile)
|
||||
.Get(citizenId)
|
||||
.RunResultAsync<Profile> conn)
|
||||
return toOption profile
|
||||
}
|
||||
|
||||
/// Insert or update a profile
|
||||
let save (profile : Profile) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Profile)
|
||||
.Get(profile.id)
|
||||
.Replace(profile)
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Delete a citizen's profile
|
||||
let delete (citizenId : CitizenId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Profile)
|
||||
.Get(citizenId)
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Search profiles (logged-on users)
|
||||
let search (srch : ProfileSearch) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
(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")
|
||||
.RunResultAsync<ProfileSearchResult list> conn)
|
||||
|
||||
// Search profiles (public)
|
||||
let publicSearch (srch : PublicSearch) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
(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> conn)
|
||||
|
||||
|
||||
/// Citizen data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Citizen =
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById (citizenId : CitizenId) conn = task {
|
||||
let! citizen =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.RunResultAsync<Citizen> conn)
|
||||
return toOption citizen
|
||||
}
|
||||
|
||||
/// Find a citizen by their No Agenda Social username
|
||||
let findByNaUser (naUser : string) conn = task {
|
||||
let! citizen =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Citizen)
|
||||
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
||||
.RunResultAsync<Citizen> conn)
|
||||
return toOption citizen
|
||||
}
|
||||
|
||||
/// Add a citizen
|
||||
let add (citizen : Citizen) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Insert(citizen)
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Update the display name and last seen on date for a citizen
|
||||
let logOnUpdate (citizen : Citizen) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizen.id)
|
||||
.Update(r.HashMap(nameof citizen.displayName, citizen.displayName)
|
||||
.With(nameof citizen.lastSeenOn, citizen.lastSeenOn))
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Delete a citizen
|
||||
let delete citizenId conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
do! Profile.delete citizenId conn
|
||||
let! _ =
|
||||
r.Table(Table.Success)
|
||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Update a citizen's real name
|
||||
let realNameUpdate (citizenId : CitizenId) (realName : string option) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.Update(r.HashMap(nameof realName, realName))
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
|
||||
/// Continent data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Continent =
|
||||
|
||||
/// Get all continents
|
||||
let all conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Continent)
|
||||
.RunResultAsync<Continent list> conn)
|
||||
|
||||
/// Get a continent by its ID
|
||||
let findById (contId : ContinentId) conn = task {
|
||||
let! continent =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Continent)
|
||||
.Get(contId)
|
||||
.RunResultAsync<Continent> conn)
|
||||
return toOption continent
|
||||
}
|
||||
|
||||
|
||||
/// Job listing data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Listing =
|
||||
|
||||
/// Find all job listings posted by the given citizen
|
||||
let findByCitizen (citizenId : CitizenId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||
.RunResultAsync<Listing list> conn)
|
||||
|
||||
|
||||
/// Success story data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Success =
|
||||
|
||||
/// Find a success report by its ID
|
||||
let findById (successId : SuccessId) conn = task {
|
||||
let! success =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Success)
|
||||
.Get(successId)
|
||||
.RunResultAsync<Success> conn)
|
||||
return toOption success
|
||||
}
|
||||
|
||||
/// Insert or update a success story
|
||||
let save (success : Success) conn = task {
|
||||
let! _ =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Success)
|
||||
.Get(success.id)
|
||||
.Replace(success)
|
||||
.RunWriteAsync conn)
|
||||
()
|
||||
}
|
||||
|
||||
// Retrieve all success stories
|
||||
let all conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
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")
|
||||
.RunResultAsync<StoryEntry list> conn)
|
406
src/JobsJobsJobs/Api/Handlers.fs
Normal file
406
src/JobsJobsJobs/Api/Handlers.fs
Normal file
|
@ -0,0 +1,406 @@
|
|||
/// 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
|
||||
|
||||
/// 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 ->
|
||||
log.LogInformation "Returning Vue app"
|
||||
// TODO: check for valid URL prefixes
|
||||
return! Vue.app next ctx
|
||||
| false ->
|
||||
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
|
||||
|
||||
|
||||
/// 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 =
|
||||
|
||||
// GET: /api/listing/mine
|
||||
let mine : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! listings = Data.Listing.findByCitizen (currentCitizenId ctx) (conn ctx)
|
||||
return! json listings 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 "/get/%O" Citizen.get
|
||||
]
|
||||
DELETE [ route "" Citizen.delete ]
|
||||
]
|
||||
GET_HEAD [ route "/continent/all" Continent.all ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
route "s/mine" Listing.mine
|
||||
]
|
||||
]
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
route "" Profile.current
|
||||
route "/count" Profile.count
|
||||
routef "/get/%O" Profile.get
|
||||
routef "/view/%O" Profile.view
|
||||
route "/public-search" Profile.publicSearch
|
||||
route "/search" Profile.search
|
||||
]
|
||||
PATCH [ route "/employment-found" Profile.employmentFound ]
|
||||
POST [ route "/save" Profile.save ]
|
||||
]
|
||||
subRoute "/success" [
|
||||
GET_HEAD [
|
||||
routef "/%O" Success.get
|
||||
route "/list" Success.all
|
||||
]
|
||||
POST [ route "/save" 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
|
19
src/JobsJobsJobs/App/.eslintrc.js
Normal file
19
src/JobsJobsJobs/App/.eslintrc.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'@vue/standard',
|
||||
'@vue/typescript/recommended'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
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'
|
||||
}
|
||||
}
|
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'
|
||||
]
|
||||
}
|
13882
src/JobsJobsJobs/App/package-lock.json
generated
Normal file
13882
src/JobsJobsJobs/App/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
src/JobsJobsJobs/App/package.json
Normal file
48
src/JobsJobsJobs/App/package.json
Normal file
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "jobs-jobs-jobs",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build --mode development",
|
||||
"lint": "vue-cli-service lint",
|
||||
"apiserve": "vue-cli-service build --mode development && cd ../Api && dotnet run -c Debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "5.9.55",
|
||||
"@vuelidate/core": "^2.0.0-alpha.22",
|
||||
"@vuelidate/validators": "^2.0.0-alpha.19",
|
||||
"bootstrap": "^5.1.0",
|
||||
"core-js": "^3.6.5",
|
||||
"date-fns": "^2.23.0",
|
||||
"date-fns-tz": "^1.1.4",
|
||||
"marked": "^2.1.3",
|
||||
"vue": "^3.0.0",
|
||||
"vue-router": "^4.0.0-0",
|
||||
"vuex": "^4.0.0-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.1.0",
|
||||
"@types/marked": "^2.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.18.0",
|
||||
"@typescript-eslint/parser": "^4.18.0",
|
||||
"@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.0.0",
|
||||
"@vue/eslint-config-standard": "^5.1.2",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^7.0.0",
|
||||
"sass": "~1.32.0",
|
||||
"sass-loader": "^10.0.0",
|
||||
"typescript": "~4.1.5"
|
||||
}
|
||||
}
|
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>
|
75
src/JobsJobsJobs/App/src/App.vue
Normal file
75
src/JobsJobsJobs/App/src/App.vue
Normal file
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="jjj-app">
|
||||
<app-nav />
|
||||
<div class="jjj-main">
|
||||
<title-bar />
|
||||
<main class="container-fluid">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
<app-footer />
|
||||
<app-toaster />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
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'
|
||||
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
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'
|
||||
}
|
||||
</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: ' *'
|
||||
// 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>
|
300
src/JobsJobsJobs/App/src/api/index.ts
Normal file
300
src/JobsJobsJobs/App/src/api/index.ts
Normal file
|
@ -0,0 +1,300 @@
|
|||
import { MarkedOptions } from 'marked'
|
||||
import {
|
||||
Citizen,
|
||||
Continent,
|
||||
Count,
|
||||
Listing,
|
||||
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
|
||||
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/get/${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('continent/all'), { method: 'GET' }), 'retrieving continents')
|
||||
},
|
||||
|
||||
/** API functions for job listings */
|
||||
listings: {
|
||||
|
||||
/**
|
||||
* 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<Listing[] | string | undefined> =>
|
||||
apiResult<Listing[]>(await fetch(apiUrl('listings/mine'), reqInit('GET', user)), 'retrieving your job listings')
|
||||
},
|
||||
|
||||
/** 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/get/${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/view/${id}`), 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/save'), 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('success/list'), 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/save'), reqInit('POST', user, data)), 'saving success story')
|
||||
}
|
||||
}
|
||||
|
||||
/** The standard Jobs, Jobs, Jobs options for `marked` (GitHub-Flavo(u)red Markdown (GFM) with smart quotes) */
|
||||
export const markedOptions : MarkedOptions = {
|
||||
gfm: true,
|
||||
smartypants: true
|
||||
}
|
||||
|
||||
export * from './types'
|
234
src/JobsJobsJobs/App/src/api/types.ts
Normal file
234
src/JobsJobsJobs/App/src/api/types.ts
Normal file
|
@ -0,0 +1,234 @@
|
|||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** 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
41
src/JobsJobsJobs/App/src/components/AudioClip.vue
Normal file
41
src/JobsJobsJobs/App/src/components/AudioClip.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<span @click="playFile"><slot></slot><audio :id="clip"><source :src="clipSource"></audio></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AudioClip',
|
||||
props: {
|
||||
clip: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
/** 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()
|
||||
}
|
||||
|
||||
return {
|
||||
clipSource,
|
||||
playFile
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
audio
|
||||
display: none
|
||||
span
|
||||
border-bottom: dotted 1px lightgray
|
||||
&:hover
|
||||
cursor: pointer
|
||||
</style>
|
49
src/JobsJobsJobs/App/src/components/CollapsePanel.vue
Normal file
49
src/JobsJobsJobs/App/src/components/CollapsePanel.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<a href="#" :class="{ 'cp-c': collapsed, 'cp-o': !collapsed }" @click.prevent="toggle">{{headerText}}</a>
|
||||
</h6>
|
||||
<slot v-if="!collapsed"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CollapsePanel',
|
||||
emits: ['toggle'],
|
||||
props: {
|
||||
headerText: {
|
||||
type: String,
|
||||
default: 'Toggle'
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup (props, { emit }) {
|
||||
return {
|
||||
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>
|
23
src/JobsJobsJobs/App/src/components/ErrorList.vue
Normal file
23
src/JobsJobsJobs/App/src/components/ErrorList.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<template v-if="errors.length > 0">
|
||||
<p>The following error<template v-if="errors.length !== 1">s</template> occurred:</p>
|
||||
<ul>
|
||||
<li v-for="(error, idx) in errors" :key="idx"><pre>{{error}}</pre></li>
|
||||
</ul>
|
||||
</template>
|
||||
<slot v-else></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ErrorList',
|
||||
props: {
|
||||
errors: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
34
src/JobsJobsJobs/App/src/components/FullDate.vue
Normal file
34
src/JobsJobsJobs/App/src/components/FullDate.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<template v-if="true">{{formatted}}</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { format, 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)
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FullDate',
|
||||
props: {
|
||||
date: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
return {
|
||||
formatted: format(parseToUtc(props.date), 'PPP')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
24
src/JobsJobsJobs/App/src/components/FullDateTime.vue
Normal file
24
src/JobsJobsJobs/App/src/components/FullDateTime.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<template v-if="true">{{formatted}}</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
import { parseToUtc } from './FullDate.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FullDateTime',
|
||||
props: {
|
||||
date: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
return {
|
||||
formatted: format(parseToUtc(props.date), 'PPPppp')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
22
src/JobsJobsJobs/App/src/components/Icon.vue
Normal file
22
src/JobsJobsJobs/App/src/components/Icon.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<span :class="iconClass"></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Icon',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
return {
|
||||
iconClass: `mdi mdi-${props.icon}`
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
48
src/JobsJobsJobs/App/src/components/LoadData.vue
Normal file
48
src/JobsJobsJobs/App/src/components/LoadData.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div v-if="loading">Loading…</div>
|
||||
<error-list v-else :errors="errors">
|
||||
<slot></slot>
|
||||
</error-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import ErrorList from './ErrorList.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LoadData',
|
||||
components: { ErrorList },
|
||||
props: {
|
||||
load: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
/** Type the input function */
|
||||
const func = props.load as (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 func(errors)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
return {
|
||||
loading,
|
||||
errors
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
83
src/JobsJobsJobs/App/src/components/MarkdownEditor.vue
Normal file
83
src/JobsJobsJobs/App/src/components/MarkdownEditor.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="col-12">
|
||||
<nav class="nav nav-pills pb-1">
|
||||
<button :class="sourceClass" @click.prevent="showMarkdown">Markdown</button>
|
||||
<button :class="previewClass" @click.prevent="showPreview">Preview</button>
|
||||
</nav>
|
||||
<section v-if="preview" class="preview" v-html="previewHtml">
|
||||
</section>
|
||||
<div v-else class="form-floating">
|
||||
<textarea :id="id" :class="{ 'form-control': true, 'md-edit': true, 'is-invalid': isInvalid }" rows="10"
|
||||
v-text="text" @input="$emit('update:text', $event.target.value)"></textarea>
|
||||
<div class="invalid-feedback">Please enter some text for {{label}}</div>
|
||||
<label :for="id">{{label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
import marked from 'marked'
|
||||
import { markedOptions } from '@/api'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MarkdownEditor',
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isInvalid: { type: Boolean }
|
||||
},
|
||||
emits: ['update:text'],
|
||||
setup (props) {
|
||||
/** 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 = marked(props.text, markedOptions)
|
||||
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'
|
||||
|
||||
return {
|
||||
preview,
|
||||
previewHtml,
|
||||
showMarkdown,
|
||||
showPreview,
|
||||
sourceClass: computed(() => preview.value ? unselected : selected),
|
||||
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>
|
92
src/JobsJobsJobs/App/src/components/MaybeSave.vue
Normal file
92
src/JobsJobsJobs/App/src/components/MaybeSave.vue
Normal file
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div class="modal fade" id="maybeSaveModal" tabindex="-1" aria-labelledby="maybeSaveLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="maybeSaveLabel">Unsaved Changes</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
You have modified the data on this page since it was last saved. What would you like to do?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click.prevent="onStay">Stay on This Page</button>
|
||||
<button type="button" class="btn btn-primary" @click.prevent="onSave">Save Changes</button>
|
||||
<button type="button" class="btn btn-danger" @click.prevent="onDiscard">Discard Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, Ref, watch } from 'vue'
|
||||
import { RouteLocationNormalized, useRouter } from 'vue-router'
|
||||
import { Validation } from '@vuelidate/core'
|
||||
import { Modal } from 'bootstrap'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MaybeSave',
|
||||
props: {
|
||||
isShown: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
toRoute: {
|
||||
// Can't type this because it's not filled until just before the modal is shown
|
||||
required: true
|
||||
},
|
||||
saveAction: {
|
||||
type: Function
|
||||
},
|
||||
validator: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
emits: ['close', 'discard', 'cancel'],
|
||||
setup (props, { emit }) {
|
||||
const router = useRouter()
|
||||
|
||||
/** The route where we tried to go */
|
||||
const newRoute = computed(() => props.toRoute as RouteLocationNormalized)
|
||||
|
||||
/** Reference to the modal dialog (we can't get it until the component is rendered) */
|
||||
const modal : Ref<Modal | undefined> = ref(undefined)
|
||||
|
||||
/** Save changes (if required) and go to the next route */
|
||||
const onSave = async () => {
|
||||
if (props.saveAction) await Promise.resolve(props.saveAction())
|
||||
emit('close')
|
||||
router.push(newRoute.value)
|
||||
}
|
||||
|
||||
/** Discard changes (if required) and go to the next route */
|
||||
const onDiscard = () => {
|
||||
if (props.validator) (props.validator as Validation).$reset()
|
||||
emit('close')
|
||||
router.push(newRoute.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
modal.value = new Modal(document.getElementById('maybeSaveModal') as HTMLElement,
|
||||
{ backdrop: 'static', keyboard: false })
|
||||
})
|
||||
|
||||
/** Show or hide the modal based on the property value changing */
|
||||
watch(() => props.isShown, (toShow) => {
|
||||
if (modal.value) {
|
||||
if (toShow) {
|
||||
modal.value.show()
|
||||
} else {
|
||||
modal.value.hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
onStay: () => emit('close'),
|
||||
onSave,
|
||||
onDiscard
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
39
src/JobsJobsJobs/App/src/components/PageTitle.vue
Normal file
39
src/JobsJobsJobs/App/src/components/PageTitle.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<p v-if="false"></p>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageTitle',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
/** 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)
|
||||
|
||||
return {
|
||||
setTitle
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
title: 'setTitle'
|
||||
}
|
||||
})
|
||||
</script>
|
37
src/JobsJobsJobs/App/src/components/layout/AppFooter.vue
Normal file
37
src/JobsJobsJobs/App/src/components/layout/AppFooter.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<footer>
|
||||
<p class="text-muted">
|
||||
Jobs, Jobs, Jobs v{{appVersion}} • <router-link to="/privacy-policy">Privacy Policy</router-link>
|
||||
• <router-link to="/terms-of-service">Terms of Service</router-link>
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { version } from '../../../package.json'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppFooter',
|
||||
setup () {
|
||||
let appVersion : string = version
|
||||
while (appVersion.endsWith('.0')) {
|
||||
appVersion = appVersion.substring(0, appVersion.length - 2)
|
||||
}
|
||||
return {
|
||||
appVersion
|
||||
}
|
||||
}
|
||||
})
|
||||
</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>
|
75
src/JobsJobsJobs/App/src/components/layout/AppNav.vue
Normal file
75
src/JobsJobsJobs/App/src/components/layout/AppNav.vue
Normal file
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<aside class="collapse show p-3">
|
||||
<p class="home-link"><router-link to="/">Jobs, Jobs, Jobs</router-link></p>
|
||||
<p> </p>
|
||||
<nav>
|
||||
<template v-if="isLoggedOn">
|
||||
<router-link to="/citizen/dashboard"><icon icon="view-dashboard-variant" /> Dashboard</router-link>
|
||||
<router-link to="/citizen/profile"><icon icon="pencil" /> Edit Your Profile</router-link>
|
||||
<router-link to="/profile/search"><icon icon="view-list-outline" /> View Profiles</router-link>
|
||||
<router-link to="/success-story/list"><icon icon="thumb-up" /> Success Stories</router-link>
|
||||
<router-link to="/citizen/log-off"><icon icon="logout-variant" /> Log Off</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/"><icon icon="home" /> Home</router-link>
|
||||
<router-link to="/profile/seeking"><icon icon="view-list-outline" /> Job Seekers</router-link>
|
||||
<router-link to="/citizen/log-on"><icon icon="login-variant" /> Log On</router-link>
|
||||
</template>
|
||||
<router-link to="/how-it-works"><icon icon="help-circle-outline" /> How It Works</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppNav',
|
||||
setup () {
|
||||
const store = useStore()
|
||||
|
||||
return {
|
||||
/** Whether a user is logged in or not */
|
||||
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
|
||||
height: 100vh
|
||||
width: 250px
|
||||
min-width: 250px
|
||||
position: sticky
|
||||
top: 0
|
||||
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-bottom: 1rem
|
||||
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
|
||||
</style>
|
89
src/JobsJobsJobs/App/src/components/layout/AppToaster.vue
Normal file
89
src/JobsJobsJobs/App/src/components/layout/AppToaster.vue
Normal file
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div aria-live="polite" aria-atomic="true" id="toastHost">
|
||||
<div class="toast-container position-absolute p-3 bottom-0 start-50 translate-middle-x" id="toasts"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { Toast } from 'bootstrap'
|
||||
|
||||
/** Remove a toast once it's hidden */
|
||||
const removeToast = (event : Event) => (event.target as HTMLDivElement).remove()
|
||||
|
||||
/** Create a toast, add it to the DOM, and show it */
|
||||
const createToast = (level : 'success' | 'warning' | 'danger', message : string, process : string | undefined) => {
|
||||
let header : HTMLDivElement | undefined
|
||||
if (level !== 'success') {
|
||||
// Create a heading, optionally including the process that generated the message
|
||||
const heading = (typ : string) : string => {
|
||||
const proc = process ? ` (${process})` : ''
|
||||
return `<span class="me-auto"><strong>${typ.toUpperCase()}</strong>${proc}</span>`
|
||||
}
|
||||
header = document.createElement('div')
|
||||
header.className = 'toast-header'
|
||||
header.innerHTML = heading(level === 'warning' ? level : 'error')
|
||||
// Include a close button, as these will not auto-close
|
||||
const close = document.createElement('button')
|
||||
close.type = 'button'
|
||||
close.className = 'btn-close'
|
||||
close.setAttribute('data-bs-dismiss', 'toast')
|
||||
close.setAttribute('aria-label', 'Close')
|
||||
header.appendChild(close)
|
||||
}
|
||||
const body = document.createElement('div')
|
||||
body.className = 'toast-body'
|
||||
body.innerHTML = message
|
||||
|
||||
const toastEl = document.createElement('div')
|
||||
toastEl.className = `toast bg-${level} text-white`
|
||||
toastEl.setAttribute('role', 'alert')
|
||||
toastEl.setAttribute('aria-live', 'assertlive')
|
||||
toastEl.setAttribute('aria-atomic', 'true')
|
||||
toastEl.addEventListener('hidden.bs.toast', removeToast)
|
||||
if (header) toastEl.appendChild(header)
|
||||
toastEl.appendChild(body)
|
||||
|
||||
;(document.getElementById('toasts') as HTMLDivElement).appendChild(toastEl)
|
||||
new Toast(toastEl, { autohide: level === 'success' }).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a success toast
|
||||
*
|
||||
* @param message The message to be displayed
|
||||
*/
|
||||
export function toastSuccess (message : string) : void {
|
||||
createToast('success', message, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a warning toast
|
||||
*
|
||||
* @param message The message to be displayed
|
||||
* @param process The process which generated the warning (optional)
|
||||
*/
|
||||
export function toastWarning (message : string, process : string | undefined) : void {
|
||||
createToast('warning', message, process)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an error toast
|
||||
*
|
||||
* @param message The message to be displayed
|
||||
* @param process The process which generated the error (optional)
|
||||
*/
|
||||
export function toastError (message : string, process : string | undefined) : void {
|
||||
createToast('danger', message, process)
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppToaster'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
#toastHost
|
||||
position: sticky
|
||||
bottom: 0
|
||||
</style>
|
26
src/JobsJobsJobs/App/src/components/layout/TitleBar.vue
Normal file
26
src/JobsJobsJobs/App/src/components/layout/TitleBar.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<nav class="navbar navbar-light bg-light">
|
||||
<span></span>
|
||||
<span class="navbar-text">
|
||||
(...and Jobs – <audio-clip clip="pelosi-jobs">Let's Vote for Jobs!</audio-clip>)
|
||||
</span>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import AudioClip from '@/components/AudioClip.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TitleBar',
|
||||
components: {
|
||||
AudioClip
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.navbar-text
|
||||
font-style: italic
|
||||
padding-right: 1rem
|
||||
</style>
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<form class="container">
|
||||
<div class="row">
|
||||
<div class="col col-xs-12 col-sm-6 col-md-4 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<select id="continentId" class="form-select" :value="criteria.continentId"
|
||||
@change="updateValue('continentId', $event.target.value)">
|
||||
<option value="">– Any –</option>
|
||||
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
|
||||
</select>
|
||||
<label for="continentId">Continent</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-md-4 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="region" class="form-control form-control-sm" placeholder="(free-form text)"
|
||||
:value="criteria.region" @input="updateValue('region', $event.target.value)">
|
||||
<label for="region">Region</label>
|
||||
</div>
|
||||
<div class="form-text">(free-form text)</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0">
|
||||
<label class="jjj-label">Seeking Remote Work?</label><br>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" id="remoteNull" name="remoteWork" class="form-check-input"
|
||||
:checked="criteria.remoteWork === ''" @click="updateValue('remoteWork', '')">
|
||||
<label for="remoteNull" class="form-check-label">No Selection</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" id="remoteYes" name="remoteWork" class="form-check-input"
|
||||
:checked="criteria.remoteWork === 'yes'" @click="updateValue('remoteWork', 'yes')">
|
||||
<label for="remoteYes" class="form-check-label">Yes</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" id="remoteNo" name="remoteWork" class="form-check-input"
|
||||
:checked="criteria.remoteWork === 'no'" @click="updateValue('remoteWork', 'no')">
|
||||
<label for="remoteNo" class="form-check-label">No</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="skillSearch" class="form-control form-control-sm" placeholder="(free-form text)"
|
||||
:value="criteria.skill" @input="updateValue('skill', $event.target.value)">
|
||||
<label for="skillSearch">Skill</label>
|
||||
</div>
|
||||
<div class="form-text">(free-form text)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-xs-12">
|
||||
<br>
|
||||
<button type="submit" class="btn btn-outline-primary" @click.prevent="$emit('search')">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, Ref } from 'vue'
|
||||
import { PublicSearch } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfilePublicSearchForm',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['search', 'update:modelValue'],
|
||||
setup (props, { emit }) {
|
||||
const store = useStore()
|
||||
|
||||
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||
const criteria : Ref<PublicSearch> = ref({ ...props.modelValue as PublicSearch })
|
||||
|
||||
/** Make sure we have continents */
|
||||
onMounted(async () => await store.dispatch('ensureContinents'))
|
||||
|
||||
return {
|
||||
criteria,
|
||||
continents: computed(() => store.state.continents),
|
||||
updateValue: (key : string, value : string) => {
|
||||
criteria.value = { ...criteria.value, [key]: value }
|
||||
emit('update:modelValue', criteria.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
91
src/JobsJobsJobs/App/src/components/profile/SearchForm.vue
Normal file
91
src/JobsJobsJobs/App/src/components/profile/SearchForm.vue
Normal file
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<form class="container">
|
||||
<div class="row">
|
||||
<div class="col col-xs-12 col-sm-6 col-md-4 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<select id="continentId" class="form-select"
|
||||
:value="criteria.continentId" @change="updateValue('continentId', $event.target.value)">
|
||||
<option value="">– Any –</option>
|
||||
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
|
||||
</select>
|
||||
<label for="continentId">Continent</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0">
|
||||
<label class="jjj-label">Seeking Remote Work?</label><br>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" id="remoteNull" name="remoteWork" class="form-check-input"
|
||||
:checked="criteria.remoteWork === ''" @click="updateValue('remoteWork', '')">
|
||||
<label for="remoteNull" class="form-check-label">No Selection</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" id="remoteYes" name="remoteWork" class="form-check-input"
|
||||
:checked="criteria.remoteWork === 'yes'" @click="updateValue('remoteWork', 'yes')">
|
||||
<label for="remoteYes" class="form-check-label">Yes</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="radio" id="remoteNo" name="remoteWork" class="form-check-input"
|
||||
:checked="criteria.remoteWork === 'no'" @click="updateValue('remoteWork', 'no')">
|
||||
<label for="remoteNo" class="form-check-label">No</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="skillSearch" class="form-control" placeholder="(free-form text)"
|
||||
:value="criteria.skill" @input="updateValue('skill', $event.target.value)">
|
||||
<label for="skillSearch" class="jjj-label">Skill</label>
|
||||
</div>
|
||||
<div class="form-text">(free-form text)</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-sm-6 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="bioSearch" class="form-control" placeholder="(free-form text)"
|
||||
:value="criteria.bioExperience" @input="updateValue('bioExperience', $event.target.value)">
|
||||
<label for="bioSearch" class="jjj-label">Bio / Experience</label>
|
||||
</div>
|
||||
<div class="form-text">(free-form text)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-xs-12">
|
||||
<br>
|
||||
<button type="submit" class="btn btn-outline-primary" @click.prevent="$emit('search')">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, Ref, ref } from 'vue'
|
||||
import { ProfileSearch } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfileSearchForm',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['search', 'update:modelValue'],
|
||||
setup (props, { emit }) {
|
||||
const store = useStore()
|
||||
|
||||
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||
const criteria : Ref<ProfileSearch> = ref({ ...props.modelValue as ProfileSearch })
|
||||
|
||||
/** Make sure we have continents */
|
||||
onMounted(async () => await store.dispatch('ensureContinents'))
|
||||
|
||||
return {
|
||||
criteria,
|
||||
continents: computed(() => store.state.continents),
|
||||
updateValue: (key : string, value : string) => {
|
||||
criteria.value = { ...criteria.value, [key]: value }
|
||||
emit('update:modelValue', criteria.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
56
src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue
Normal file
56
src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue
Normal file
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div class="row pb-3">
|
||||
<div class="col col-xs-2 col-md-1 align-self-center">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete" @click.prevent="$emit('remove')">
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
<div class="col col-xs-10 col-md-6">
|
||||
<div class="form-floating">
|
||||
<input type="text" :id="`skillDesc${skill.id}`" class="form-control" maxlength="100"
|
||||
placeholder="A skill (language, design technique, process, etc.)"
|
||||
:value="skill.description" @input="updateValue('description', $event.target.value)">
|
||||
<label :for="`skillDesc${skill.id}`" class="jjj-label">Skill</label>
|
||||
</div>
|
||||
<div class="form-text">A skill (language, design technique, process, etc.)</div>
|
||||
</div>
|
||||
<div class="col col-xs-12 col-md-5">
|
||||
<div class="form-floating">
|
||||
<input type="text" :id="`skillNotes${skill.id}`" class="form-control" maxlength="100"
|
||||
placeholder="A further description of the skill (100 characters max)"
|
||||
:value="skill.notes" @input="updateValue('notes', $event.target.value)">
|
||||
<label :for="`skillNotes${skill.id}`" class="jjj-label">Notes</label>
|
||||
</div>
|
||||
<div class="form-text">A further description of the skill (100 characters max)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, Ref, ref } from 'vue'
|
||||
import { Skill } from '@/api'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfileSkillEdit',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['input', 'remove', 'update:modelValue'],
|
||||
setup (props, { emit }) {
|
||||
/** The skill being edited */
|
||||
const skill : Ref<Skill> = ref({ ...props.modelValue as Skill })
|
||||
|
||||
return {
|
||||
skill,
|
||||
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')
|
0
src/JobsJobsJobs/App/src/plugins/.gitkeep
Normal file
0
src/JobsJobsJobs/App/src/plugins/.gitkeep
Normal file
153
src/JobsJobsJobs/App/src/router/index.ts
Normal file
153
src/JobsJobsJobs/App/src/router/index.ts
Normal file
|
@ -0,0 +1,153 @@
|
|||
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: '/listing/:id/edit',
|
||||
name: 'EditListing',
|
||||
component: () => import(/* webpackChunkName: "jobedit" */ '../views/listing/ListingEdit.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
|
||||
}
|
3
src/JobsJobsJobs/App/src/shims-vuetify.d.ts
vendored
Normal file
3
src/JobsJobsJobs/App/src/shims-vuetify.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
declare module 'vuetify'
|
||||
declare module 'vuetify/lib/components'
|
||||
declare module 'vuetify/lib/directives'
|
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: {
|
||||
}
|
||||
})
|
28
src/JobsJobsJobs/App/src/views/Home.vue
Normal file
28
src/JobsJobsJobs/App/src/views/Home.vue
Normal file
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Welcome!" />
|
||||
<p> </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>
|
||||
<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</a>
|
||||
<em><audio-clip clip="thats-true"> (that’s true!)</audio-clip></em> and find out what you’re missing.
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import AudioClip from '@/components/AudioClip.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Home',
|
||||
components: {
|
||||
AudioClip
|
||||
}
|
||||
})
|
||||
</script>
|
83
src/JobsJobsJobs/App/src/views/HowItWorks.vue
Normal file
83
src/JobsJobsJobs/App/src/views/HowItWorks.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title 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>
|
||||
</article>
|
||||
</template>
|
425
src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue
Normal file
425
src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue
Normal file
|
@ -0,0 +1,425 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title 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:
|
||||
<router-link to="/">https://noagendacareers.com/</router-link>
|
||||
</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: <router-link to="/how-it-works">https://noagendacareers.com/how-it-works</router-link></li>
|
||||
</ul>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PrivacyPolicy',
|
||||
setup () {
|
||||
return {
|
||||
name: 'Jobs, Jobs, Jobs'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
43
src/JobsJobsJobs/App/src/views/TermsOfService.vue
Normal file
43
src/JobsJobsJobs/App/src/views/TermsOfService.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Terms of Service" />
|
||||
<h3>Terms of Service</h3>
|
||||
<p><em>(as of February 6<sup>th</sup>, 2021)</em></p>
|
||||
|
||||
<h4>Acceptance of Terms</h4>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h4>Description of Service and Registration</h4>
|
||||
<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</a>. 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</router-link> for details on the personal (user)
|
||||
information we maintain.
|
||||
</p>
|
||||
|
||||
<h4>Liability</h4>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h4>Updates to Terms</h4>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>
|
||||
You may also wish to review our <router-link to="/privacy-policy">privacy policy</router-link> to learn how we
|
||||
handle your data.
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
48
src/JobsJobsJobs/App/src/views/citizen/Authorized.vue
Normal file
48
src/JobsJobsJobs/App/src/views/citizen/Authorized.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Logging on..." />
|
||||
<p> </p>
|
||||
<p v-html="message"></p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from '@/store'
|
||||
import { AFTER_LOG_ON_URL } from '@/router'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Authorized',
|
||||
setup () {
|
||||
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)
|
||||
|
||||
return {
|
||||
message: computed(() => store.state.logOnState)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
120
src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue
Normal file
120
src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue
Normal file
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<article class="container">
|
||||
<page-title title="Dashboard" />
|
||||
<h3 class="pb-4">Welcome, {{user.name}}</h3>
|
||||
<load-data :load="retrieveData">
|
||||
<div class="row row-cols-1 row-cols-md-2">
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header">Your Profile</h5>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-3 text-muted fst-italic">
|
||||
Last updated <full-date :date="profile.lastUpdatedOn" />
|
||||
</h6>
|
||||
<p v-if="profile" class="card-text">
|
||||
Your profile currently lists {{profile.skills.length}}
|
||||
skill<template v-if="profile.skills.length !== 1">s</template>.
|
||||
<span v-if="profile.seekingEmployment">
|
||||
<br><br>
|
||||
Your profile indicates that you are seeking employment. Once you find it,
|
||||
<router-link to="/success-story/add">tell your fellow citizens about it!</router-link>
|
||||
</span>
|
||||
</p>
|
||||
<p v-else class="card-text">
|
||||
You do not have an employment profile established; click below (or “Edit Profile” in the
|
||||
menu) to get started!
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<template v-if="profile">
|
||||
<router-link class="btn btn-outline-secondary"
|
||||
:to="`/profile/${user.citizenId}/view`">View Profile</router-link>
|
||||
<router-link class="btn btn-outline-secondary" to="/citizen/profile">Edit Profile</router-link>
|
||||
</template>
|
||||
<router-link v-else class="btn btn-primary" to="/citizen/profile">Create Profile</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header">Other Citizens</h5>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-3 text-muted fst-italic">
|
||||
<template v-if="profileCount === 0">No</template><template v-else>{{profileCount}} Total</template>
|
||||
Employment Profile<template v-if="profileCount !== 1">s</template>
|
||||
</h6>
|
||||
<p v-if="profileCount === 1 && profile" class="card-text">
|
||||
It looks like, for now, it’s just you…
|
||||
</p>
|
||||
<p v-else-if="profileCount > 0" class="card-text">
|
||||
Take a look around and see if you can help them find work!
|
||||
</p>
|
||||
<p v-else class="card-text">
|
||||
You can click below, but you will not find anything…
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<router-link class="btn btn-outline-secondary" to="/profile/search">Search Profiles</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</load-data>
|
||||
<p> </p>
|
||||
<p>
|
||||
To see how this application works, check out “How It Works” in the sidebar (last updated June
|
||||
14<sup>th</sup>, 2021).
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, Ref, ref } from 'vue'
|
||||
import api, { LogOnSuccess, Profile } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import FullDate from '@/components/FullDate.vue'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
FullDate,
|
||||
LoadData
|
||||
},
|
||||
setup () {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
retrieveData,
|
||||
user,
|
||||
profile,
|
||||
profileCount
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
280
src/JobsJobsJobs/App/src/views/citizen/EditProfile.vue
Normal file
280
src/JobsJobsJobs/App/src/views/citizen/EditProfile.vue
Normal file
|
@ -0,0 +1,280 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Edit Profile" />
|
||||
<h3 class="pb-3">Employment Profile</h3>
|
||||
<load-data :load="retrieveData">
|
||||
<form class="row g-3">
|
||||
<div class="col-12 col-sm-10 col-md-8 col-lg-6">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="realName" class="form-control" v-model="v$.realName.$model" maxlength="255"
|
||||
placeholder="Leave blank to use your NAS display name">
|
||||
<label for="realName">Real Name</label>
|
||||
</div>
|
||||
<div class="form-text">Leave blank to use your NAS display name</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isSeeking" class="form-check-input" v-model="v$.isSeekingEmployment.$model">
|
||||
<label for="isSeeking" class="form-check-label">I am currently seeking employment</label>
|
||||
</div>
|
||||
<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!</router-link></em>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<div class="form-floating">
|
||||
<select id="continentId" :class="{ 'form-select': true, 'is-invalid': v$.continentId.$error }"
|
||||
:value="v$.continentId.$model" @change="continentChanged">
|
||||
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
|
||||
</select>
|
||||
<label for="continentId" class="jjj-required">Continent</label>
|
||||
</div>
|
||||
<div class="invalid-feedback">Please select a continent</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="region" :class="{ 'form-control': true, 'is-invalid': v$.region.$error }"
|
||||
v-model="v$.region.$model" maxlength="255" placeholder="Country, state, geographic area, etc.">
|
||||
<div id="regionFeedback" class="invalid-feedback">Please enter a region</div>
|
||||
<label for="region" class="jjj-required">Region</label>
|
||||
</div>
|
||||
<div class="form-text">Country, state, geographic area, etc.</div>
|
||||
</div>
|
||||
<markdown-editor id="bio" label="Professional Biography" v-model:text="v$.biography.$model"
|
||||
:isInvalid="v$.biography.$error" />
|
||||
<div class="col-12 col-offset-md-2 col-md-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isRemote" class="form-check-input" v-model="v$.remoteWork.$model">
|
||||
<label class="form-check-label" for="isRemote">I am looking for remote work</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isFullTime" class="form-check-input" v-model="v$.fullTime.$model">
|
||||
<label class="form-check-label" for="isFullTime">I am looking for full-time work</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
<h4 class="pb-2">
|
||||
Skills
|
||||
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button>
|
||||
</h4>
|
||||
</div>
|
||||
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
|
||||
@remove="removeSkill(skill.id)" @input="v$.skills.$touch" />
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
<h4>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>
|
||||
<markdown-editor id="experience" label="Experience" v-model:text="v$.experience.$model" />
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isPublic" class="form-check-input" v-model="v$.isPublic.$model">
|
||||
<label class="form-check-label" for="isPublic">
|
||||
Allow my profile to be searched publicly (outside NA Social)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
|
||||
<button class="btn btn-primary" @click.prevent="saveProfile">
|
||||
<icon icon="content-save-outline" /> Save
|
||||
</button>
|
||||
<template v-if="!isNew">
|
||||
|
||||
<router-link class="btn btn-outline-secondary" :to="`/profile/${user.citizenId}/view`">
|
||||
<icon icon="file-account-outline" /> View Your User Profile
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</load-data>
|
||||
<hr>
|
||||
<p class="text-muted fst-italic">
|
||||
(If you want to delete your profile, or your entire account, <router-link to="/so-long/options">see your deletion
|
||||
options here</router-link>.)
|
||||
</p>
|
||||
<maybe-save :isShown="confirmNavShown" :toRoute="nextRoute" :saveAction="saveProfile" :validator="v$"
|
||||
@close="confirmClose" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, reactive, Ref } from 'vue'
|
||||
import { onBeforeRouteLeave, RouteLocationNormalized } from 'vue-router'
|
||||
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 LoadData from '@/components/LoadData.vue'
|
||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
||||
import MaybeSave from '@/components/MaybeSave.vue'
|
||||
import ProfileSkillEdit from '@/components/profile/SkillEdit.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EditProfile',
|
||||
components: {
|
||||
LoadData,
|
||||
MarkdownEditor,
|
||||
MaybeSave,
|
||||
ProfileSkillEdit
|
||||
},
|
||||
setup () {
|
||||
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[]) => {
|
||||
await store.dispatch('ensureContinents')
|
||||
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 || '' : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
profile.continentId = (e.target as HTMLSelectElement).value
|
||||
v$.value.continentId.$touch()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the navigation confirmation is shown */
|
||||
const confirmNavShown = ref(false)
|
||||
|
||||
/** The "next" route (will be navigated or cleared) */
|
||||
const nextRoute : Ref<RouteLocationNormalized | undefined> = ref(undefined)
|
||||
|
||||
/** If the user has unsaved changes, give them an opportunity to save before moving on */
|
||||
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
|
||||
if (!v$.value.$anyDirty) return true
|
||||
nextRoute.value = to
|
||||
confirmNavShown.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
return {
|
||||
v$,
|
||||
retrieveData,
|
||||
user,
|
||||
isNew,
|
||||
profile,
|
||||
continents: computed(() => store.state.continents),
|
||||
continentChanged,
|
||||
addSkill,
|
||||
removeSkill,
|
||||
saveProfile,
|
||||
confirmNavShown,
|
||||
nextRoute,
|
||||
confirmClose: () => { confirmNavShown.value = false }
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
30
src/JobsJobsJobs/App/src/views/citizen/LogOff.vue
Normal file
30
src/JobsJobsJobs/App/src/views/citizen/LogOff.vue
Normal file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Logging off..." />
|
||||
<p> </p>
|
||||
<p class="fst-italic">Logging off…</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toastSuccess } from '@/components/layout/AppToaster.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LogOff',
|
||||
setup () {
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
store.commit('clearUser')
|
||||
toastSuccess('Log Off Successful | <strong>Have a Nice Day!</strong>')
|
||||
router.push('/')
|
||||
})
|
||||
|
||||
return { }
|
||||
}
|
||||
})
|
||||
</script>
|
33
src/JobsJobsJobs/App/src/views/citizen/LogOn.vue
Normal file
33
src/JobsJobsJobs/App/src/views/citizen/LogOn.vue
Normal file
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<article>
|
||||
<p> </p>
|
||||
<p class="fst-italic">Sending you over to No Agenda Social to log on; see you back in just a second…</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'LogOn',
|
||||
setup () {
|
||||
/** 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)
|
||||
|
||||
return { }
|
||||
}
|
||||
})
|
||||
</script>
|
3
src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue
Normal file
3
src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<p>TODO: placeholder</p>
|
||||
</template>
|
70
src/JobsJobsJobs/App/src/views/listing/MyListings.vue
Normal file
70
src/JobsJobsJobs/App/src/views/listing/MyListings.vue
Normal file
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="My Job Listings" />
|
||||
<h3 class="pb-3">My Job Listings</h3>
|
||||
<p>
|
||||
<router-link class="btn btn-primary-outline" to="/listing/new/edit">Add a New Job Listing</router-link>
|
||||
</p>
|
||||
<load-data :load="getListings">
|
||||
<table v-if="listings.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Title</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="listing in listings" :key="listing.id">
|
||||
<td><router-link :to="`/listing/${listing.Id}/edit`">Edit</router-link></td>
|
||||
<td>{{listing.Title}}</td>
|
||||
<td><full-date-time :date="listing.createdOn" /></td>
|
||||
<td><full-date-time :date="listing.updatedOn" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="fst-italic">No job listings found</p>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, Ref, ref } from 'vue'
|
||||
import api, { Listing, LogOnSuccess } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import FullDateTime from '@/components/FullDateTime.vue'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MyListings',
|
||||
components: {
|
||||
FullDateTime,
|
||||
LoadData
|
||||
},
|
||||
setup () {
|
||||
const store = useStore()
|
||||
|
||||
/** The listings for the user */
|
||||
const listings : Ref<Listing[]> = ref([])
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getListings,
|
||||
listings
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
143
src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue
Normal file
143
src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue
Normal file
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Search Profiles" />
|
||||
<h3 class="pb-3">Search Profiles</h3>
|
||||
|
||||
<p v-if="!searched">
|
||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||
</p>
|
||||
<collapse-panel headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse">
|
||||
<profile-search-form v-model="criteria" @search="doSearch" />
|
||||
</collapse-panel>
|
||||
<error-list :errors="errors">
|
||||
<p v-if="searching" class="pt-3">Searching profiles...</p>
|
||||
<template v-else>
|
||||
<table v-if="results.length > 0" class="table table-sm table-hover pt-3">
|
||||
<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>
|
||||
<tr v-for="profile in results" :key="profile.citzenId">
|
||||
<td><router-link :to="`/profile/${profile.citizenId}/view`">View</router-link></td>
|
||||
<td :class="{ 'font-weight-bold' : profile.seekingEmployment }">{{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><full-date :date="profile.lastUpdatedOn" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="searched" class="pt-3">No results found for the specified criteria</p>
|
||||
</template>
|
||||
</error-list>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script 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'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfileSearch',
|
||||
components: {
|
||||
CollapsePanel,
|
||||
ErrorList,
|
||||
FullDate,
|
||||
ProfileSearchForm
|
||||
},
|
||||
setup () {
|
||||
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: undefined,
|
||||
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
|
||||
const searchParams : ProfileSearch = {
|
||||
continentId: queryValue(route, 'continentId'),
|
||||
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
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query, setUpPage, { immediate: true })
|
||||
|
||||
return {
|
||||
errors,
|
||||
criteria,
|
||||
isCollapsed,
|
||||
toggleCollapse: (it : boolean) => { isCollapsed.value = it },
|
||||
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
|
||||
searching,
|
||||
searched,
|
||||
results,
|
||||
yesOrNo
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
104
src/JobsJobsJobs/App/src/views/profile/ProfileView.vue
Normal file
104
src/JobsJobsJobs/App/src/views/profile/ProfileView.vue
Normal file
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title :title="pageTitle" />
|
||||
<load-data :load="retrieveProfile">
|
||||
<h2><a :href="it.citizen.profileUrl" target="_blank">{{citizenName}}</a></h2>
|
||||
<h4 class="pb-3">{{it.continent.name}}, {{it.profile.region}}</h4>
|
||||
<p v-html="workTypes"></p>
|
||||
<hr>
|
||||
<div v-html="bioHtml"></div>
|
||||
|
||||
<template v-if="it.profile.skills.length > 0">
|
||||
<hr>
|
||||
<h4 class="pb-3">Skills</h4>
|
||||
<ul>
|
||||
<li v-for="(skill, idx) in it.profile.skills" :key="idx">
|
||||
{{skill.description}}<template v-if="skill.notes"> ({{skill.notes}})</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-if="it.profile.experience">
|
||||
<hr>
|
||||
<h4 class="pb-3">Experience / Employment History</h4>
|
||||
<div v-html="expHtml"></div>
|
||||
</template>
|
||||
|
||||
<template v-if="user.citizenId === it.citizen.id">
|
||||
<br><br>
|
||||
<router-link class="btn btn-primary" to="/citizen/profile">
|
||||
<icon icon="pencil" /> Edit Your Profile
|
||||
</router-link>
|
||||
</template>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import marked from 'marked'
|
||||
import api, { LogOnSuccess, markedOptions, ProfileForView } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfileEdit',
|
||||
components: { LoadData },
|
||||
setup () {
|
||||
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 citizen's name (real, display, or NAS, whichever is found first) */
|
||||
const citizenName = computed(() => {
|
||||
const c = it.value?.citizen
|
||||
return c?.realName || c?.displayName || c?.naUser || ''
|
||||
})
|
||||
|
||||
/** The work types for the top of the page */
|
||||
const workTypes = computed(() => {
|
||||
const parts : string[] = []
|
||||
if (it.value) {
|
||||
const p = it.value.profile
|
||||
if (p.seekingEmployment) {
|
||||
parts.push('<strong><em>CURRENTLY SEEKING EMPLOYMENT</em></strong>')
|
||||
} else {
|
||||
parts.push('Not actively seeking employment')
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pageTitle: computed(() => it.value ? `Employment profile for ${citizenName.value}` : 'Loading Profile...'),
|
||||
user,
|
||||
retrieveProfile,
|
||||
it,
|
||||
workTypes,
|
||||
citizenName,
|
||||
bioHtml: computed(() => marked(it.value?.profile.biography || '', markedOptions)),
|
||||
expHtml: computed(() => marked(it.value?.profile.experience || '', markedOptions))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
142
src/JobsJobsJobs/App/src/views/profile/Seeking.vue
Normal file
142
src/JobsJobsJobs/App/src/views/profile/Seeking.vue
Normal file
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="People Seeking Work" />
|
||||
<h3 class="pb-3">People Seeking Work</h3>
|
||||
|
||||
<p v-if="!searched">
|
||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||
</p>
|
||||
<collapse-panel headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse">
|
||||
<profile-public-search-form v-model="criteria" @search="doSearch" />
|
||||
</collapse-panel>
|
||||
<error-list :errors="errors">
|
||||
<p v-if="searching">Searching profiles...</p>
|
||||
<template v-else>
|
||||
<template v-if="results.length > 0">
|
||||
<p class="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</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>
|
||||
<tr v-for="(profile, idx) in results" :key="idx">
|
||||
<td>{{profile.continent}}</td>
|
||||
<td>{{profile.region}}</td>
|
||||
<td class="text-center">{{yesOrNo(profile.remoteWork)}}</td>
|
||||
<td>
|
||||
<template v-for="(skill, idx) in profile.skills" :key="idx">{{skill}}<br></template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<p v-else-if="searched" class="pt-3">No results found for the specified criteria</p>
|
||||
</template>
|
||||
</error-list>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, 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'
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CollapsePanel,
|
||||
ErrorList,
|
||||
ProfilePublicSearchForm
|
||||
},
|
||||
setup () {
|
||||
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: undefined,
|
||||
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 searchParams : PublicSearch = {
|
||||
continentId: queryValue(route, 'continentId'),
|
||||
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
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query, setUpPage, { immediate: true })
|
||||
|
||||
return {
|
||||
errors,
|
||||
criteria,
|
||||
isCollapsed,
|
||||
toggleCollapse: (it : boolean) => { isCollapsed.value = it },
|
||||
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
|
||||
searching,
|
||||
searched,
|
||||
results,
|
||||
yesOrNo
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
80
src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue
Normal file
80
src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Account Deletion Options" />
|
||||
<h3 class="pb-3">Account Deletion Options</h3>
|
||||
|
||||
<h4 class="pb-3">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" @click.prevent="deleteProfile">Delete Your Profile</button>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4 class="pb-3">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" @click.prevent="deleteAccount">Delete Your Entire Account</button>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api, { LogOnSuccess } from '@/api'
|
||||
import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DeletionOptions',
|
||||
setup () {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deleteProfile,
|
||||
deleteAccount
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
15
src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue
Normal file
15
src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Account Deletion Success" />
|
||||
<h3 class="pb-3">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” in the left-hand menu will create a new,
|
||||
empty account without prompting you further.
|
||||
</p>
|
||||
<p>
|
||||
Thank you for participating, and thank you for your courage. #GitmoNation
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
156
src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue
Normal file
156
src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue
Normal file
|
@ -0,0 +1,156 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title :title="title" />
|
||||
<h3 class="pb-3">{{title}}</h3>
|
||||
|
||||
<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.)</em>
|
||||
</p>
|
||||
<form class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="fromHere" class="form-check-input" v-model="v$.fromHere.$model">
|
||||
<label for="fromHere" class="form-check-label">I found my employment here</label>
|
||||
</div>
|
||||
</div>
|
||||
<markdown-editor id="story" label="The Success Story" v-model:text="v$.story.$model" />
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" @click.prevent="saveStory(true)">
|
||||
<icon icon="content-save-outline" /> Save
|
||||
</button>
|
||||
<p v-if="isNew">
|
||||
<em>(Saving this will set “Seeking Employment” to “No” on your profile.)</em>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</load-data>
|
||||
<maybe-save :isShown="confirmNavShown" :toRoute="nextRoute" :saveAction="doSave" :validator="v$"
|
||||
@close="confirmClose" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, Ref } from 'vue'
|
||||
import { onBeforeRouteLeave, RouteLocationNormalized, useRoute, useRouter } from 'vue-router'
|
||||
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'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StoryEdit',
|
||||
components: {
|
||||
LoadData,
|
||||
MarkdownEditor,
|
||||
MaybeSave
|
||||
},
|
||||
setup () {
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the navigation confirmation is shown */
|
||||
const confirmNavShown = ref(false)
|
||||
|
||||
/** The "next" route (will be navigated or cleared) */
|
||||
const nextRoute : Ref<RouteLocationNormalized | undefined> = ref(undefined)
|
||||
|
||||
/** Prompt for save if the user navigates away with unsaved changes */
|
||||
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
|
||||
if (!v$.value.$anyDirty) return true
|
||||
nextRoute.value = to
|
||||
confirmNavShown.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
return {
|
||||
title,
|
||||
isNew,
|
||||
retrieveStory,
|
||||
v$,
|
||||
saveStory,
|
||||
confirmNavShown,
|
||||
nextRoute,
|
||||
doSave: async () => await saveStory(false),
|
||||
confirmClose: () => { confirmNavShown.value = false }
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
80
src/JobsJobsJobs/App/src/views/success-story/StoryList.vue
Normal file
80
src/JobsJobsJobs/App/src/views/success-story/StoryList.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Success Stories" />
|
||||
<h3 class="pb-3">Success Stories</h3>
|
||||
<load-data :load="retrieveStories">
|
||||
<table v-if="stories?.length > 0" class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Story</th>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Found Here?</th>
|
||||
<th scope="col">Recorded On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="story in stories" :key="story.id">
|
||||
<td>
|
||||
<router-link v-if="story.hasStory" :to="`/success-story/${story.id}/view`">View</router-link>
|
||||
<em v-else>None</em>
|
||||
<template v-if="story.citizenId === user.citizenId">
|
||||
~ <router-link :to="`/success-story/${story.id}/edit`">Edit</router-link>
|
||||
</template>
|
||||
</td>
|
||||
<td>{{story.citizenName}}</td>
|
||||
<td>
|
||||
<strong v-if="story.fromHere">Yes</strong>
|
||||
<template v-else>No</template>
|
||||
</td>
|
||||
<td><full-date :date="story.recordedOn" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else>There are no success stories recorded <em>(yet)</em></p>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, 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'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StoryList',
|
||||
components: {
|
||||
FullDate,
|
||||
LoadData
|
||||
},
|
||||
setup () {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
retrieveStories,
|
||||
stories,
|
||||
user
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
73
src/JobsJobsJobs/App/src/views/success-story/StoryView.vue
Normal file
73
src/JobsJobsJobs/App/src/views/success-story/StoryView.vue
Normal file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Success Story" />
|
||||
<load-data :load="retrieveStory">
|
||||
<h3 class="pb-3">{{citizenName}}’s Success Story</h3>
|
||||
<h4 class="text-muted"><full-date-time :date="story.recordedOn" /></h4>
|
||||
<p v-if="story.fromHere" class="fst-italic"><strong>Found via Jobs, Jobs, Jobs</strong></p>
|
||||
<hr>
|
||||
<div v-if="story.story" v-html="successStory"></div>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, Ref, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import marked from 'marked'
|
||||
import api, { LogOnSuccess, markedOptions, Success } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import FullDateTime from '@/components/FullDateTime.vue'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'StoryView',
|
||||
components: {
|
||||
FullDateTime,
|
||||
LoadData
|
||||
},
|
||||
setup () {
|
||||
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 = citResponse.realName || citResponse.displayName || citResponse.naUser
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
story,
|
||||
retrieveStory,
|
||||
citizenName,
|
||||
successStory: computed(() => marked(story.value?.story || '', markedOptions))
|
||||
}
|
||||
}
|
||||
})
|
||||
</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,12 +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>
|
||||
<ShowAllFiles>true</ShowAllFiles>
|
||||
</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,41 +0,0 @@
|
|||
@page "/listings/mine"
|
||||
@inject HttpClient http
|
||||
@inject NavigationManager nav
|
||||
@inject AppState state
|
||||
|
||||
<PageTitle Title="My Job Listings" />
|
||||
<h3>My Job Listings</h3>
|
||||
|
||||
<Loading OnLoad=@OnLoad Message=@(new MarkupString("Loading job listings…"))>
|
||||
<p>
|
||||
<a class="btn" href="/listing/new/edit">Add a New Job Listing</a>
|
||||
</p>
|
||||
@if (Listings.Any())
|
||||
{
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Title</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var listing in Listings)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="/listing/@listing.Id/edit">Edit</a></td>
|
||||
<td>@listing.Title</td>
|
||||
<td><FullDateTime TheDate=@listing.CreatedOn /></td>
|
||||
<td><FullDateTime TheDate=@listing.UpdatedOn /></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p><em>No job listings found</em></p>
|
||||
}
|
||||
</Loading>
|
|
@ -1,34 +0,0 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Client.Pages.Listings
|
||||
{
|
||||
public partial class Mine : ComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The job listings entered by the current user
|
||||
/// </summary>
|
||||
private IEnumerable<Listing> Listings { get; set; } = Enumerable.Empty<Listing>();
|
||||
|
||||
/// <summary>
|
||||
/// Load the user's job listings
|
||||
/// </summary>
|
||||
/// <param name="errors">Error collection for possible problems</param>
|
||||
private async Task OnLoad(ICollection<string> errors)
|
||||
{
|
||||
var listings = await ServerApi.RetrieveMany<Listing>(http, "listing/mine");
|
||||
|
||||
if (listings.IsOk)
|
||||
{
|
||||
Listings = listings.Ok;
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(listings.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
@page "/success-story/list"
|
||||
@inject HttpClient http
|
||||
@inject AppState state
|
||||
|
||||
<PageTitle Title="Success Stories" />
|
||||
<h3>Success Stories</h3>
|
||||
|
||||
<Loading OnLoad=@LoadStories>
|
||||
@if (Stories.Any())
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Story</th>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Found Here?</th>
|
||||
<th scope="col">Recorded On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var story in Stories)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (story.HasStory)
|
||||
{
|
||||
<a href="/success-story/view/@story.Id">View</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<em>None</em>
|
||||
}
|
||||
@if (story.CitizenId == state.User!.Id)
|
||||
{
|
||||
<text> ~ </text><a href="/success-story/edit/@story.Id">Edit</a>
|
||||
}
|
||||
</td>
|
||||
<td>@story.CitizenName</td>
|
||||
<td>
|
||||
@if (story.FromHere)
|
||||
{
|
||||
<strong>Yes</strong>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>No</text>
|
||||
}
|
||||
</td>
|
||||
<td><FullDate TheDate=@story.RecordedOn /></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>There are no success stories recorded <em>(yet)</em></p>
|
||||
}
|
||||
</Loading>
|
|
@ -1,34 +0,0 @@
|
|||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Client.Pages.SuccessStory
|
||||
{
|
||||
public partial class ListStories : ComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The story entries
|
||||
/// </summary>
|
||||
private IEnumerable<StoryEntry> Stories { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Load all success stories
|
||||
/// </summary>
|
||||
/// <param name="errors">The collection into which errors can be reported</param>
|
||||
public async Task LoadStories(ICollection<string> errors)
|
||||
{
|
||||
ServerApi.SetJwt(http, state);
|
||||
var stories = await ServerApi.RetrieveMany<StoryEntry>(http, "success/list");
|
||||
|
||||
if (stories.IsOk)
|
||||
{
|
||||
Stories = stories.Ok;
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(stories.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
@page "/success-story/view/{Id}"
|
||||
@inject HttpClient http
|
||||
@inject AppState state
|
||||
|
||||
<PageTitle Title="Success Story" />
|
||||
|
||||
<Loading OnLoad=@RetrieveStory>
|
||||
<h3>@Citizen.CitizenName’s Success Story</h3>
|
||||
<h4><FullDateTime TheDate=@Story.RecordedOn /></h4>
|
||||
@if (Story.FromHere)
|
||||
{
|
||||
<p><em><strong>Found via Jobs, Jobs, Jobs</strong></em></p>
|
||||
}
|
||||
<hr>
|
||||
@if (Story.Story != null)
|
||||
{
|
||||
<div>@(new MarkupString(Story.Story.ToHtml()))</div>
|
||||
}
|
||||
</Loading>
|
|
@ -1,69 +0,0 @@
|
|||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Client.Pages.SuccessStory
|
||||
{
|
||||
public partial class ViewStory : ComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the success story to display
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Id { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The success story to be displayed
|
||||
/// </summary>
|
||||
private Success Story { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The citizen who authorized this success story
|
||||
/// </summary>
|
||||
private Citizen Citizen { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve the success story
|
||||
/// </summary>
|
||||
/// <param name="errors">The error collection via which errors will be reported</param>
|
||||
public async Task RetrieveStory(ICollection<string> errors)
|
||||
{
|
||||
ServerApi.SetJwt(http, state);
|
||||
var story = await ServerApi.RetrieveOne<Success>(http, $"success/{Id}");
|
||||
|
||||
if (story.IsOk)
|
||||
{
|
||||
if (story.Ok == null)
|
||||
{
|
||||
errors.Add($"Success story {Id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
Story = story.Ok;
|
||||
var citizen = await ServerApi.RetrieveOne<Citizen>(http, $"citizen/get/{Story.CitizenId}");
|
||||
if (citizen.IsOk)
|
||||
{
|
||||
if (citizen.Ok == null)
|
||||
{
|
||||
errors.Add($"Citizen ID {Story.CitizenId} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
Citizen = citizen.Ok;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(citizen.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(story.Error);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
@page "/terms-of-service"
|
||||
|
||||
<PageTitle Title="Terms of Service" />
|
||||
|
||||
<h3>Terms of Service</h3>
|
||||
<p><em>(as of February 6<sup>th</sup>, 2021)</em></p>
|
||||
|
||||
<h4>Acceptance of Terms</h4>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h4>Description of Service and Registration</h4>
|
||||
<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">No Agenda Social</a>.
|
||||
Registration is accomplished by allowing Jobs, Jobs, Jobs to read one’s No Agenda Social profile. See our
|
||||
<a href="/privacy-policy">privacy policy</a> for details on the personal (user) information we maintain.
|
||||
</p>
|
||||
|
||||
<h4>Liability</h4>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h4>Updates to Terms</h4>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>You may also wish to review our <a href="/privacy-policy">privacy policy</a> to learn how we handle your data.</p>
|
|
@ -1,27 +0,0 @@
|
|||
using Blazored.Toast;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Client
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
builder.Services.AddSingleton(new AppState());
|
||||
builder.Services.AddSingleton(new JsonSerializerOptions().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));
|
||||
builder.Services.AddBlazoredToast();
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user