Compare commits
38
Commits
cfde960e63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10d64ec0a | ||
|
|
4146352697 | ||
|
|
8e1e90761b | ||
|
|
5a48d5a2bb | ||
|
|
eebd44faf8 | ||
|
|
c4567270b5 | ||
|
|
7d4ad34587 | ||
|
|
95bc2dfabc | ||
|
|
0752bf129e | ||
|
|
1474ba29db | ||
|
|
cbe002f4c4 | ||
|
|
8f41c04ca1 | ||
|
|
8f5545de7c | ||
|
|
5e99edc503 | ||
|
|
368cb53528 | ||
|
|
77655c779d | ||
|
|
1270fb3ae9 | ||
|
|
705c3d63aa | ||
|
|
8cec569f22 | ||
|
|
3b428215e3 | ||
|
|
851309f58f | ||
|
|
585ae4924b | ||
|
|
638fb4c0f8 | ||
|
|
f79aafab0b | ||
|
|
77f3d182d4 | ||
|
|
fc97a4271c | ||
|
|
1877aee8a5 | ||
|
|
4f9e770270 | ||
|
|
8932165f7b | ||
|
|
cd1fbeb3b0 | ||
|
|
a550abba16 | ||
|
|
fceef893c3 | ||
|
|
2acc65823b | ||
|
|
5eca1635f5 | ||
|
|
fa14fe0496 | ||
|
|
be83b7570d | ||
|
|
76c2680c60 | ||
|
|
95fb591b4b |
@@ -0,0 +1,12 @@
|
|||||||
|
POSTGRES_HOST=192.168.0.200
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=fbla25
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgrespw
|
||||||
|
JWT_SECRET=Secret
|
||||||
|
EMAIL_HOST=marinodev.com
|
||||||
|
EMAIL_PORT=465
|
||||||
|
EMAIL_USER=careerconnectnotifier@marinodev.com
|
||||||
|
EMAIL_PASS=userpassword
|
||||||
|
BASE_URL=https://fbla25.marinodev.com
|
||||||
|
|
||||||
@@ -26,3 +26,9 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
postgresql
|
postgresql
|
||||||
|
|
||||||
|
# User uploads
|
||||||
|
/uploads/logos/*
|
||||||
|
/uploads/avatars/*
|
||||||
|
/uploads/resumes/*
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,4 +8,4 @@ RUN npm ci --omit dev
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT cd /srv/FBLA25 && PORT=8080 node build
|
ENTRYPOINT node /srv/FBLA25/server.js
|
||||||
|
|||||||
@@ -1,38 +1,83 @@
|
|||||||
# sv
|
# CareerConnect - FBLA 2025
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
## Overview
|
||||||
|
|
||||||
## Creating a project
|
This is a job board application built using [SvelteKit](https://kit.svelte.dev/) for the 2025 FBLA Website Coding & Development event. It allows users to browse job listings, post new job opportunities, and manage job applications. The application is designed for fast performance and a seamless user experience.
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
## Features
|
||||||
|
|
||||||
```bash
|
- User authentication (login/signup/logout)
|
||||||
# create a new project in the current directory
|
- Browse job listings
|
||||||
npx sv create
|
- Post new job listings
|
||||||
|
- Apply for jobs
|
||||||
|
- Permissions system
|
||||||
|
- Themes
|
||||||
|
- Companies
|
||||||
|
|
||||||
# create a new project in my-app
|
## Installation
|
||||||
npx sv create my-app
|
|
||||||
|
To set up the project locally, follow these steps:
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) (LTS recommended)
|
||||||
|
- [npm](https://www.npmjs.com/) or [pnpm](https://pnpm.io/)
|
||||||
|
|
||||||
|
### Clone the repository
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.marinodev.com/MarinoDev/FBLA25
|
||||||
|
cd FBLA25
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
Create a `.env` file in the root directory and configure environment variables. `.env.example` is provided as a template.
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
### Docker
|
||||||
|
|
||||||
```bash
|
A `Dockerfile` and `docker-compose.yml` file are provided for running the application in a Docker container.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
#### Install dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Start the development server
|
||||||
|
|
||||||
|
```sh
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
Go to `http://localhost:5173/` (or the port shown in the terminal).
|
||||||
|
|
||||||
To create a production version of your app:
|
## Deployment
|
||||||
|
|
||||||
```bash
|
To deploy the application, build it using:
|
||||||
|
|
||||||
|
```sh
|
||||||
npm run build
|
npm run build
|
||||||
|
node server.js
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
## Resources Used
|
||||||
|
|
||||||
|
### Technologies
|
||||||
|
|
||||||
|
- [SvelteKit](https://kit.svelte.dev/)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
|
||||||
|
### Libraries
|
||||||
|
|
||||||
|
- [dotenv](https://www.npmjs.com/package/dotenv)
|
||||||
|
- [express](https://www.npmjs.com/package/express)
|
||||||
|
- [bcrypt](https://www.npmjs.com/package/bcrypt)
|
||||||
|
- [desm](https://www.npmjs.com/package/desm)
|
||||||
|
- [nodemailer](https://www.npmjs.com/package/nodemailer)
|
||||||
|
- [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)
|
||||||
|
- [postgres](https://www.npmjs.com/package/postgres)
|
||||||
|
- [js-cookie](https://www.npmjs.com/package/js-cookie)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ services:
|
|||||||
- "8002:8080"
|
- "8002:8080"
|
||||||
networks:
|
networks:
|
||||||
- fbla25
|
- fbla25
|
||||||
|
volumes:
|
||||||
|
- ./.env:/srv/FBLA25/.env
|
||||||
|
- ./uploads:/srv/FBLA25/uploads/
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fbla25:
|
fbla25:
|
||||||
|
|||||||
Generated
+2303
-802
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -16,10 +16,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.3",
|
"@eslint/compat": "^1.2.3",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.16.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node-fetch": "^2.6.12",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"eslint": "^9.7.0",
|
"eslint": "^9.7.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.36.0",
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.9",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
@@ -37,12 +39,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.9",
|
"@sveltejs/adapter-node": "^5.2.9",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"desm": "^1.3.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nodemailer": "^6.10.0",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
"svelte-preprocess": "^6.0.3",
|
||||||
"vitest": "^2.0.4"
|
"vitest": "^2.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -1,10 +1,14 @@
|
|||||||
## Table of Permissions
|
## Table of Permissions
|
||||||
|
|
||||||
| Permission Value | Description |
|
| Permission Value | Description |
|
||||||
|------------------|------------------------|
|
|---------------------|------------------|
|
||||||
| `00000001` | View postings |
|
| `0b00000001`(bit 0) | View access |
|
||||||
| `00000010` | View account |
|
| `0b00000010`(bit 1) | Apply for jobs |
|
||||||
| `00000100` | Submit postings access |
|
| `0b00000100`(bit 2) | Submit postings |
|
||||||
| `00001000` | Manage postings |
|
| `0b00001000`(bit 3) | Manage employers |
|
||||||
| `00010000` | Manage users |
|
| `0b00010000`(bit 4) | Manage tags |
|
||||||
| `00100000` | Apply |
|
| `0b00100000`(bit 5) | Manage postings |
|
||||||
|
| `0b01000000`(bit 6) | Manage users |
|
||||||
|
| `0b10000000`(bit 7) | Manage companies |
|
||||||
|
|
||||||
|
**Note**: Users that are not logged in are assigned a permission value of `0b00000001` (View access only).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { handler } from './build/handler.js';
|
||||||
|
import { join } from 'desm';
|
||||||
|
|
||||||
|
const assetsPath = join(import.meta.url, 'uploads');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Serve static files dynamically from the "static" folder (e.g., for uploads)
|
||||||
|
app.use('/uploads', express.static(assetsPath));
|
||||||
|
|
||||||
|
// Serve the built SvelteKit app
|
||||||
|
app.use(handler);
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(8080, () => {
|
||||||
|
console.log('Server running on port 8080');
|
||||||
|
});
|
||||||
+393
-16
@@ -2,22 +2,35 @@
|
|||||||
@import 'tailwindcss/components';
|
@import 'tailwindcss/components';
|
||||||
@import 'tailwindcss/utilities';
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
|
|
||||||
[data-theme='light'] {
|
[data-theme='light'] {
|
||||||
--text-color: #000000;
|
--text-color: #000000;
|
||||||
--bg-color: #f4f4f4;
|
--bg-color: #e9e9e9;
|
||||||
--hover-bg-color: #e4e4f0;
|
--hover-bg-color: #d0d0d0;
|
||||||
--elevated-bg-color: #ffffff;
|
--separator-line-color: #a0a0a0;
|
||||||
--low-emphasis-text-color: #6b6b6b;
|
--low-emphasis-text-color: #6b6b6b;
|
||||||
--primary-color: #1F96F3;
|
--primary-color: #1F96F3;
|
||||||
|
--dull-primary-color: #51aaf0;
|
||||||
|
--elevated-bg-color: #ffffff;
|
||||||
|
--bg-accent-color: #f4f4f4;
|
||||||
|
--danger-color: #ff2d2f;
|
||||||
|
--hyperlink-color: #3b82f6;
|
||||||
|
--nav-bg-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--text-color: #f4f4f4;
|
--text-color: #f4f4f4;
|
||||||
--bg-color: #080808;
|
--bg-color: #0c0c0c;
|
||||||
--hover-bg-color: #1f2937;
|
--hover-bg-color: #2c2e2e;
|
||||||
--elevated-bg-color: #1a202c;
|
--separator-line-color: #4e545d;
|
||||||
--low-emphasis-text-color: #999999;
|
--low-emphasis-text-color: #999999;
|
||||||
--primary-color: #1F96F3;
|
--primary-color: #1F96F3;
|
||||||
|
--dull-primary-color: #1569ab;
|
||||||
|
--elevated-bg-color: #2a2a2b;
|
||||||
|
--bg-accent-color: #303030;
|
||||||
|
--danger-color: #ff1d1f;
|
||||||
|
--hyperlink-color: #3b82f6;
|
||||||
|
--nav-bg-color: #101011;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -30,9 +43,8 @@ h1 {
|
|||||||
@apply text-4xl
|
@apply text-4xl
|
||||||
}
|
}
|
||||||
|
|
||||||
.elevated {
|
.bg-color {
|
||||||
background-color: var(--elevated-bg-color);
|
background-color: var(--bg-color);
|
||||||
@apply shadow-lg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-bg-color:hover {
|
.hover-bg-color:hover {
|
||||||
@@ -43,7 +55,11 @@ h1 {
|
|||||||
color: var(--low-emphasis-text-color);
|
color: var(--low-emphasis-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.low-emphasis-text:hover {
|
.low-emphasis-text-button {
|
||||||
|
color: var(--low-emphasis-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.low-emphasis-text-button:hover {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,19 +68,380 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-border {
|
.top-border {
|
||||||
border-top: 1px solid var(--elevated-bg-color);
|
border-top: 1px solid var(--separator-line-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-border {
|
.bottom-border {
|
||||||
border-bottom: 1px solid var(--elevated-bg-color);
|
border-bottom: 1px solid var(--separator-line-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.small-icon {
|
.left-border {
|
||||||
font-size: 24px !important;
|
border-left: 1px solid var(--separator-line-color);
|
||||||
font-variation-settings:
|
}
|
||||||
'FILL' 0,
|
|
||||||
|
.right-border {
|
||||||
|
border-right: 1px solid var(--separator-line-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-16 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-variation-settings: 'FILL' 0,
|
||||||
'wght' 400,
|
'wght' 400,
|
||||||
'GRAD' 0,
|
'GRAD' 0,
|
||||||
'opsz' 20
|
'opsz' 20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-20 {
|
||||||
|
font-size: 20px !important;
|
||||||
|
font-variation-settings: 'FILL' 0,
|
||||||
|
'wght' 400,
|
||||||
|
'GRAD' 0,
|
||||||
|
'opsz' 20
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-48 {
|
||||||
|
font-size: 48px !important;
|
||||||
|
font-variation-settings: 'FILL' 0,
|
||||||
|
'wght' 400,
|
||||||
|
'GRAD' 0,
|
||||||
|
'opsz' 20
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='search'], input[type='text'], input[type='password'], input[type='email'], input[type='tel'], input[type='number'], textarea, select {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--separator-line-color);
|
||||||
|
caret-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='search']:focus, input[type='text']:focus, input[type='password']:focus, input[type='email']:focus, input[type='tel']:focus, input[type='number']:focus, textarea:focus, select:focus {
|
||||||
|
outline: 0 solid var(--text-color);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0 var(--primary-color);
|
||||||
|
caret-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='search']:-webkit-autofill, input[type='text']:-webkit-autofill, input[type='password']:-webkit-autofill, input[type='email']:-webkit-autofill, input[type='tel']:-webkit-autofill, input[type='number']:-webkit-autofill, textarea:-webkit-autofill, select:-webkit-autofill {
|
||||||
|
-webkit-text-fill-color: var(--text-color) !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
caret-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
border: 1px solid var(--separator-line-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrange-vertically > * {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input[type='search'] {
|
||||||
|
flex-grow: 1;
|
||||||
|
border: 1px solid var(--separator-line-color);
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar button {
|
||||||
|
height: 36px;
|
||||||
|
background: none;
|
||||||
|
border-top: 1px solid var(--separator-line-color);
|
||||||
|
border-right: 1px solid var(--separator-line-color);
|
||||||
|
border-bottom: 1px solid var(--separator-line-color);
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar button:hover {
|
||||||
|
background-color: var(--hover-bg-color);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-container-small {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@apply px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.search-cancel::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-color: var(--text-color);
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23777'><path d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/></svg>");
|
||||||
|
background-size: 20px 20px;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table, .table {
|
||||||
|
/*border-collapse: separate;*/
|
||||||
|
width: 100%;
|
||||||
|
/*text-align: start;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-top: 1px solid var(--separator-line-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.left, td.left {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-borders {
|
||||||
|
border: 1px solid var(--separator-line-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevated {
|
||||||
|
background-color: var(--elevated-bg-color);
|
||||||
|
@apply shadow-lg
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevated-bg {
|
||||||
|
background-color: var(--elevated-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bg {
|
||||||
|
background-color: var(--nav-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-bg-color {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-bg-color {
|
||||||
|
background-color: var(--bg-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dull-primary-bg-color {
|
||||||
|
background-color: var(--dull-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dull-primary-text-color {
|
||||||
|
color: var(--dull-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dull-primary-border-color {
|
||||||
|
border-color: var(--dull-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-text-color {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion:hover, .active {
|
||||||
|
background-color: var(--hover-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--separator-line-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-bg-color {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-color {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-border-color {
|
||||||
|
border-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Modal (background) */
|
||||||
|
.modal {
|
||||||
|
display: none; /* Hidden by default */
|
||||||
|
position: fixed; /* Stay in place */
|
||||||
|
z-index: 1; /* Sit on top */
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
height: 100%; /* Full height */
|
||||||
|
overflow: auto; /* Enable scroll if needed */
|
||||||
|
background-color: rgb(0, 0, 0); /* Fallback color */
|
||||||
|
background-color: rgba(0, 0, 0, 0.6); /* Black w/ opacity */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-always-display {
|
||||||
|
position: fixed; /* Stay in place */
|
||||||
|
z-index: 1; /* Sit on top */
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
height: 100%; /* Full height */
|
||||||
|
overflow: auto; /* Enable scroll if needed */
|
||||||
|
background-color: rgb(0, 0, 0); /* Fallback color */
|
||||||
|
background-color: rgba(0, 0, 0, 0.6); /* Black w/ opacity */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Content/Box */
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
margin: 10% auto; /* 10% from the top and centered */
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--separator-line-color);
|
||||||
|
max-width: 420px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--text-color);
|
||||||
|
@apply text-2xl
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip text */
|
||||||
|
.tooltip-text {
|
||||||
|
visibility: hidden;
|
||||||
|
background-color: var(--bg-accent-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
/* Position the tooltip text - see examples below! */
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
width: 120px;
|
||||||
|
bottom: 120%;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -60px; /* Use half of the width (120/2 = 60), to center the tooltip */
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0s linear 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-text::after {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%; /* At the bottom of the tooltip */
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -5px;
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--bg-accent-color) transparent transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the tooltip text when you mouse over the tooltip container */
|
||||||
|
.tooltip:hover .tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip .tooltip-text:hover {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-hyperlink:hover {
|
||||||
|
color: var(--hyperlink-color);
|
||||||
|
text-decoration: underline var(--hyperlink-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hyperlink-color {
|
||||||
|
color: var(--hyperlink-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hyperlink-underline {
|
||||||
|
text-decoration: underline var(--hyperlink-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-char-length {
|
||||||
|
max-width: 200ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 5; /* Adjust the number of lines as needed */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--separator-line-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-on-elevated::-webkit-scrollbar-track {
|
||||||
|
background: var(--elevated-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.-webkit-scrollbar-thumb::-webkit-scrollbar-track {
|
||||||
|
background: var(--elevated-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-height {
|
||||||
|
max-height: calc(100vh - 314px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-with-navbar {
|
||||||
|
top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: relative;
|
||||||
|
bottom: 0;
|
||||||
|
/*width: 100%;*/
|
||||||
|
/*position: absolute;*/
|
||||||
|
/*bottom: 0;*/
|
||||||
|
/*width: 100%;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/*.drop_zone {*/
|
||||||
|
/* border: 1px dashed var(--dull-primary-color);*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<!--suppress HtmlUnknownTarget -->
|
<!--suppress HtmlUnknownTarget -->
|
||||||
<html lang="en" data-theme="">
|
<html data-theme="" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>FBLA 25</title>
|
<title>FBLA 25</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link href="%sveltekit.assets%/favicon.png" rel="icon" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const PERMISSIONS = {
|
||||||
|
VIEW: 0b00000001,
|
||||||
|
APPLY_FOR_JOBS: 0b0000010,
|
||||||
|
SUBMIT_POSTINGS: 0b0000100,
|
||||||
|
MANAGE_EMPLOYERS: 0b00001000,
|
||||||
|
MANAGE_TAGS: 0b00010000,
|
||||||
|
MANAGE_POSTINGS: 0b00100000,
|
||||||
|
MANAGE_USERS: 0b01000000,
|
||||||
|
MANAGE_COMPANIES: 0b10000000
|
||||||
|
};
|
||||||
+949
-9
@@ -1,27 +1,967 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import sql from '$lib/db/db.server';
|
import sql from '$lib/db/db.server';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { deleteLogo, saveAvatar, saveLogo } from '$lib/index.server';
|
||||||
|
import {
|
||||||
|
EmploymentType,
|
||||||
|
type User,
|
||||||
|
type Company,
|
||||||
|
type Tag,
|
||||||
|
type Posting,
|
||||||
|
type Application
|
||||||
|
} from '$lib/types';
|
||||||
|
import { sendEmployerNotificationEmail } from '$lib/emailer.server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export async function createUser(username: string, password: string): Promise<void> {
|
export async function createUser(user: User): Promise<number> {
|
||||||
const password_hash: string = await bcrypt.hash(password, 12);
|
const password_hash: string = await bcrypt.hash(user.password!, 12);
|
||||||
|
|
||||||
const response = await sql`
|
const response = await sql`
|
||||||
INSERT INTO users (username, password_hash, perms)
|
INSERT INTO users (username, password_hash, perms, created_at, last_signin, active, email, phone, full_name,
|
||||||
VALUES (${username}, ${password_hash}, 3);
|
company_code)
|
||||||
|
VALUES (${user.username}, ${password_hash}, ${user.perms}, NOW(), NOW(), ${user.active}, ${user.email},
|
||||||
|
${user.phone}, ${user.fullName}, ${user.companyCode}) RETURNING id;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// TODO: handle custom image uploads
|
||||||
|
user.id = response[0].id;
|
||||||
|
await saveAvatar(user);
|
||||||
|
|
||||||
|
return response[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserCreds(username: string, password: string): Promise<number> {
|
export async function updateUser(user: User): Promise<number> {
|
||||||
|
let password_hash: string | null = null;
|
||||||
|
|
||||||
|
// Hash the password if provided
|
||||||
|
if (user.password) {
|
||||||
|
password_hash = await bcrypt.hash(user.password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the SQL query
|
||||||
|
const response = await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET username = ${user.username},
|
||||||
|
${
|
||||||
|
user.perms !== undefined
|
||||||
|
? sql`perms = ${user.perms},`
|
||||||
|
: sql``
|
||||||
|
}
|
||||||
|
${
|
||||||
|
user.active !== undefined
|
||||||
|
? sql`active = ${user.active},`
|
||||||
|
: sql``
|
||||||
|
}
|
||||||
|
${
|
||||||
|
password_hash !== null
|
||||||
|
? sql`password_hash = ${password_hash},`
|
||||||
|
: sql``
|
||||||
|
}
|
||||||
|
email = ${user.email},
|
||||||
|
phone = ${user.phone},
|
||||||
|
full_name = ${user.fullName},
|
||||||
|
company_code = ${user.companyCode}
|
||||||
|
WHERE id = ${user.id}
|
||||||
|
RETURNING id;`;
|
||||||
|
|
||||||
|
await saveAvatar(user);
|
||||||
|
|
||||||
|
return response[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUserCreds(username: string, password: string): Promise<User | null> {
|
||||||
const [user] = await sql`
|
const [user] = await sql`
|
||||||
SELECT password_hash, perms
|
SELECT id, username, password_hash, perms, active, company_id AS "companyId"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = ${username}
|
WHERE username = ${username}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return -1;
|
return null;
|
||||||
}
|
}
|
||||||
if (await bcrypt.compare(password, user.password_hash)) {
|
if (await bcrypt.compare(password, user.password_hash)) {
|
||||||
return user['perms'];
|
delete user.password_hash;
|
||||||
|
return <User>user;
|
||||||
}
|
}
|
||||||
return -1;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// should require MANAGE_USERS permission
|
||||||
|
export async function getUsers(searchQuery: string | null = null): Promise<User[]> {
|
||||||
|
const users = await sql`
|
||||||
|
SELECT id,
|
||||||
|
username,
|
||||||
|
perms,
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
active,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName",
|
||||||
|
company_id
|
||||||
|
FROM users
|
||||||
|
WHERE username ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
|
||||||
|
`;
|
||||||
|
users.forEach((user) => {
|
||||||
|
user.company = {
|
||||||
|
id: user.company_id
|
||||||
|
};
|
||||||
|
delete user.company_id;
|
||||||
|
});
|
||||||
|
return <User[]>(<unknown>users);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanies(searchQuery: string | null = null): Promise<Company[]> {
|
||||||
|
return sql<Company[]>`
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
company_code AS "companyCode",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt"
|
||||||
|
FROM companies
|
||||||
|
WHERE name ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// should require MANAGE_USERS permission
|
||||||
|
export async function getUser(id: number): Promise<User> {
|
||||||
|
const [user] = await sql`
|
||||||
|
SELECT id,
|
||||||
|
username,
|
||||||
|
perms,
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
active,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName",
|
||||||
|
company_id,
|
||||||
|
company_code
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
if (!user) {
|
||||||
|
error(404, 'User not found');
|
||||||
|
}
|
||||||
|
user.company = {
|
||||||
|
id: user.company_id
|
||||||
|
};
|
||||||
|
delete user.company_id;
|
||||||
|
return <User>user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserWithCompany(id: number): Promise<User> {
|
||||||
|
const [user] = await sql`
|
||||||
|
SELECT u.id,
|
||||||
|
u.username,
|
||||||
|
u.perms,
|
||||||
|
u.email,
|
||||||
|
u.phone,
|
||||||
|
u.full_name AS "fullName",
|
||||||
|
u.created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
u.last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
u.active,
|
||||||
|
c.id AS company_id,
|
||||||
|
c.name AS company_name,
|
||||||
|
c.description AS company_description,
|
||||||
|
c.website AS company_website,
|
||||||
|
c.created_at AS company_created_at
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN
|
||||||
|
companies c ON u.company_id = c.id
|
||||||
|
WHERE u.id = ${id};
|
||||||
|
`;
|
||||||
|
if (!user) {
|
||||||
|
error(404, 'User not found');
|
||||||
|
}
|
||||||
|
user.company = {
|
||||||
|
id: user.company_id,
|
||||||
|
name: user.company_name,
|
||||||
|
description: user.company_description,
|
||||||
|
website: user.company_website,
|
||||||
|
createdAt: user.company_created_at
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the company-specific columns from the user object
|
||||||
|
delete user.company_id;
|
||||||
|
delete user.company_name;
|
||||||
|
delete user.company_description;
|
||||||
|
delete user.company_website;
|
||||||
|
delete user.company_created_at;
|
||||||
|
|
||||||
|
return <User>user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserWithCompanyAndApplications(
|
||||||
|
id: number
|
||||||
|
): Promise<{ user: User; applications: Application[] }> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH company_data AS (SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = (SELECT company_id FROM users WHERE id = ${id})),
|
||||||
|
user_data AS (SELECT id,
|
||||||
|
username,
|
||||||
|
perms,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
active
|
||||||
|
FROM users
|
||||||
|
WHERE "id" = ${id}),
|
||||||
|
application_data AS (SELECT id,
|
||||||
|
posting_id AS "postingId",
|
||||||
|
(SELECT title FROM postings WHERE id = posting_id) AS "postingTitle",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
candidate_statement AS "candidateStatement"
|
||||||
|
FROM applications
|
||||||
|
WHERE "user_id" = ${id})
|
||||||
|
SELECT (SELECT row_to_json(company_data)
|
||||||
|
FROM company_data) AS company,
|
||||||
|
(SELECT row_to_json(user_data)
|
||||||
|
FROM user_data) AS user,
|
||||||
|
(
|
||||||
|
SELECT json_agg(row_to_json(application_data))
|
||||||
|
FROM application_data
|
||||||
|
) AS applications;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
error(404, 'User not found');
|
||||||
|
}
|
||||||
|
const user = data[0].user;
|
||||||
|
if (data[0].company) {
|
||||||
|
user.company = data[0].company;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.createdAt = new Date(user.createdAt);
|
||||||
|
user.lastSignIn = new Date(user.lastSignIn);
|
||||||
|
if (user.company) {
|
||||||
|
user.company.createdAt = new Date(user.company.createdAt);
|
||||||
|
}
|
||||||
|
const applications = data[0].applications;
|
||||||
|
if (applications) {
|
||||||
|
applications.forEach((application: { createdAt: string | number | Date }) => {
|
||||||
|
application.createdAt = new Date(application.createdAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: <User>user,
|
||||||
|
applications: <Application[]>applications
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// should require MANAGE_USERS permission
|
||||||
|
export async function deleteUser(id: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
DELETE
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// should require MANAGE_TAGS permission
|
||||||
|
export async function getTags(searchQuery: string | null): Promise<Tag[]> {
|
||||||
|
return sql<Tag[]>`
|
||||||
|
SELECT id, display_name as "displayName", created_at AT TIME ZONE 'UTC' AS "createdAt"
|
||||||
|
FROM tags
|
||||||
|
WHERE display_name ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLastSignin(username: string): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET last_signin = NOW()
|
||||||
|
WHERE username = ${username};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCompany(company: Company): Promise<number> {
|
||||||
|
const response = await sql`
|
||||||
|
INSERT INTO companies (name, description, website, created_at, company_code)
|
||||||
|
VALUES (${company.name}, ${company.description}, ${company.website}, NOW(),
|
||||||
|
generate_company_code(CAST(CURRVAL('companies_id_seq') AS INT))) RETURNING id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
await saveLogo(company);
|
||||||
|
|
||||||
|
return response[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editCompany(company: Company): Promise<number> {
|
||||||
|
const response = await sql`
|
||||||
|
UPDATE companies
|
||||||
|
SET name = ${company.name},
|
||||||
|
description = ${company.description},
|
||||||
|
website = ${company.website}
|
||||||
|
WHERE id = ${company.id} RETURNING id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
await saveLogo(company);
|
||||||
|
|
||||||
|
return response[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCompany(id: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
DELETE
|
||||||
|
FROM companies
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
|
||||||
|
await deleteLogo(<Company>{ id: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompany(id: number): Promise<Company> {
|
||||||
|
const [company] = await sql`
|
||||||
|
SELECT id, name, description, website, created_at AS "createdAt", company_code AS "companyCode"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
error(404, 'Company not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Company>company;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyFullData(
|
||||||
|
id: number
|
||||||
|
): Promise<{ company: Company; users: User[]; postings: Posting[] }> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH company_data AS (SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = ${id}),
|
||||||
|
user_data AS (SELECT id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName"
|
||||||
|
FROM users
|
||||||
|
WHERE "company_id" = ${id}),
|
||||||
|
posting_data AS (SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
employer_id AS "employerId",
|
||||||
|
address,
|
||||||
|
employment_type AS "employmentType",
|
||||||
|
wage,
|
||||||
|
link,
|
||||||
|
tag_ids AS "tagIds",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
|
||||||
|
flyer_link AS "flyerLink"
|
||||||
|
FROM postings
|
||||||
|
WHERE "company_id" = ${id})
|
||||||
|
SELECT (SELECT row_to_json(company_data)
|
||||||
|
FROM company_data) AS company,
|
||||||
|
(SELECT json_agg(row_to_json(user_data))
|
||||||
|
FROM user_data) AS users,
|
||||||
|
(SELECT json_agg(row_to_json(posting_data))
|
||||||
|
FROM posting_data) AS postings;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
error(404, 'Company not found');
|
||||||
|
}
|
||||||
|
if (data[0].company) {
|
||||||
|
data[0].company.createdAt = new Date(data[0].company.createdAt);
|
||||||
|
}
|
||||||
|
if (data[0].postings) {
|
||||||
|
data[0].postings.forEach(
|
||||||
|
(posting: {
|
||||||
|
createdAt: string | number | Date;
|
||||||
|
updatedAt: string | number | Date;
|
||||||
|
tagIds: number[] | undefined;
|
||||||
|
tags: { id: number; displayName: null; createdAt: null }[];
|
||||||
|
}) => {
|
||||||
|
posting.createdAt = new Date(posting.createdAt);
|
||||||
|
posting.updatedAt = new Date(posting.updatedAt);
|
||||||
|
if (posting.tagIds) {
|
||||||
|
posting.tagIds?.forEach((tagId: number) => {
|
||||||
|
posting.tags.push({ id: tagId, displayName: null, createdAt: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
delete posting.tagIds;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: <Company>data[0].company,
|
||||||
|
users: <User[]>data[0].users,
|
||||||
|
postings: <Posting[]>data[0].postings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPosting(posting: Posting): Promise<number> {
|
||||||
|
if (posting.tagIds === null || posting.tagIds === undefined) {
|
||||||
|
posting.tagIds = [];
|
||||||
|
}
|
||||||
|
posting.tags?.forEach((tag) => {
|
||||||
|
posting.tagIds?.push(tag.id);
|
||||||
|
});
|
||||||
|
if (posting.companyId === null || posting.companyId === undefined) {
|
||||||
|
if (posting.company) {
|
||||||
|
posting.companyId = posting.company.id;
|
||||||
|
} else {
|
||||||
|
posting.companyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await sql`
|
||||||
|
INSERT INTO postings (title, description, employer_id, address, employment_type, wage, link, tag_ids, created_at,
|
||||||
|
updated_at, flyer_link, company_id)
|
||||||
|
VALUES (${posting.title}, ${posting.description}, ${posting.employerId}, ${posting.address},
|
||||||
|
${posting.employmentType}, ${posting.wage}, ${posting.link}, ${posting.tagIds}, NOW(), NOW(),
|
||||||
|
${posting.flyerLink}, ${posting.companyId}) RETURNING id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
return response[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editPosting(posting: Posting): Promise<number> {
|
||||||
|
if (posting.tagIds === null || posting.tagIds === undefined) {
|
||||||
|
posting.tagIds = [];
|
||||||
|
}
|
||||||
|
posting.tags?.forEach((tag) => {
|
||||||
|
posting.tagIds?.push(tag.id);
|
||||||
|
});
|
||||||
|
if (posting.companyId === null || posting.companyId === undefined) {
|
||||||
|
if (posting.company) {
|
||||||
|
posting.companyId = posting.company.id;
|
||||||
|
} else {
|
||||||
|
posting.companyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await sql`
|
||||||
|
UPDATE postings
|
||||||
|
SET title = ${posting.title},
|
||||||
|
description = ${posting.description},
|
||||||
|
employer_id = ${posting.employerId},
|
||||||
|
address = ${posting.address},
|
||||||
|
employment_type = ${posting.employmentType},
|
||||||
|
wage = ${posting.wage},
|
||||||
|
link = ${posting.link},
|
||||||
|
tag_ids = ${posting.tagIds},
|
||||||
|
updated_at = NOW(),
|
||||||
|
flyer_link = ${posting.flyerLink},
|
||||||
|
company_id = ${posting.companyId}
|
||||||
|
WHERE id = ${posting.id} RETURNING id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
return response[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePosting(id: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
DELETE
|
||||||
|
FROM postings
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyEmployers(
|
||||||
|
id: number
|
||||||
|
): Promise<{ company: Company; users: User[] }> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH company_data AS (SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
company_code AS "companyCode"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = ${id}),
|
||||||
|
user_data AS (SELECT id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
company_id as "companyId"
|
||||||
|
FROM users
|
||||||
|
WHERE "company_id" = ${id})
|
||||||
|
SELECT (SELECT row_to_json(company_data)
|
||||||
|
FROM company_data) AS company,
|
||||||
|
(SELECT json_agg(row_to_json(user_data))
|
||||||
|
FROM user_data) AS users;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
error(404, 'Company not found');
|
||||||
|
}
|
||||||
|
if (data[0].users) {
|
||||||
|
data[0].users.forEach(
|
||||||
|
(user: {
|
||||||
|
company: { id: unknown };
|
||||||
|
companyId: unknown;
|
||||||
|
createdAt: string | number | Date;
|
||||||
|
lastSignIn: string | number | Date;
|
||||||
|
}) => {
|
||||||
|
user.company = {
|
||||||
|
id: user.companyId
|
||||||
|
};
|
||||||
|
user.createdAt = new Date(user.createdAt);
|
||||||
|
user.lastSignIn = new Date(user.lastSignIn);
|
||||||
|
delete user.companyId;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: <Company>data[0].company,
|
||||||
|
users: <User[]>data[0].users
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyEmployersAndRequests(
|
||||||
|
id: number
|
||||||
|
): Promise<{ company: Company; employers: User[]; requests: User[] }> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH company_data AS (SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
company_code AS "companyCode"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = ${id}),
|
||||||
|
employer_data AS (SELECT id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
company_id as "companyId"
|
||||||
|
FROM users
|
||||||
|
WHERE "company_id" = ${id}),
|
||||||
|
request_data AS (SELECT id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
|
||||||
|
company_id as "companyId"
|
||||||
|
FROM users
|
||||||
|
WHERE "company_code" = (SELECT company_code FROM companies WHERE id = ${id}))
|
||||||
|
SELECT (SELECT row_to_json(company_data)
|
||||||
|
FROM company_data) AS company,
|
||||||
|
(SELECT json_agg(row_to_json(employer_data))
|
||||||
|
FROM employer_data) AS employers,
|
||||||
|
(SELECT json_agg(row_to_json(request_data))
|
||||||
|
FROM request_data) AS requests;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
error(404, 'Company not found');
|
||||||
|
}
|
||||||
|
if (data[0].employers) {
|
||||||
|
data[0].employers.forEach(
|
||||||
|
(user: {
|
||||||
|
company: { id: unknown };
|
||||||
|
companyId: unknown;
|
||||||
|
createdAt: string | number | Date;
|
||||||
|
lastSignIn: string | number | Date;
|
||||||
|
}) => {
|
||||||
|
user.company = {
|
||||||
|
id: user.companyId
|
||||||
|
};
|
||||||
|
user.createdAt = new Date(user.createdAt);
|
||||||
|
user.lastSignIn = new Date(user.lastSignIn);
|
||||||
|
delete user.companyId;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data[0].requests) {
|
||||||
|
data[0].requests.forEach(
|
||||||
|
(user: {
|
||||||
|
company: { id: unknown };
|
||||||
|
companyId: unknown;
|
||||||
|
createdAt: string | number | Date;
|
||||||
|
lastSignIn: string | number | Date;
|
||||||
|
}) => {
|
||||||
|
user.company = {
|
||||||
|
id: user.companyId
|
||||||
|
};
|
||||||
|
user.createdAt = new Date(user.createdAt);
|
||||||
|
user.lastSignIn = new Date(user.lastSignIn);
|
||||||
|
delete user.companyId;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: <Company>data[0].company,
|
||||||
|
employers: <User[]>data[0].employers,
|
||||||
|
requests: <User[]>data[0].requests
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEmployerFromCompany(companyId: number, userId: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET company_id = NULL,
|
||||||
|
company_code = NULL
|
||||||
|
WHERE id = ${userId};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addEmployerToCompany(companyId: number, userId: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET company_id = ${companyId}
|
||||||
|
WHERE id = ${userId};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPostings(searchQuery: string | null = null): Promise<Posting[]> {
|
||||||
|
const postings = await sql<Posting[]>`
|
||||||
|
SELECT p.id,
|
||||||
|
p.title,
|
||||||
|
p.description,
|
||||||
|
p.employer_id AS "employerId",
|
||||||
|
p.address,
|
||||||
|
p.employment_type AS "employmentType",
|
||||||
|
p.wage,
|
||||||
|
p.link,
|
||||||
|
p.tag_ids AS "tagIds",
|
||||||
|
p.created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
p.updated_at AT TIME ZONE 'UTC' AS "updatedAt",
|
||||||
|
p.flyer_link AS "flyerLink",
|
||||||
|
p.company_id AS "companyId",
|
||||||
|
c.name AS "companyName"
|
||||||
|
FROM postings p
|
||||||
|
LEFT JOIN companies c ON p.company_id = c.id
|
||||||
|
WHERE title ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
|
||||||
|
`;
|
||||||
|
postings.forEach((posting) => {
|
||||||
|
posting.company = <Company>{};
|
||||||
|
if (posting.companyName) {
|
||||||
|
posting.company.name = posting.companyName;
|
||||||
|
}
|
||||||
|
delete posting.companyName;
|
||||||
|
posting.tags = [];
|
||||||
|
|
||||||
|
posting.employmentType = EmploymentType[posting.employmentType as keyof typeof EmploymentType];
|
||||||
|
if (posting.tagIds) {
|
||||||
|
posting.tagIds?.forEach((tagId: number) => {
|
||||||
|
posting.tags.push({ id: tagId, displayName: null, createdAt: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
delete posting.tagIds;
|
||||||
|
});
|
||||||
|
return <Posting[]>(<unknown>postings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPosting(id: number): Promise<Posting> {
|
||||||
|
const data = await sql<Posting[]>`
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
employer_id AS "employerId",
|
||||||
|
address,
|
||||||
|
employment_type AS "employmentType",
|
||||||
|
wage,
|
||||||
|
link,
|
||||||
|
tag_ids AS "tagIds",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
|
||||||
|
flyer_link AS "flyerLink",
|
||||||
|
company_id AS "companyId"
|
||||||
|
FROM postings
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
const posting = data[0];
|
||||||
|
posting.tags = [];
|
||||||
|
posting.employmentType = EmploymentType[posting.employmentType as keyof typeof EmploymentType];
|
||||||
|
if (posting.tagIds) {
|
||||||
|
posting.tagIds?.forEach((tagId: number) => {
|
||||||
|
posting.tags.push({ id: tagId, displayName: null, createdAt: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
delete posting.tagIds;
|
||||||
|
|
||||||
|
if (posting.createdAt) {
|
||||||
|
posting.createdAt = new Date(posting.createdAt);
|
||||||
|
}
|
||||||
|
if (posting.updatedAt) {
|
||||||
|
posting.updatedAt = new Date(posting.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return posting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPostingWithCompanyUser(id: number): Promise<Posting> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH company_data AS (SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
created_at AS "createdAt"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = (SELECT company_id FROM postings WHERE id = ${id})),
|
||||||
|
user_data AS (SELECT username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName"
|
||||||
|
FROM users
|
||||||
|
WHERE "company_id" = (SELECT company_id FROM postings WHERE id = ${id})),
|
||||||
|
posting_data AS (SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
employer_id AS "employerId",
|
||||||
|
address,
|
||||||
|
employment_type AS "employmentType",
|
||||||
|
wage,
|
||||||
|
link,
|
||||||
|
tag_ids AS "tagIds",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
|
||||||
|
flyer_link AS "flyerLink"
|
||||||
|
FROM postings
|
||||||
|
WHERE id = ${id})
|
||||||
|
SELECT (SELECT row_to_json(company_data)
|
||||||
|
FROM company_data) AS company,
|
||||||
|
(SELECT row_to_json(user_data)
|
||||||
|
FROM user_data) AS user,
|
||||||
|
(
|
||||||
|
SELECT row_to_json(posting_data)
|
||||||
|
FROM posting_data
|
||||||
|
) AS posting;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
error(404, 'Posting not found');
|
||||||
|
}
|
||||||
|
const posting = <Posting>data[0].posting;
|
||||||
|
posting.company = <Company>data[0].company;
|
||||||
|
posting.employer = <User>data[0].user;
|
||||||
|
|
||||||
|
if (posting.createdAt) {
|
||||||
|
posting.createdAt = new Date(posting.createdAt);
|
||||||
|
}
|
||||||
|
if (posting.updatedAt) {
|
||||||
|
posting.updatedAt = new Date(posting.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return posting;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getEditApplicationPageData(applicationId: number): Promise<{ posting: Posting; application: Application }> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH application_data AS (
|
||||||
|
SELECT id AS application_id,
|
||||||
|
candidate_statement,
|
||||||
|
posting_id
|
||||||
|
FROM applications
|
||||||
|
WHERE id = ${applicationId}
|
||||||
|
),
|
||||||
|
company_data AS (
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
website,
|
||||||
|
created_at AS "createdAt"
|
||||||
|
FROM companies
|
||||||
|
WHERE id = (
|
||||||
|
SELECT company_id
|
||||||
|
FROM postings
|
||||||
|
WHERE id = (SELECT posting_id FROM application_data)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
user_data AS (
|
||||||
|
SELECT username,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
full_name AS "fullName"
|
||||||
|
FROM users
|
||||||
|
WHERE company_id = (
|
||||||
|
SELECT company_id
|
||||||
|
FROM postings
|
||||||
|
WHERE id = (SELECT posting_id FROM application_data)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
posting_data AS (
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
employer_id AS "employerId",
|
||||||
|
address,
|
||||||
|
employment_type AS "employmentType",
|
||||||
|
wage,
|
||||||
|
link,
|
||||||
|
tag_ids AS "tagIds",
|
||||||
|
created_at AT TIME ZONE 'UTC' AS "createdAt",
|
||||||
|
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
|
||||||
|
flyer_link AS "flyerLink"
|
||||||
|
FROM postings
|
||||||
|
WHERE id = (SELECT posting_id FROM application_data)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT row_to_json(company_data) FROM company_data) AS company,
|
||||||
|
(SELECT row_to_json(user_data) FROM user_data) AS user,
|
||||||
|
(SELECT row_to_json(posting_data) FROM posting_data) AS posting,
|
||||||
|
(SELECT row_to_json(application_data) FROM application_data) AS application;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data || !data[0]?.posting) {
|
||||||
|
error(404, 'Posting not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const posting = <Posting>data[0].posting;
|
||||||
|
posting.company = <Company>data[0].company;
|
||||||
|
posting.employer = <User>data[0].user;
|
||||||
|
|
||||||
|
if (posting.createdAt) {
|
||||||
|
posting.createdAt = new Date(posting.createdAt);
|
||||||
|
}
|
||||||
|
if (posting.updatedAt) {
|
||||||
|
posting.updatedAt = new Date(posting.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = <Application>{
|
||||||
|
id: data[0].application.application_id,
|
||||||
|
candidateStatement: data[0].application.candidate_statement
|
||||||
|
};
|
||||||
|
|
||||||
|
return { posting, application };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApplication(application: Application): Promise<number> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const response = await sql`
|
||||||
|
INSERT INTO applications (posting_id, user_id, candidate_statement, created_at)
|
||||||
|
VALUES (${application.postingId}, ${application.userId}, ${application.candidateStatement}, NOW()) RETURNING id;
|
||||||
|
`;
|
||||||
|
sendEmployerNotificationEmail(application.postingId).catch((err) => {
|
||||||
|
console.error('Failed to send employer notification email: ', err);
|
||||||
|
});
|
||||||
|
return response[0].id;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes('duplicate key value')) {
|
||||||
|
error(400, 'You have already applied for this job');
|
||||||
|
} else {
|
||||||
|
error(500, 'Internal server error while creating application');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApplication(id: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
DELETE
|
||||||
|
FROM applications
|
||||||
|
WHERE id = ${id};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApplicationWithUser(
|
||||||
|
applicationId: number,
|
||||||
|
userId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
DELETE
|
||||||
|
FROM applications
|
||||||
|
WHERE id = ${applicationId}
|
||||||
|
AND user_id = ${userId};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editApplication(application: Application): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE applications
|
||||||
|
SET candidate_statement = ${application.candidateStatement}
|
||||||
|
WHERE id = ${application.id};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApplications(postingId: number): Promise<Application[]> {
|
||||||
|
const data = await sql`
|
||||||
|
SELECT a.id,
|
||||||
|
a.candidate_statement AS "candidateStatement",
|
||||||
|
a.created_at AS "createdAt",
|
||||||
|
u.id AS "userId",
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.phone,
|
||||||
|
u.full_name AS "fullName"
|
||||||
|
FROM applications a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.posting_id = ${postingId};
|
||||||
|
`;
|
||||||
|
|
||||||
|
data.forEach((application) => {
|
||||||
|
application.createdAt = new Date(application.createdAt);
|
||||||
|
application.user = {
|
||||||
|
id: application.userId,
|
||||||
|
username: application.username,
|
||||||
|
email: application.email,
|
||||||
|
phone: application.phone,
|
||||||
|
fullName: application.fullName,
|
||||||
|
resume: fs.existsSync(
|
||||||
|
path.join(process.cwd(), 'uploads', 'resumes', `${application.userId}.pdf`)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
delete application.userId;
|
||||||
|
delete application.username;
|
||||||
|
delete application.email;
|
||||||
|
delete application.phone;
|
||||||
|
delete application.fullName;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Application[]>(<unknown>data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserCompanyId(userId: number, companyId: number): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET company_id = ${companyId},
|
||||||
|
company_code = (SELECT company_code FROM companies WHERE id = ${companyId})
|
||||||
|
WHERE id = ${userId};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationInfo(
|
||||||
|
postingId: number
|
||||||
|
): Promise<{ title: string; emails: string[] }> {
|
||||||
|
const data = await sql`
|
||||||
|
WITH posting_data AS (SELECT title, company_id
|
||||||
|
FROM postings
|
||||||
|
WHERE id = ${postingId}),
|
||||||
|
user_emails AS (SELECT email
|
||||||
|
FROM users
|
||||||
|
WHERE company_id = (SELECT company_id FROM posting_data))
|
||||||
|
SELECT (SELECT title FROM posting_data) AS title,
|
||||||
|
(SELECT json_agg(email) FROM user_emails) AS emails;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!data || !data[0]) {
|
||||||
|
error(404, 'Posting not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data[0].title,
|
||||||
|
emails: data[0].emails
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { getNotificationInfo } from '$lib/db/index.server';
|
||||||
|
|
||||||
|
// Create a transporter object using SMTP transport
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.EMAIL_HOST,
|
||||||
|
port: Number(process.env.EMAIL_PORT),
|
||||||
|
secure: true, // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function sendEmployerNotificationEmail(postingId: number) {
|
||||||
|
let info = await getNotificationInfo(postingId);
|
||||||
|
// Send mail with defined transport object
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `CareerConnect Notifications <${process.env.EMAIL_USER}>`,
|
||||||
|
// to: info.emails.join(', '), // EMAILING OF REAL COMPANIES DISABLED, UNCOMMENT TO ENABLE
|
||||||
|
to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||||
|
subject: 'New Application Received!',
|
||||||
|
text: `A new application has been received for the posting '${info.title}'!\n\nCheck it out at ${process.env.BASE_URL}/postings/${postingId}/manage/applications`
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { type Cookies, error } from '@sveltejs/kit';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { Company, User } from '$lib/types';
|
||||||
|
|
||||||
|
// TODO: Handle saving custom avatar uploads
|
||||||
|
export async function saveAvatar(user: User): Promise<void> {
|
||||||
|
const url = `https://ui-avatars.com/api/?background=random&format=svg&name=${user.fullName ? encodeURIComponent(user.fullName) : encodeURIComponent(user.username)}`;
|
||||||
|
const response = await fetch(url, { headers: { accept: 'image/svg+xml' } });
|
||||||
|
const avatar = await response.text();
|
||||||
|
const filePath = path.join('uploads', 'avatars', `${user.id}.svg`);
|
||||||
|
fs.writeFileSync(filePath, avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLogo(company: Company): Promise<void> {
|
||||||
|
// const url = `https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(company.name!)}`;
|
||||||
|
// const response = await fetch(url, { headers: { accept: 'image/svg+xml' } });
|
||||||
|
// const avatar = await response.text();
|
||||||
|
// const filePath = path.join('uploads', 'logos', `${company.id}.svg`);
|
||||||
|
const url = `https://img.logo.dev/${new URL(company.website!).hostname}`;
|
||||||
|
console.log(url);
|
||||||
|
const response = await fetch(url, { headers: { accept: 'image/jpeg' } });
|
||||||
|
const avatar = await response.buffer();
|
||||||
|
const filePath = path.join('uploads', 'logos', `${company.id}.jpg`);
|
||||||
|
fs.writeFileSync(filePath, avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLogo(company: Company): Promise<void> {
|
||||||
|
// const url = `https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(company.name!)}`;
|
||||||
|
// const response = await fetch(url, { headers: { accept: 'image/svg+xml' } });
|
||||||
|
// const avatar = await response.text();
|
||||||
|
const filePath = path.join('uploads', 'logos', `${company.id}.jpg`);
|
||||||
|
fs.rmSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: change to return null instead of -1
|
||||||
|
export function getUserPerms(cookies: Cookies): number {
|
||||||
|
if (process.env.JWT_SECRET === undefined) {
|
||||||
|
throw new Error('JWT_SECRET not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT = cookies.get('jwt');
|
||||||
|
if (JWT) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
|
||||||
|
if (typeof decoded === 'object' && 'perms' in decoded) {
|
||||||
|
return decoded['perms'];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserId(cookies: Cookies): number {
|
||||||
|
if (process.env.JWT_SECRET === undefined) {
|
||||||
|
throw new Error('JWT_SECRET not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT = cookies.get('jwt');
|
||||||
|
if (JWT) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
|
||||||
|
if (typeof decoded === 'object' && 'id' in decoded) {
|
||||||
|
return decoded['id'];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserCompanyId(cookies: Cookies): number | null {
|
||||||
|
if (process.env.JWT_SECRET === undefined) {
|
||||||
|
throw new Error('JWT_SECRET not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT = cookies.get('jwt');
|
||||||
|
if (JWT) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
|
||||||
|
if (typeof decoded === 'object' && 'companyId' in decoded) {
|
||||||
|
return decoded['companyId'];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
|
export function setJWT(cookies: Cookies, user: User) {
|
||||||
|
const payload = {
|
||||||
|
username: user.username,
|
||||||
|
perms: user.perms,
|
||||||
|
id: user.id,
|
||||||
|
companyId: user.companyId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.JWT_SECRET === undefined) {
|
||||||
|
throw new Error('JWT_SECRET not defined');
|
||||||
|
}
|
||||||
|
if (process.env.BASE_URL === undefined) {
|
||||||
|
throw new Error('BASE_URL not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secure: boolean = process.env.BASE_URL?.includes('https://');
|
||||||
|
|
||||||
|
const maxAge = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure: secure });
|
||||||
|
}
|
||||||
@@ -1 +1,52 @@
|
|||||||
export let userState = $state({ perms: 0b00000001 });
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import type { EmploymentType } from '$lib/types';
|
||||||
|
|
||||||
|
export let userState = $state({
|
||||||
|
perms: PERMISSIONS.VIEW,
|
||||||
|
username: null,
|
||||||
|
id: null,
|
||||||
|
companyId: null
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userPerms = PERMISSIONS.VIEW | PERMISSIONS.APPLY_FOR_JOBS;
|
||||||
|
export const employerPerms = PERMISSIONS.SUBMIT_POSTINGS | PERMISSIONS.MANAGE_EMPLOYERS;
|
||||||
|
export const adminPerms =
|
||||||
|
PERMISSIONS.MANAGE_TAGS |
|
||||||
|
PERMISSIONS.MANAGE_POSTINGS |
|
||||||
|
PERMISSIONS.MANAGE_USERS |
|
||||||
|
PERMISSIONS.MANAGE_COMPANIES;
|
||||||
|
|
||||||
|
export function telFormatter(initial: string) {
|
||||||
|
const num = initial.replace(/\D/g, '');
|
||||||
|
initial =
|
||||||
|
(num.length > 0 ? '(' : '') +
|
||||||
|
num.substring(0, 3) +
|
||||||
|
(num.length > 3 ? ') ' + num.substring(3, 6) : '') +
|
||||||
|
(num.length > 6 ? '-' + num.substring(6, 10) : '');
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCookieValue = (name: string) =>
|
||||||
|
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
|
||||||
|
|
||||||
|
export function updateUserState() {
|
||||||
|
const JWT = getCookieValue('jwt');
|
||||||
|
if (JWT !== '') {
|
||||||
|
const state = JSON.parse(atob(JWT.split('.')[1]));
|
||||||
|
userState.perms = state.perms;
|
||||||
|
userState.username = state.username;
|
||||||
|
userState.id = state.id;
|
||||||
|
userState.companyId = state.companyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function employmentTypeDisplayName(type: EmploymentType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'full_time':
|
||||||
|
return 'Full Time';
|
||||||
|
case 'part_time':
|
||||||
|
return 'Part Time';
|
||||||
|
case 'internship':
|
||||||
|
return 'Internship';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number | null;
|
||||||
|
username: string;
|
||||||
|
password: string | null;
|
||||||
|
perms: number;
|
||||||
|
createdAt: Date | null;
|
||||||
|
lastSignIn: Date | null;
|
||||||
|
active: boolean | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
fullName: string | null;
|
||||||
|
company: Company | null;
|
||||||
|
companyCode: string | null;
|
||||||
|
companyId: number | null | undefined;
|
||||||
|
resume: boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
website: string | null;
|
||||||
|
createdAt: Date | null;
|
||||||
|
companyCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Posting {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
employerId: number;
|
||||||
|
address: string;
|
||||||
|
employmentType: EmploymentType;
|
||||||
|
wage: string;
|
||||||
|
link: string;
|
||||||
|
tags: Tag[];
|
||||||
|
tagIds: number[] | null | undefined;
|
||||||
|
createdAt: Date | null;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
flyerLink: string | null;
|
||||||
|
company: Company;
|
||||||
|
companyId: number | null | undefined;
|
||||||
|
companyName: string | null | undefined;
|
||||||
|
employer: User | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
displayName: string | null;
|
||||||
|
createdAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EmploymentType {
|
||||||
|
full_time = 'full_time',
|
||||||
|
part_time = 'part_time',
|
||||||
|
internship = 'internship'
|
||||||
|
// contract = 'Contract',
|
||||||
|
// temporary = 'Temporary',
|
||||||
|
// seasonal = 'Seasonal'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Application {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
postingId: number;
|
||||||
|
postingTitle: string | null;
|
||||||
|
candidateStatement: string;
|
||||||
|
createdAt: Date;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center" style="padding-top: 32px">
|
||||||
|
<h1 class="text-9xl font-bold">
|
||||||
|
{$page.status}
|
||||||
|
</h1>
|
||||||
|
<h1>That's an error</h1>
|
||||||
|
{#if $page.status === 404}
|
||||||
|
<p>We cant seem to find the page you are looking for.</p>
|
||||||
|
<p>The address may be mistyped, or the page may have moved or been deleted.</p>
|
||||||
|
{/if}
|
||||||
|
{#if $page.status === 403}
|
||||||
|
<p>You dont have access to this page!</p>
|
||||||
|
<p>Please contact your admin if you think this is a mistake</p>
|
||||||
|
{/if}
|
||||||
|
{#if $page.status === 401}
|
||||||
|
<p>You must be signed-in to view this page!</p>
|
||||||
|
{/if}
|
||||||
|
{#if $page.status === 500}
|
||||||
|
<p>This one is on our end...</p>
|
||||||
|
<p>We are working to resolve this as fast as possible.</p>
|
||||||
|
{/if}
|
||||||
|
{#if $page.status === 400}
|
||||||
|
<p>Invalid request!</p>
|
||||||
|
<p>Make sure you have correctly inputted all information.</p>
|
||||||
|
{/if}
|
||||||
|
{#if $page.status !== 404 && $page.status !== 403 && $page.status !== 401 && $page.status !== 500 && $page.status !== 400}
|
||||||
|
<p>An unexpected error has occurred.</p>
|
||||||
|
<p>Please try again later</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-8">Error: {$page.error?.message}</p>
|
||||||
|
</div>
|
||||||
+172
-45
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { userState } from '$lib/shared.svelte';
|
import { updateUserState, userState } from '$lib/shared.svelte';
|
||||||
// import { userState } from '$lib/shared.svelte';
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
let currentTheme: string = $state('');
|
let currentTheme: string = $state('');
|
||||||
|
|
||||||
@@ -12,14 +13,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme: string) {
|
function setTheme(theme: string) {
|
||||||
const one_year = 60 * 60 * 24 * 365;
|
const oneYear = 60 * 60 * 24 * 365;
|
||||||
document.cookie = `theme=${theme}; max-age=${one_year}; path=/`;
|
document.cookie = `theme=${theme}; max-age=${oneYear}; path=/`;
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
currentTheme = theme;
|
currentTheme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCookieValue = (name: String) =>
|
function toggleMenu() {
|
||||||
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
|
var menu = document.getElementById('mobile-menu')!;
|
||||||
|
if (menu.style.display === 'block') {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
menu.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const savedTheme = document.documentElement.getAttribute('data-theme');
|
const savedTheme = document.documentElement.getAttribute('data-theme');
|
||||||
@@ -31,63 +38,183 @@
|
|||||||
setTheme(currentTheme);
|
setTheme(currentTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
const JWT = getCookieValue('jwt');
|
updateUserState();
|
||||||
if (JWT !== '') {
|
|
||||||
userState.perms = JSON.parse(atob(JWT.split('.')[1])).perms;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const largeScreen = new MediaQuery('min-width: 640px');
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,arrow_drop_up,calendar_today,call,check,close,cloud_upload,dark_mode,delete,description,edit,group,info,light_mode,login,mail,menu,open_in_new,person,search,sell,store,upload,visibility,visibility_off,work"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,dark_mode,group,light_mode,login,sell,work"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="bottom-border mx-2 flex h-16 justify-between p-4 align-middle">
|
<div class="flex min-h-screen flex-col">
|
||||||
<nav class="pt-1">
|
<nav class="bottom-border nav-bg sticky top-0 z-50 align-middle">
|
||||||
<a href="/" class="hover-bg-color mr-1 rounded-md px-2 pb-2 pt-1.5">
|
<div class="flex h-14 justify-between p-3">
|
||||||
|
<div class="pt-1">
|
||||||
|
<a
|
||||||
|
class="hover-bg-color mr-1 rounded-md px-2 pb-2 pt-1.5"
|
||||||
|
href="/"
|
||||||
|
onclick={(document.getElementById('mobile-menu').style.display = 'none')}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
class="inline-block"
|
|
||||||
src="/mdevtriangle.svg"
|
|
||||||
alt="MarinoDev Logo"
|
alt="MarinoDev Logo"
|
||||||
|
class="inline-block"
|
||||||
height="24"
|
height="24"
|
||||||
|
src="/mdevtriangle.svg"
|
||||||
width="24"
|
width="24"
|
||||||
/>
|
/>
|
||||||
|
<!-- <div class="inline-block text-sm">Home</div>-->
|
||||||
</a>
|
</a>
|
||||||
<a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>
|
<!-- <a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>-->
|
||||||
{#if (userState.perms & 0b00000001) !== 0}
|
{#if largeScreen.current}
|
||||||
<a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a>
|
{#if (userState.perms & PERMISSIONS.VIEW) > 0}
|
||||||
|
<a href="/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Postings</a>
|
||||||
|
<a href="/companies" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Companies</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if (userState.perms & 0b00001000) !== 0}
|
{#if (userState.perms & (PERMISSIONS.MANAGE_POSTINGS | PERMISSIONS.MANAGE_TAGS | PERMISSIONS.MANAGE_USERS)) > 0}
|
||||||
<a href="/administration/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm"
|
<a
|
||||||
>Administration</a
|
href={(userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0
|
||||||
|
? '/admin/postings'
|
||||||
|
: (userState.perms & PERMISSIONS.MANAGE_USERS) > 0
|
||||||
|
? '/admin/users'
|
||||||
|
: // TODO: Implement tags
|
||||||
|
// : (userState.perms & PERMISSIONS.MANAGE_TAGS) > 0
|
||||||
|
// ? '/admin/tags'
|
||||||
|
(userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0
|
||||||
|
? '/admin/companies'
|
||||||
|
: '/admin'}
|
||||||
|
class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Administration</a
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
{/if}
|
||||||
<div>
|
|
||||||
<button onclick={toggleTheme} class="">
|
|
||||||
<span class="material-symbols-outlined rounded-full p-1 dark:invisible">
|
|
||||||
{'light_mode'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button onclick={toggleTheme} class="">
|
|
||||||
<span class="material-symbols-outlined invisible rounded-full p-1 dark:visible">
|
|
||||||
{'dark_mode'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() =>
|
|
||||||
(window.location.href = (userState.perms & 0b00000010) !== 0 ? '/account' : '/signin')}
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined rounded-full p-1">
|
|
||||||
{(userState.perms & 0b00000010) !== 0 ? 'account_circle' : 'login'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="material-symbols-outlined hover-bg-color rounded-full p-1"
|
||||||
|
onclick={toggleTheme}
|
||||||
|
>
|
||||||
|
{currentTheme === 'light' ? 'light_mode' : 'dark_mode'}
|
||||||
|
</button>
|
||||||
|
{#if largeScreen.current}
|
||||||
|
<a
|
||||||
|
class="hover-bg-color inline-block h-min rounded p-1 pr-2 align-top"
|
||||||
|
href={userState.id !== null ? '/account' : '/signin'}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined inline-block h-min align-top">
|
||||||
|
{userState.id !== null ? 'account_circle' : 'login'}
|
||||||
|
</span>
|
||||||
|
<span class="inline-block h-min align-top text-sm">
|
||||||
|
{userState.id !== null ? 'Account' : 'Sign-in'}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="material-symbols-outlined hover-bg-color rounded-full p-1"
|
||||||
|
onclick={toggleMenu}>menu</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mobile-menu" class="hidden">
|
||||||
|
{#if (userState.perms & PERMISSIONS.VIEW) > 0}
|
||||||
|
<a href="/postings" class="hover-bg-color mr-1 block px-3 py-2 text-sm" onclick={toggleMenu}
|
||||||
|
>Postings</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/companies"
|
||||||
|
class="hover-bg-color mr-1 block px-3 py-2 text-sm"
|
||||||
|
onclick={toggleMenu}>Companies</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if (userState.perms & (PERMISSIONS.MANAGE_POSTINGS | PERMISSIONS.MANAGE_TAGS | PERMISSIONS.MANAGE_USERS)) > 0}
|
||||||
|
<a
|
||||||
|
href={(userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0
|
||||||
|
? '/admin/postings'
|
||||||
|
: (userState.perms & PERMISSIONS.MANAGE_USERS) > 0
|
||||||
|
? '/admin/users'
|
||||||
|
: // TODO: Implement tags
|
||||||
|
// : (userState.perms & PERMISSIONS.MANAGE_TAGS) > 0
|
||||||
|
// ? '/admin/tags'
|
||||||
|
(userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0
|
||||||
|
? '/admin/companies'
|
||||||
|
: '/admin'}
|
||||||
|
class="hover-bg-color mr-1 block px-3 py-2 text-sm"
|
||||||
|
onclick={toggleMenu}>Administration</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
class="hover-bg-color block h-min p-1 px-2 align-top"
|
||||||
|
href={userState.id !== null ? '/account' : '/signin'}
|
||||||
|
onclick={toggleMenu}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined h-min align-top">
|
||||||
|
{userState.id !== null ? 'account_circle' : 'login'}
|
||||||
|
</span>
|
||||||
|
<span class="h-min align-top text-sm">
|
||||||
|
{userState.id !== null ? 'Account' : 'Sign-in'}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex-grow">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="top-border bg-color footer {largeScreen.current
|
||||||
|
? 'flex'
|
||||||
|
: 'block'} h-min w-full justify-between p-2 text-center"
|
||||||
|
>
|
||||||
|
<div class="{largeScreen.current ? 'inline-block' : 'block'} text-left align-top">
|
||||||
|
{#if largeScreen.current}
|
||||||
|
<div class="inline-block pr-3">
|
||||||
|
<p class="font-semibold">Drake Marino:</p>
|
||||||
|
<a class="hyperlink-color hyperlink-underline" href="mailto:drake@marinodev.com"
|
||||||
|
>drake@marinodev.com</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="inline-block">
|
||||||
|
<p class="font-semibold">Chetan Malkan:</p>
|
||||||
|
<a class="hyperlink-color hyperlink-underline" href="mailto:chetan@marinodev.com"
|
||||||
|
>chetan@marinodev.com</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!--{#if largeScreen.current}-->
|
||||||
|
<div class="{largeScreen.current ? 'inline-block' : 'block'} align-top font-semibold">
|
||||||
|
<div class="font-semibold">MarinoDev</div>
|
||||||
|
<div class="font-normal">2025</div>
|
||||||
|
</div>
|
||||||
|
<!--{/if}-->
|
||||||
|
<div class="{largeScreen.current ? 'inline-block text-right' : 'block text-left'} align-top">
|
||||||
|
{#if largeScreen.current}
|
||||||
|
<div class="font-semibold">Source Code:</div>
|
||||||
|
<a
|
||||||
|
class="hyperlink-color hyperlink-underline"
|
||||||
|
href="https://git.marinodev.com/MarinoDev/FBLA25"
|
||||||
|
>https://git.marinodev.com/MarinoDev/FBLA25</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!--<h3 class="pt-16 font-semibold">Contact Information:</h3>-->
|
||||||
{@render children()}
|
<!--<p>Drake Marino:</p>-->
|
||||||
</div>
|
<!--<a href="mailto:drake@marinodev.com" class="hyperlink-color hyperlink-underline"-->
|
||||||
|
<!-- >drake@marinodev.com</a-->
|
||||||
|
<!-->-->
|
||||||
|
<!--<p>Chetan Malkan:</p>-->
|
||||||
|
<!--<a href="mailto:chetan@marinodev.com" class="hyperlink-color hyperlink-underline"-->
|
||||||
|
<!-- >chetan@marinodev.com</a-->
|
||||||
|
<!-->-->
|
||||||
|
<!--<h3 class="pt-8 font-semibold">Source Code:</h3>-->
|
||||||
|
<!--<a href="https://git.marinodev.com/MarinoDev/FBLA25" class="hyperlink-color hyperlink-underline"-->
|
||||||
|
<!-- >https://git.marinodev.com/MarinoDev/FBLA25</a-->
|
||||||
|
<!-->-->
|
||||||
|
|||||||
@@ -1,2 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { updateUserState } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateUserState();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container flex flex-col items-center justify-center px-4 py-16">
|
||||||
|
<img alt="MarinoDev Logo" src="/mdevtriangle.svg" class="mb-8 h-32 w-32" />
|
||||||
|
|
||||||
|
<h1 class="mb-4 text-center text-5xl font-bold md:text-7xl">CareerConnect</h1>
|
||||||
|
<p class="low-emphasis-text mb-2 text-center text-xl italic md:text-2xl">
|
||||||
|
Connecting Students with Opportunities
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="low-emphasis-text mt-6 max-w-2xl text-center text-lg md:text-xl">
|
||||||
|
CareerConnect bridges the gap between students and employers by providing an accessible,
|
||||||
|
intuitive platform for internship and co-op discovery.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-10 flex flex-col gap-4 md:flex-row">
|
||||||
|
<a
|
||||||
|
href="/postings"
|
||||||
|
class="dull-primary-bg-color rounded-xl px-6 py-3 text-lg font-medium shadow transition"
|
||||||
|
>
|
||||||
|
Browse Job Postings
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/register"
|
||||||
|
class="dull-primary-border-color rounded-xl border px-6 py-3 text-lg font-medium shadow transition"
|
||||||
|
>
|
||||||
|
Create an Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-12 text-sm text-gray-500">
|
||||||
|
Already have an account? <a href="/signin" class="text-blue-600 underline">Sign in here</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<h1>About</h1>
|
<h1>About</h1>
|
||||||
<p>
|
<p>
|
||||||
This is my submission for the 2025 FBLA Website Coding & Development event. It was built using
|
This is my submission for the 2025 FBLA Website Coding & Development event. It was built using
|
||||||
<a href="https://svelte.dev/docs/kit/introduction" class="text-blue-600">SvelteKit</a>.
|
<a class="text-blue-600" href="https://svelte.dev/docs/kit/introduction">SvelteKit</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { deleteApplicationWithUser, getUserWithCompanyAndApplications } from '$lib/db/index.server';
|
||||||
|
import { getUserId } from '$lib/index.server';
|
||||||
|
import { type Actions, fail } from '@sveltejs/kit';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
const id = getUserId(cookies);
|
||||||
|
const userData = await getUserWithCompanyAndApplications(id);
|
||||||
|
const resumeExists = fs.existsSync(path.join(process.cwd(), 'uploads', 'resumes', `${id}.pdf`));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userData,
|
||||||
|
resumeExists
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
deleteApplication: async ({ url, cookies }) => {
|
||||||
|
const id = parseInt(url.searchParams.get('id')!);
|
||||||
|
try {
|
||||||
|
await deleteApplicationWithUser(id, getUserId(cookies));
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uploadResume: async ({ request, cookies }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('resume') as File;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
fail(400, { message: 'invalid' });
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
`uploads/resumes/${getUserId(cookies)}.pdf`,
|
||||||
|
Buffer.from(await file.arrayBuffer())
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { Company } from '$lib/types';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
let applicationToDelete: number = $state(0);
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let resumeState = $state(data.resumeExists);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!document.cookie.includes('jwt=')) {
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
|
if (window.location.search.includes('refresh')) {
|
||||||
|
location.replace(location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
const acc = document.getElementsByClassName('accordion');
|
||||||
|
|
||||||
|
for (let i = 0; i < acc.length; i++) {
|
||||||
|
acc[i].addEventListener('click', function (this: HTMLElement) {
|
||||||
|
this.classList.toggle('active');
|
||||||
|
this.children[1].innerHTML = this.classList.contains('active')
|
||||||
|
? 'arrow_drop_up'
|
||||||
|
: 'arrow_drop_down';
|
||||||
|
|
||||||
|
/* Toggle between hiding and showing the active panel */
|
||||||
|
let panel = this.nextElementSibling as HTMLElement;
|
||||||
|
if (panel.style.display === 'flex') {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function avatarFallback(e: Event) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${data.user.fullName ? encodeURIComponent(data.user.fullName) : encodeURIComponent(data.user.username)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoFallback(e: Event, company: Company) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(company.name!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// function logoFallback(e: Event) {
|
||||||
|
// (e.target as HTMLImageElement).src =
|
||||||
|
// `https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.user.company!.name!)}`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpload() {
|
||||||
|
document.getElementById('uploadModal')!.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUpload() {
|
||||||
|
document.getElementById('uploadModal')!.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: FileList | undefined | null = $state();
|
||||||
|
let file: File | undefined | null = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (files) {
|
||||||
|
if (files[files.length - 1]?.type == 'application/pdf') {
|
||||||
|
file = files[files.length - 1];
|
||||||
|
console.log(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function dropHandler(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.dataTransfer?.items) {
|
||||||
|
const items = event.dataTransfer.items;
|
||||||
|
if (items[0].kind === 'file') {
|
||||||
|
const upload = items[0].getAsFile();
|
||||||
|
if (upload?.type !== 'application/pdf') {
|
||||||
|
console.error('Invalid file type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file = upload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOverHandler(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
alert('Please select a file first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('resume', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/uploadResume', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('File uploaded successfully:', result);
|
||||||
|
closeUpload();
|
||||||
|
resumeState = true;
|
||||||
|
} else {
|
||||||
|
console.error('Upload failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeScreen = new MediaQuery('min-width: 640px');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content my-2 flex">
|
||||||
|
{#if largeScreen.current}
|
||||||
|
<div class="elevated separator-borders mr-2 inline-block h-min min-w-max rounded align-top">
|
||||||
|
<div class="inline-block p-4">
|
||||||
|
<img
|
||||||
|
alt="User avatar"
|
||||||
|
class="mb-2 inline-block rounded-lg"
|
||||||
|
height="240"
|
||||||
|
id="avatar"
|
||||||
|
onerror={avatarFallback}
|
||||||
|
src="/uploads/avatars/{data.user.id
|
||||||
|
? data.user.id
|
||||||
|
: 'default'}.svg?timestamp=${Date.now()}"
|
||||||
|
width="240"
|
||||||
|
/>
|
||||||
|
{#if data.user.fullName}
|
||||||
|
<h2 class="text-center font-semibold">{data.user.fullName}</h2>
|
||||||
|
<h3 class="text-center">{data.user.username}</h3>
|
||||||
|
{/if}
|
||||||
|
{#if !data.user.fullName}
|
||||||
|
<h2 class="text-center font-semibold">{data.user.username}</h2>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if data.user.email}
|
||||||
|
<div class="top-border p-3">
|
||||||
|
<span class="material-symbols-outlined align-middle">mail</span>
|
||||||
|
<a class="hover-hyperlink" href="mailto:{data.user.email}">{data.user.email}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.user.phone}
|
||||||
|
<div class="top-border p-3">
|
||||||
|
<span class="material-symbols-outlined align-middle">call</span>
|
||||||
|
<a class="hover-hyperlink" href="tel:{data.user.phone}">{data.user.phone}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.user.createdAt}
|
||||||
|
<div class="top-border p-3">
|
||||||
|
<span class="material-symbols-outlined align-middle">calendar_today</span>
|
||||||
|
Joined {data.user.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="inline-block w-full">
|
||||||
|
<div class="elevated separator-borders h-min w-full rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">User Details</div>
|
||||||
|
<div class="flex">
|
||||||
|
<a class="dull-primary-bg-color my-2 rounded-md px-2.5 py-1" href="/account/settings"
|
||||||
|
>Edit account</a
|
||||||
|
>
|
||||||
|
<button class="danger-border-color m-2 rounded-md border px-2.5 py-1" onclick={signOut}
|
||||||
|
>Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="font-semibold">
|
||||||
|
ID: <span class="font-normal">{data.user.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
Username: <span class="font-normal">{data.user.username}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
Full Name: <span class="font-normal">{data.user.fullName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
Account active: <span class="material-symbols-outlined align-middle"
|
||||||
|
>{data.user.active ? 'check' : 'cancel'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
Last sign-in: <span class="font-normal"
|
||||||
|
>{data.user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!--{#if !data.user.company?.id}-->
|
||||||
|
<!-- <div class="pb-2 font-semibold">-->
|
||||||
|
<!-- Employer company: <span class="font-normal">N/A</span>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!--{/if}-->
|
||||||
|
{#if data.user.company?.id}
|
||||||
|
<div class="font-semibold">
|
||||||
|
Employer company:
|
||||||
|
<div class="top-border mt-2 p-3 align-top">
|
||||||
|
<img
|
||||||
|
id="logo"
|
||||||
|
class="mb-2 inline-block rounded"
|
||||||
|
src="/uploads/logos/{data.user.company.id}.jpg?timestamp=${Date.now()}"
|
||||||
|
alt="Company Logo"
|
||||||
|
onerror={(e) => logoFallback(e, data.user.company!)}
|
||||||
|
height="32"
|
||||||
|
width="32"
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<div>{data.user.company.name}</div>
|
||||||
|
<div class="max-char-length font-normal">{data.user.company.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Résumé:</div>
|
||||||
|
{#if resumeState}
|
||||||
|
<a class="pb-2" href="/uploads/resumes/{data.user.id}.pdf" target="_blank">
|
||||||
|
<button class="dull-primary-bg-color my-1 mr-2 rounded-md px-2.5 py-1"
|
||||||
|
><span class="material-symbols-outlined align-middle">open_in_new</span> View résumé
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<button class="my-1 mb-2 rounded-md border px-2.5 py-1" onclick={openUpload}
|
||||||
|
><span class="material-symbols-outlined align-middle">upload</span> Upload new version
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<!-- <div class="">No résumé submitted.</div>-->
|
||||||
|
<button class="dull-primary-bg-color mb-1 rounded-md px-2.5 py-1" onclick={openUpload}
|
||||||
|
><span class="material-symbols-outlined align-middle">upload</span> Upload
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="top-border pt-2 font-semibold">
|
||||||
|
Permissions: <span class="font-normal">{data.user.perms}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if data.applications}
|
||||||
|
<div class="elevated separator-borders m-2 ml-0 inline-block h-min w-full rounded">
|
||||||
|
<div class="p-3 font-semibold">Pending applications</div>
|
||||||
|
{#each data.applications as application}
|
||||||
|
<button class="top-border accordion flex justify-between p-2">
|
||||||
|
<div class="inline-block">
|
||||||
|
<div class="text-left font-semibold">
|
||||||
|
Applied to: <a
|
||||||
|
class="hover-hyperlink font-normal"
|
||||||
|
href="/postings/{application.postingId}">{application.postingTitle}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="text-left font-semibold">
|
||||||
|
Applied on: <span class="font-normal"
|
||||||
|
>{application.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="material-symbols-outlined pr-3 pt-3 align-top">arrow_drop_down</div>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden justify-between p-2">
|
||||||
|
<div class="inline-block">
|
||||||
|
<!-- <h2>Candidate Statement</h2>-->
|
||||||
|
<h3 class="low-emphasis-text">
|
||||||
|
Why do you believe you are the best fit for this role?
|
||||||
|
</h3>
|
||||||
|
<p class="whitespace-pre-wrap">{application.candidateStatement}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mr-3 mt-3 min-w-max">
|
||||||
|
<a
|
||||||
|
href="account/editapplication?id={application.id}"
|
||||||
|
class="material-symbols-outlined hyperlink-color mr-3">edit</a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="material-symbols-outlined danger-color inline-block"
|
||||||
|
onclick={() => {
|
||||||
|
applicationToDelete = application.id;
|
||||||
|
openConfirm();
|
||||||
|
}}
|
||||||
|
>delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="modal" id="deleteConfirmModal" method="POST">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Are you sure?</h2>
|
||||||
|
<button class="material-symbols-outlined" onclick={closeConfirm} type="button">close</button>
|
||||||
|
</div>
|
||||||
|
<p>This will permanently delete this application. This action cannot be undone.</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button
|
||||||
|
class="danger-bg-color rounded px-2 py-1"
|
||||||
|
formaction="?/deleteApplication&id={applicationToDelete}"
|
||||||
|
type="submit"
|
||||||
|
>Delete application
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
onclick={closeConfirm}
|
||||||
|
type="button"
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form class="modal" id="uploadModal" method="POST" onsubmit={handleSubmit}>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Résumé Upload</h2>
|
||||||
|
<button class="material-symbols-outlined" onclick={closeUpload} type="button">close</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="dull-primary-border-color rounded-lg border-2 border-dashed"
|
||||||
|
ondragover={dragOverHandler}
|
||||||
|
ondrop={dropHandler}
|
||||||
|
role="region"
|
||||||
|
>
|
||||||
|
<label class="cursor-pointer p-4" for="resume">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="material-symbols-outlined icon-48">cloud_upload</span>
|
||||||
|
<h3>Drag & drop your résumé here</h3>
|
||||||
|
<p class="">
|
||||||
|
or <span class="hyperlink-color hyperlink-underline">click here to browse.</span>
|
||||||
|
</p>
|
||||||
|
<input accept=".pdf" bind:files class="hidden" id="resume" type="file" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">{file?.name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button class="dull-primary-bg-color rounded px-2 py-1" type="submit">Submit</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
onclick={closeUpload}
|
||||||
|
type="button"
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { editApplication, getEditApplicationPageData } from '$lib/db/index.server';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import type { Application } from '$lib/types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||||
|
if (!url.searchParams.has('id')) {
|
||||||
|
error(400, 'Missing required parameter: id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission check (apply perm)
|
||||||
|
const id = parseInt(url.searchParams.get('id')!);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.APPLY_FOR_JOBS) > 0) {
|
||||||
|
return await getEditApplicationPageData(id);
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
// Application submission
|
||||||
|
submit: async ({ request, cookies, url }) => {
|
||||||
|
// Permission check (apply perm)
|
||||||
|
if (!((getUserPerms(cookies) & PERMISSIONS.APPLY_FOR_JOBS) > 0)) {
|
||||||
|
return fail(403, { errorMessage: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const candidateStatement = data.get('candidateStatement')?.toString().trim();
|
||||||
|
|
||||||
|
// Statement data validation
|
||||||
|
if (!candidateStatement || candidateStatement === '') {
|
||||||
|
return fail(400, { errorMessage: 'Candidate statement is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to DB and redirect
|
||||||
|
if (!url.searchParams.has('id')) {
|
||||||
|
error(400, 'Missing required parameter: id');
|
||||||
|
}
|
||||||
|
const id: number = parseInt(url.searchParams.get('id')!);
|
||||||
|
|
||||||
|
await editApplication(<Application>{ id, candidateStatement });
|
||||||
|
redirect(301, `/postings`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { employmentTypeDisplayName } from '$lib/shared.svelte';
|
||||||
|
import type { Posting } from '$lib/types';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function logoFallback(e: Event, posting: Posting) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeScreen = new MediaQuery('min-width: 1024px');
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet jobDetails()}
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders my-2 h-min rounded p-4 {largeScreen.current
|
||||||
|
? 'mr-2 inline-block w-1/2'
|
||||||
|
: 'block w-full'}"
|
||||||
|
>
|
||||||
|
<div class="bottom-border elevated-bg flex justify-between pb-2">
|
||||||
|
<div class="inline-block">
|
||||||
|
<img
|
||||||
|
alt="Company Logo"
|
||||||
|
class="inline-block rounded"
|
||||||
|
height="64"
|
||||||
|
onerror={(e) => logoFallback(e, data.posting)}
|
||||||
|
src="/uploads/logos/{data.posting?.company.id}.jpg"
|
||||||
|
width="64"
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<h1>{data.posting.title}</h1>
|
||||||
|
<h2>Company: {data.posting.company.name}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scrollbar-on-elevated overflow-y-scroll">
|
||||||
|
<h2 class="pt-2 font-semibold">Contact</h2>
|
||||||
|
<p>{data.posting.employer?.fullName} ({data.posting.employer?.username})</p>
|
||||||
|
<a class="hover-hyperlink" href="mailto:{data.posting.employer?.email}"
|
||||||
|
>{data.posting.employer?.email}</a
|
||||||
|
>
|
||||||
|
<a class="hover-hyperlink" href="tel:{data.posting.employer?.phone}"
|
||||||
|
>{data.posting.employer?.phone}</a
|
||||||
|
>
|
||||||
|
<h2 class="pt-2 font-semibold">Details</h2>
|
||||||
|
{#if data.posting.employmentType}
|
||||||
|
<p>{employmentTypeDisplayName(data.posting.employmentType)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.address}
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={data.posting.address}"
|
||||||
|
class="block"
|
||||||
|
>Address: <span class="hover-hyperlink break-words">{data.posting.address}</span></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.wage}
|
||||||
|
<p>Wage: {data.posting.wage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.createdAt}
|
||||||
|
<p>Posted: {data.posting.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.link}
|
||||||
|
<a href={data.posting.link} class="block"
|
||||||
|
>More information: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{data.posting.link}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.flyerLink}
|
||||||
|
<a href={data.posting.flyerLink} class="block"
|
||||||
|
>Flyer: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{data.posting.flyerLink}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<h2 class="pt-2 font-semibold">Job Description</h2>
|
||||||
|
<p class="whitespace-pre-wrap break-words">{data.posting.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content {largeScreen.current ? 'flex' : 'block'}">
|
||||||
|
{#if largeScreen.current}
|
||||||
|
{@render jobDetails()}
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders my-2 h-min rounded {largeScreen.current
|
||||||
|
? 'inline-block w-1/2'
|
||||||
|
: 'block w-full'}"
|
||||||
|
>
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Edit Application</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Why do you believe you are the best fit for this role? <span class="text-red-500">*</span>
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="candidateStatement"
|
||||||
|
name="candidateStatement"
|
||||||
|
placeholder="Answer here"
|
||||||
|
required
|
||||||
|
rows="4">{data.application.candidateStatement}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Your account information and résumé (if supplied) will be submitted along with this
|
||||||
|
application. If there is any other information you would like the employer to know, please
|
||||||
|
add it in the box above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
|
||||||
|
formaction="?/submit&id={data.application.id}"
|
||||||
|
type="submit"
|
||||||
|
>Save application
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{#if !largeScreen.current}
|
||||||
|
{@render jobDetails()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { deleteUser, getUserWithCompany, updateUser } from '$lib/db/index.server';
|
||||||
|
import { type Actions, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { getUserId } from '$lib/index.server';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
const id = getUserId(cookies);
|
||||||
|
return { user: await getUserWithCompany(id) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies }) => {
|
||||||
|
const id = getUserId(cookies);
|
||||||
|
const data = await request.formData();
|
||||||
|
const username = data.get('username')?.toString().trim();
|
||||||
|
let email: string | undefined | null = data.get('email')?.toString().trim();
|
||||||
|
let phone: string | undefined | null = data.get('phone')?.toString().trim();
|
||||||
|
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
|
||||||
|
let companyCode: string | undefined | null = data
|
||||||
|
.get('companyCode')
|
||||||
|
?.toString()
|
||||||
|
.toUpperCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (phone === '' || phone == undefined) phone = null;
|
||||||
|
if (companyCode === '' || companyCode == undefined) companyCode = null;
|
||||||
|
|
||||||
|
if (email && !email.includes('@')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone && !phone.match(/\((\d{3})\) \d{3}-\d{4}/)) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid phone number' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username && username !== '' && fullName && fullName !== '' && email && email !== '') {
|
||||||
|
try {
|
||||||
|
await updateUser(<User>{
|
||||||
|
id: id,
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
fullName: fullName,
|
||||||
|
companyCode: companyCode
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
return redirect(301, `/account`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: async ({ cookies }) => {
|
||||||
|
const id = getUserId(cookies);
|
||||||
|
try {
|
||||||
|
await deleteUser(id);
|
||||||
|
cookies.delete('jwt', { path: '/' });
|
||||||
|
return redirect(301, `/signin`);
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { telFormatter } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
let permsAccordions: boolean[] = [false, false, false];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!document.cookie.includes('jwt=')) {
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
|
|
||||||
|
let acc = document.getElementsByClassName('accordion');
|
||||||
|
for (let i = 0; i < acc.length; i++) {
|
||||||
|
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
|
||||||
|
const target = event?.target as HTMLElement | null;
|
||||||
|
if (target?.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
|
||||||
|
return; // Do nothing if it's the checkbox
|
||||||
|
}
|
||||||
|
|
||||||
|
this.classList.toggle('active');
|
||||||
|
permsAccordions[i] = !permsAccordions[i];
|
||||||
|
|
||||||
|
let panel = this.nextElementSibling as HTMLElement;
|
||||||
|
if (panel.style.display === 'block') {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectAllCheckbox = acc[i].querySelector('.select-all') as HTMLInputElement;
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
let checkboxes =
|
||||||
|
this.parentElement!.parentElement!.parentElement!.nextElementSibling!.querySelectorAll(
|
||||||
|
'.permCheckbox'
|
||||||
|
) as NodeListOf<HTMLInputElement>;
|
||||||
|
checkboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = selectAllCheckbox.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let permCheckboxes = acc[i].nextElementSibling!.querySelectorAll(
|
||||||
|
'.permCheckbox'
|
||||||
|
) as NodeListOf<HTMLInputElement>;
|
||||||
|
|
||||||
|
permCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener('change', function () {
|
||||||
|
let allChecked = true;
|
||||||
|
let someChecked = false;
|
||||||
|
permCheckboxes.forEach((cb) => {
|
||||||
|
if (cb.checked) {
|
||||||
|
someChecked = true;
|
||||||
|
} else {
|
||||||
|
allChecked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectAllCheckbox.checked = allChecked;
|
||||||
|
selectAllCheckbox.indeterminate = !allChecked && someChecked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal: HTMLElement = document.getElementById('deleteConfirmModal') as HTMLElement;
|
||||||
|
window.onclick = function (event) {
|
||||||
|
if (event.target == modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
|
||||||
|
this.value = telFormatter(this.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
let perms = data.user!.perms;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Edit Account</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Username <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
type="text"
|
||||||
|
value={data.user?.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Full name <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="fullName"
|
||||||
|
name="fullName"
|
||||||
|
placeholder="Full name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={data.user?.fullName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Email <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={data.user?.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Phone (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
|
||||||
|
placeholder="Phone"
|
||||||
|
type="tel"
|
||||||
|
value={data.user?.phone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Company code (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="companyCode"
|
||||||
|
name="companyCode"
|
||||||
|
placeholder="Company code"
|
||||||
|
type="text"
|
||||||
|
value={data.user?.companyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="low-emphasis-text pb-4">
|
||||||
|
Enter a code here to join a company. You will still have to be approved before you can
|
||||||
|
make any postings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Update account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
onclick={openConfirm}
|
||||||
|
type="button"
|
||||||
|
>Delete account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal" id="deleteConfirmModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Are you sure?</h2>
|
||||||
|
<button class="material-symbols-outlined" onclick={closeConfirm} type="button"
|
||||||
|
>close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>This will permanently delete your account. This action cannot be undone.</p>
|
||||||
|
<p>Please type "I understand" into the box below to confirm</p>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="confirm"
|
||||||
|
name="confirm"
|
||||||
|
pattern="I understand"
|
||||||
|
placeholder="I understand"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button class="danger-bg-color rounded px-2 py-1" formaction="?/delete" type="submit"
|
||||||
|
>Delete account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
onclick={closeConfirm}
|
||||||
|
type="button"
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
const largeScreen = new MediaQuery('min-width: 1024px');
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if largeScreen.current}
|
||||||
|
<div class="bottom-border h-10 pt-2 text-center">
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0}
|
||||||
|
<a
|
||||||
|
href="/admin/postings"
|
||||||
|
class="p-2 {page.url.pathname.startsWith('/admin/postings')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
|
><span class="material-symbols-outlined align-bottom">work</span> Postings</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_USERS) > 0}
|
||||||
|
<a
|
||||||
|
href="/admin/users"
|
||||||
|
class="p-2 {page.url.pathname.startsWith('/admin/users')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
|
><span class="material-symbols-outlined align-bottom">group</span> Users</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<!--TODO-->
|
||||||
|
<!--{#if (userState.perms & PERMISSIONS.MANAGE_TAGS) > 0}-->
|
||||||
|
<!-- <a-->
|
||||||
|
<!-- href="/admin/tags"-->
|
||||||
|
<!-- class="{page.url.pathname.startsWith('/admin/tags')-->
|
||||||
|
<!-- ? 'primary-underline font-bold'-->
|
||||||
|
<!-- : 'low-emphasis-text low-emphasis-text-button'} p-2"-->
|
||||||
|
<!-- ><span class="material-symbols-outlined align-bottom">sell</span> Tags</a-->
|
||||||
|
<!-- >-->
|
||||||
|
<!--{/if}-->
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0}
|
||||||
|
<a
|
||||||
|
href="/admin/companies"
|
||||||
|
class="{page.url.pathname.startsWith('/admin/companies')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'} p-2"
|
||||||
|
><span class="material-symbols-outlined align-bottom">store</span> Companies</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
{#if largeScreen.current}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<h2 class="m-3 text-center">Please use a computer to view the admin page!</h2>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0) {
|
||||||
|
window.location.href = '/admin/postings';
|
||||||
|
} else if ((userState.perms & PERMISSIONS.MANAGE_USERS) > 0) {
|
||||||
|
window.location.href = '/admin/users';
|
||||||
|
} else if ((userState.perms & PERMISSIONS.MANAGE_TAGS) > 0) {
|
||||||
|
window.location.href = '/admin/tags';
|
||||||
|
} else if ((userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
|
||||||
|
window.location.href = '/admin/companies';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getCompanies } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
|
const search = url.searchParams.get('searchCompanies');
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
|
||||||
|
return {
|
||||||
|
companies: await getCompanies(search)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
Company Management (Total: {data.companies?.length || 0})
|
||||||
|
</div>
|
||||||
|
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/companies/create"
|
||||||
|
>Create new company</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<form action="" class="flex p-4">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
class="search-cancel"
|
||||||
|
id="searchCompanies"
|
||||||
|
name="searchCompanies"
|
||||||
|
placeholder="Search Companies"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
<button><span class="material-symbols-outlined">search</span></button>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"-->
|
||||||
|
<!-- >Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span-->
|
||||||
|
<!-- ></button-->
|
||||||
|
<!-- >-->
|
||||||
|
<!-- <button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"-->
|
||||||
|
<!-- >Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span-->
|
||||||
|
<!-- ></button-->
|
||||||
|
<!-- >-->
|
||||||
|
</form>
|
||||||
|
<div class="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left w-16 py-1">ID</th>
|
||||||
|
<th class="py-1">Name</th>
|
||||||
|
<th class="py-1">Website</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
|
<th class="w-28 py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if data.companies !== undefined}
|
||||||
|
{#each data.companies as company}
|
||||||
|
<tr>
|
||||||
|
<td class="left">{company.id}</td>
|
||||||
|
<td>{company.name}</td>
|
||||||
|
<td
|
||||||
|
><a href={company.website} class="hyperlink-underline hyperlink-color"
|
||||||
|
>{company.website}</a
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{company.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td class="w-28 pr-1 text-end">
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 tooltip relative my-1 rounded p-1"
|
||||||
|
href="/companies/{company.id}"
|
||||||
|
>store<span class="tooltip-text font-sans text-sm">View company</span></a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined icon-20 hyperlink-color tooltip relative my-1 ml-1 mr-8 rounded p-1"
|
||||||
|
href="/companies/{company.id}/edit"
|
||||||
|
>edit<span class="tooltip-text font-sans text-sm">Edit company</span></a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getPostings } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
|
const search = url.searchParams.get('searchPostings');
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_POSTINGS) > 0) {
|
||||||
|
return {
|
||||||
|
postings: await getPostings(search)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { employmentTypeDisplayName } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
// Formating for all dates on the page
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data from server
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
Posting Management (Total: {data.postings?.length || 0})
|
||||||
|
</div>
|
||||||
|
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/postings/create"
|
||||||
|
>Create new posting</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<form action="" class="flex p-4">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
class="search-cancel"
|
||||||
|
id="searchPostings"
|
||||||
|
name="searchPostings"
|
||||||
|
placeholder="Search Postings"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
<button><span class="material-symbols-outlined">search</span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left w-16 py-1">ID</th>
|
||||||
|
<th class="py-1">Title</th>
|
||||||
|
<th class="py-1">Company</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
|
<th class="py-1">Updated</th>
|
||||||
|
<th class="py-1">Employment Type</th>
|
||||||
|
<th class="w-28 py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if data.postings !== undefined}
|
||||||
|
{#each data.postings as posting}
|
||||||
|
<tr>
|
||||||
|
<td class="left">{posting.id}</td>
|
||||||
|
<td class="left">{posting.title}</td>
|
||||||
|
<td>{posting.company.name || 'unknown'}</td>
|
||||||
|
<td
|
||||||
|
>{posting.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{posting.updatedAt?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td>{employmentTypeDisplayName(posting.employmentType)}</td>
|
||||||
|
<td class="w-28 pr-1 text-end">
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 tooltip relative my-1 rounded p-1"
|
||||||
|
href="/postings/{posting.id}"
|
||||||
|
>work<span class="tooltip-text font-sans text-sm">View posting</span></a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined icon-20 hyperlink-color tooltip relative my-1 ml-1 mr-8 rounded p-1"
|
||||||
|
href="/postings/{posting.id}/manage/edit"
|
||||||
|
>edit<span class="tooltip-text font-sans text-sm">Edit posting</span></a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getTags } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
|
const search = url.searchParams.get('searchUsers');
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_TAGS) > 0) {
|
||||||
|
return {
|
||||||
|
tags: await getTags(search)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(401, 'Unauthorized');
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
Tag Management (Total: {data.tags?.length || 0})
|
||||||
|
</div>
|
||||||
|
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/tags/create"
|
||||||
|
>Create new tag</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<form action="" class="flex p-4">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
class="search-cancel"
|
||||||
|
id="searchTags"
|
||||||
|
name="searchTags"
|
||||||
|
placeholder="Search Tags"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
<button><span class="material-symbols-outlined">search</span></button>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"-->
|
||||||
|
<!-- >Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span-->
|
||||||
|
<!-- ></button-->
|
||||||
|
<!-- >-->
|
||||||
|
<!-- <button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"-->
|
||||||
|
<!-- >Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span-->
|
||||||
|
<!-- ></button-->
|
||||||
|
<!-- >-->
|
||||||
|
</form>
|
||||||
|
<div class="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left py-1">ID</th>
|
||||||
|
<th class="py-1">Name</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
|
<th class="py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if data.tags !== undefined}
|
||||||
|
{#each data.tags as tag}
|
||||||
|
<tr>
|
||||||
|
<td class="left">{tag.id}</td>
|
||||||
|
<td>{tag.displayName}</td>
|
||||||
|
<td>{tag.createdAt?.toLocaleDateString('en-US', dateFormatOptions)}</td>
|
||||||
|
<td class="w-28 pr-1 text-end">
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 my-1 rounded p-1"
|
||||||
|
href="/admin/tags/{tag.id}">info</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 my-1 ml-1 mr-8 rounded p-1"
|
||||||
|
href="/admin/tags/{tag.id}/edit">edit</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getUsers } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
|
const search = url.searchParams.get('searchUsers');
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
|
||||||
|
return {
|
||||||
|
users: await getUsers(search)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { userPerms, employerPerms, adminPerms } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRoleFromPerms(perms: number): string {
|
||||||
|
if (perms & adminPerms) return 'Admin';
|
||||||
|
if (perms & employerPerms) return 'Employer';
|
||||||
|
if (perms & userPerms) return 'User';
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
User Account Management (Total: {data.users?.length || 0})
|
||||||
|
</div>
|
||||||
|
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/admin/users/create"
|
||||||
|
>Create new user</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<form action="" class="flex p-4">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
class="search-cancel"
|
||||||
|
id="searchUsers"
|
||||||
|
name="searchUsers"
|
||||||
|
placeholder="Search Users"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
<button><span class="material-symbols-outlined">search</span></button>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"-->
|
||||||
|
<!-- >Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span-->
|
||||||
|
<!-- ></button-->
|
||||||
|
<!-- >-->
|
||||||
|
<!-- <button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"-->
|
||||||
|
<!-- >Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span-->
|
||||||
|
<!-- ></button-->
|
||||||
|
<!-- >-->
|
||||||
|
</form>
|
||||||
|
<div class="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left w-16 py-1">ID</th>
|
||||||
|
<th class="py-1">Username</th>
|
||||||
|
<th class="py-1">Permissions</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
|
<th class="py-1">Last Sign-In</th>
|
||||||
|
<th class="py-1">Active</th>
|
||||||
|
<th class="w-28 py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if data.users !== undefined}
|
||||||
|
{#each data.users as user}
|
||||||
|
<tr>
|
||||||
|
<td class="left">{user.id}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.perms} ({getRoleFromPerms(user.perms)})</td>
|
||||||
|
<td
|
||||||
|
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) || 'unknown'}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td class="material-symbols-outlined py-1.5">
|
||||||
|
{user.active ? 'check' : 'close'}
|
||||||
|
</td>
|
||||||
|
<td class="w-28 pr-1 text-end">
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 tooltip relative my-1 rounded p-1"
|
||||||
|
href="/admin/users/{user.id}"
|
||||||
|
>person<span class="tooltip-text font-sans text-sm">View account</span></a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="hover-bg-color material-symbols-outlined icon-20 hyperlink-color tooltip relative my-1 ml-1 mr-8 rounded p-1"
|
||||||
|
href="/admin/users/{user.id}/edit"
|
||||||
|
>edit<span class="tooltip-text font-sans text-sm">Edit account</span></a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getUserWithCompany } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.user);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
|
||||||
|
return {
|
||||||
|
user: await getUserWithCompany(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { adminPerms, employerPerms, userPerms } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function avatarFallback(e: Event) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${data.user.fullName ? encodeURIComponent(data.user.fullName) : encodeURIComponent(data.user.username)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoFallback(e: Event) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.user.company!.name!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content flex py-4">
|
||||||
|
<div class="elevated separator-borders m-2 inline-block h-min min-w-max rounded align-top">
|
||||||
|
<div class="inline-block p-4">
|
||||||
|
<img
|
||||||
|
alt="User avatar"
|
||||||
|
class="mb-2 inline-block rounded-lg"
|
||||||
|
height="240"
|
||||||
|
onerror={avatarFallback}
|
||||||
|
src="/uploads/avatars/{data.user.id}.svg?timestamp=${Date.now()}"
|
||||||
|
width="240"
|
||||||
|
/>
|
||||||
|
{#if data.user.fullName}
|
||||||
|
<h2 class="text-center font-semibold">{data.user.fullName}</h2>
|
||||||
|
<h3 class="text-center">{data.user.username}</h3>
|
||||||
|
{/if}
|
||||||
|
{#if !data.user.fullName}
|
||||||
|
<h2 class="text-center font-semibold">{data.user.username}</h2>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if data.user.email}
|
||||||
|
<div class="top-border p-3">
|
||||||
|
<span class="material-symbols-outlined align-middle">mail</span>
|
||||||
|
<a class="hover-hyperlink" href="mailto:{data.user.email}">{data.user.email}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.user.phone}
|
||||||
|
<div class="top-border p-3">
|
||||||
|
<span class="material-symbols-outlined align-middle">call</span>
|
||||||
|
<a class="hover-hyperlink" href="tel:{data.user.phone}">{data.user.phone}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.user.createdAt}
|
||||||
|
<div class="top-border p-3">
|
||||||
|
<span class="material-symbols-outlined align-middle">calendar_today</span>
|
||||||
|
Joined {data.user.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="elevated separator-borders m-2 inline-block h-min w-full rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">User Details</div>
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1"
|
||||||
|
href="/admin/users/{data.user.id}/edit">Edit user</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="font-semibold">
|
||||||
|
ID: <span class="font-normal">{data.user.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
Account active: <span class="material-symbols-outlined align-middle"
|
||||||
|
>{data.user.active ? 'check' : 'cancel'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
Last sign-in: <span class="font-normal"
|
||||||
|
>{data.user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if !data.user.company?.id}
|
||||||
|
<div class="pb-2 font-semibold">
|
||||||
|
Employer company: <span class="font-normal">N/A</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.user.company?.id}
|
||||||
|
<div class="font-semibold">
|
||||||
|
Employer company:
|
||||||
|
<div class="top-border mt-2 p-3">
|
||||||
|
<img
|
||||||
|
class="mb-2 inline-block rounded-lg"
|
||||||
|
src="/uploads/logos/{data.user.company.id}.jpg?timestamp=${Date.now()}"
|
||||||
|
alt="Company Logo"
|
||||||
|
onerror={logoFallback}
|
||||||
|
height="32"
|
||||||
|
width="32"
|
||||||
|
/>
|
||||||
|
<div class="inline-block">
|
||||||
|
<div>{data.user.company.name}</div>
|
||||||
|
<div class="max-char-length font-normal">{data.user.company.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="top-border pt-2 font-semibold">
|
||||||
|
Permissions: <span class="font-normal">{data.user.perms}</span>
|
||||||
|
<div class="font-normal">
|
||||||
|
{#if (data.user.perms & userPerms) > 0}
|
||||||
|
<p class="font-semibold">User permissions:</p>
|
||||||
|
{#if (data.user.perms & PERMISSIONS.VIEW) > 0}
|
||||||
|
<p class="pl-4">View access</p>
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
|
||||||
|
<p class="pl-4">Apply for jobs</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & employerPerms) > 0}
|
||||||
|
<p class="font-semibold">Employer permissions:</p>
|
||||||
|
{#if (data.user.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0}
|
||||||
|
<p class="pl-4">Submit postings</p>
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & PERMISSIONS.MANAGE_EMPLOYERS) > 0}
|
||||||
|
<p class="pl-4">Manage employers (of their company)</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & adminPerms) > 0}
|
||||||
|
<p class="font-semibold">Admin permissions:</p>
|
||||||
|
{#if (data.user.perms & PERMISSIONS.MANAGE_POSTINGS) > 0}
|
||||||
|
<p class="pl-4">Manage postings</p>
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & PERMISSIONS.MANAGE_USERS) > 0}
|
||||||
|
<p class="pl-4">Manage users</p>
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & PERMISSIONS.MANAGE_TAGS) > 0}
|
||||||
|
<p class="pl-4">Manage tags</p>
|
||||||
|
{/if}
|
||||||
|
{#if (data.user.perms & PERMISSIONS.MANAGE_COMPANIES) > 0}
|
||||||
|
<p class="pl-4">Manage companies</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { deleteUser, getUser, updateUser } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
import { userPerms, employerPerms } from '$lib/shared.svelte';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.user);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
|
||||||
|
return {
|
||||||
|
user: await getUser(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies, params }) => {
|
||||||
|
const id = parseInt(params.user!);
|
||||||
|
const data = await request.formData();
|
||||||
|
const username = data.get('username')?.toString().trim();
|
||||||
|
let password: string | undefined | null = data.get('password')?.toString().trim();
|
||||||
|
const view = data.get('view')?.toString();
|
||||||
|
const apply = data.get('apply')?.toString();
|
||||||
|
const submitPostings = data.get('submitPostings')?.toString();
|
||||||
|
const manageEmployers = data.get('manageEmployers')?.toString();
|
||||||
|
const manageTags = data.get('manageTags')?.toString();
|
||||||
|
const managePostings = data.get('managePostings')?.toString();
|
||||||
|
const manageUsers = data.get('manageUsers')?.toString();
|
||||||
|
const accountActive = data.get('accountActive')?.toString();
|
||||||
|
let email: string | undefined | null = data.get('email')?.toString().trim();
|
||||||
|
let phone: string | undefined | null = data.get('phone')?.toString().trim();
|
||||||
|
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
|
||||||
|
let companyCode: string | undefined | null = data
|
||||||
|
.get('companyCode')
|
||||||
|
?.toString()
|
||||||
|
.toUpperCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (password === '' || password == undefined) password = null;
|
||||||
|
if (email === '' || email == undefined) email = null;
|
||||||
|
if (phone === '' || phone == undefined) phone = null;
|
||||||
|
if (fullName === '' || fullName == undefined) fullName = null;
|
||||||
|
if (companyCode === '' || companyCode == undefined) companyCode = null;
|
||||||
|
|
||||||
|
if (email && !email.includes('@')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone && !phone.match(/\((\d{3})\) \d{3}-\d{4}/)) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid phone number' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let newUserPerms = 0;
|
||||||
|
newUserPerms += PERMISSIONS.VIEW * (view === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.APPLY_FOR_JOBS * (apply === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.SUBMIT_POSTINGS * (submitPostings === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_EMPLOYERS * (manageEmployers === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_TAGS * (manageTags === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_POSTINGS * (managePostings === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_USERS * (manageUsers === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_COMPANIES * (manageUsers === 'on' ? 1 : 0);
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
|
||||||
|
return fail(403, { errorMessage: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
if (((requestPerms | userPerms | employerPerms) & newUserPerms) !== newUserPerms) {
|
||||||
|
return fail(403, {
|
||||||
|
errorMessage: 'Cannot give a user higher permissions than yourself!'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!username) {
|
||||||
|
return fail(400, { errorMessage: 'Missing username' });
|
||||||
|
}
|
||||||
|
if (password && password.length < 8) {
|
||||||
|
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
|
||||||
|
}
|
||||||
|
if (username.length < 4) {
|
||||||
|
return fail(400, { errorMessage: 'Username must be at least 4 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateUser(<User>{
|
||||||
|
id: id,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
perms: newUserPerms,
|
||||||
|
active: accountActive === 'on',
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
fullName: fullName,
|
||||||
|
companyCode: companyCode
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
return redirect(301, `/admin/users/${id}`);
|
||||||
|
},
|
||||||
|
delete: async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.user!);
|
||||||
|
const userToDelete = await getUser(id);
|
||||||
|
const deletePerms = userToDelete!.perms;
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
|
||||||
|
return fail(403, { errorMessage: 'Unauthorized' });
|
||||||
|
} else {
|
||||||
|
if ((requestPerms & deletePerms) !== deletePerms) {
|
||||||
|
return fail(403, {
|
||||||
|
errorMessage: 'Cannot delete a user with higher permissions than yourself!'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await deleteUser(id);
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
return redirect(301, '/admin/users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { userPerms, employerPerms, adminPerms, telFormatter } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
let permsAccordions: boolean[] = [false, false, false];
|
||||||
|
|
||||||
|
let passwordVisible = $state(false);
|
||||||
|
|
||||||
|
function showPassword() {
|
||||||
|
const password = document.querySelector('input[name="newPassword"]');
|
||||||
|
if (password) {
|
||||||
|
if (password.getAttribute('type') === 'password') {
|
||||||
|
password.setAttribute('type', 'text');
|
||||||
|
passwordVisible = true;
|
||||||
|
} else {
|
||||||
|
password.setAttribute('type', 'password');
|
||||||
|
passwordVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let acc = document.getElementsByClassName('accordion');
|
||||||
|
for (let i = 0; i < acc.length; i++) {
|
||||||
|
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
|
||||||
|
const target = event?.target as HTMLElement | null;
|
||||||
|
if (target?.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
|
||||||
|
return; // Do nothing if it's the checkbox
|
||||||
|
}
|
||||||
|
|
||||||
|
this.classList.toggle('active');
|
||||||
|
permsAccordions[i] = !permsAccordions[i];
|
||||||
|
|
||||||
|
let panel = this.nextElementSibling as HTMLElement;
|
||||||
|
if (panel.style.display === 'block') {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectAllCheckbox = acc[i].querySelector('.select-all') as HTMLInputElement;
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
let checkboxes =
|
||||||
|
this.parentElement!.parentElement!.parentElement!.nextElementSibling!.querySelectorAll(
|
||||||
|
'.permCheckbox'
|
||||||
|
) as NodeListOf<HTMLInputElement>;
|
||||||
|
checkboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = selectAllCheckbox.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let permCheckboxes = acc[i].nextElementSibling!.querySelectorAll(
|
||||||
|
'.permCheckbox'
|
||||||
|
) as NodeListOf<HTMLInputElement>;
|
||||||
|
|
||||||
|
permCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener('change', function () {
|
||||||
|
let allChecked = true;
|
||||||
|
let someChecked = false;
|
||||||
|
permCheckboxes.forEach((cb) => {
|
||||||
|
if (cb.checked) {
|
||||||
|
someChecked = true;
|
||||||
|
} else {
|
||||||
|
allChecked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectAllCheckbox.checked = allChecked;
|
||||||
|
selectAllCheckbox.indeterminate = !allChecked && someChecked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal: HTMLElement = document.getElementById('deleteConfirmModal') as HTMLElement;
|
||||||
|
window.onclick = function (event) {
|
||||||
|
if (event.target == modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
|
||||||
|
this.value = telFormatter(this.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
let perms = data.user!.perms;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
Edit User {data.user.username}{data.user.fullName ? ` (${data.user.fullName})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={data.user?.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative pt-4 text-sm font-semibold">
|
||||||
|
New password (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
placeholder="New password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute right-2.5 -translate-y-1/2 transform pt-12"
|
||||||
|
onclick={showPassword}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Full name <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="fullName"
|
||||||
|
name="fullName"
|
||||||
|
placeholder="Full name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={data.user?.fullName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Email <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={data.user?.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Phone (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
|
||||||
|
placeholder="Phone"
|
||||||
|
type="tel"
|
||||||
|
value={data.user?.phone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Company code (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="companyCode"
|
||||||
|
name="companyCode"
|
||||||
|
placeholder="Company code"
|
||||||
|
type="text"
|
||||||
|
value={data.user?.companyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="low-emphasis-text">
|
||||||
|
This code can be used to associate an employer with their company. If left blank, they will
|
||||||
|
not be able to create any postings.
|
||||||
|
</p>
|
||||||
|
<div class="bg-color separator-borders mb-2 mt-4 rounded">
|
||||||
|
<button
|
||||||
|
class="accordion flex w-full place-content-between rounded p-2 text-left"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex place-items-center">
|
||||||
|
<span class="ml-1 mr-3"
|
||||||
|
><input
|
||||||
|
checked={(perms & userPerms) === userPerms}
|
||||||
|
class="select-all"
|
||||||
|
id="userPerms"
|
||||||
|
indeterminate={(perms & userPerms) !== userPerms && (perms & userPerms) > 0}
|
||||||
|
name="userPerms"
|
||||||
|
type="checkbox"
|
||||||
|
/></span
|
||||||
|
>User Permissions
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="view">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.VIEW) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="view"
|
||||||
|
name="view"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">View access</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="apply">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="apply"
|
||||||
|
name="apply"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Apply for jobs</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-color separator-borders my-2 rounded">
|
||||||
|
<button
|
||||||
|
class="accordion flex w-full place-content-between rounded p-2 text-left"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex place-items-center">
|
||||||
|
<span class="ml-1 mr-3"
|
||||||
|
><input
|
||||||
|
checked={(perms & employerPerms) === employerPerms}
|
||||||
|
class="select-all"
|
||||||
|
id="companyPerms"
|
||||||
|
indeterminate={(perms & employerPerms) !== employerPerms &&
|
||||||
|
(perms & employerPerms) > 0}
|
||||||
|
name="companyPerms"
|
||||||
|
type="checkbox"
|
||||||
|
/></span
|
||||||
|
>Company Permissions
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{permsAccordions[1] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="submitPostings">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.SUBMIT_POSTINGS) >= 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="submitPostings"
|
||||||
|
name="submitPostings"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Submit postings</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageEmployers">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.MANAGE_EMPLOYERS) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageEmployers"
|
||||||
|
name="manageEmployers"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage employers (within their company)</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-color separator-borders mt-2 rounded">
|
||||||
|
<button
|
||||||
|
class="accordion flex w-full place-content-between rounded p-2 text-left"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex place-items-center">
|
||||||
|
<span class="ml-1 mr-3"
|
||||||
|
><input
|
||||||
|
checked={(perms & adminPerms) === adminPerms}
|
||||||
|
class="select-all"
|
||||||
|
id="adminPerms"
|
||||||
|
indeterminate={(perms & adminPerms) !== adminPerms && (perms & adminPerms) > 0}
|
||||||
|
name="adminPerms"
|
||||||
|
type="checkbox"
|
||||||
|
/></span
|
||||||
|
>Admin Permissions
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageTags">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.MANAGE_TAGS) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageTags"
|
||||||
|
name="manageTags"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage tags</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="managePostings">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.MANAGE_POSTINGS) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="managePostings"
|
||||||
|
name="managePostings"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage postings</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageUsers">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.MANAGE_USERS) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageUsers"
|
||||||
|
name="manageUsers"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage users</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageCompanies">
|
||||||
|
<input
|
||||||
|
checked={(perms & PERMISSIONS.MANAGE_COMPANIES) > 0}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageCompanies"
|
||||||
|
name="manageCompanies"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage companies</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex place-items-center p-2" for="accountActive">
|
||||||
|
<input
|
||||||
|
checked={data.user?.active}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="accountActive"
|
||||||
|
name="accountActive"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Account active</span></label
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Save user
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
onclick={openConfirm}
|
||||||
|
type="button"
|
||||||
|
>Delete user
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form class="modal" id="deleteConfirmModal" method="POST" use:enhance>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Are you sure?</h2>
|
||||||
|
<button class="material-symbols-outlined" onclick={closeConfirm} type="button"
|
||||||
|
>close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
This will permanently delete user <span class="font-semibold">{data.user?.username}.</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p>Please type "I understand" into the box below to confirm</p>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="confirm"
|
||||||
|
name="confirm"
|
||||||
|
pattern="I understand"
|
||||||
|
placeholder="I understand"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button class="danger-bg-color rounded px-2 py-1" formaction="?/delete" type="submit"
|
||||||
|
>Delete user
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
onclick={closeConfirm}
|
||||||
|
type="button"
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createUser } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
import { employerPerms, userPerms } from '$lib/shared.svelte';
|
||||||
|
import type { PageServerLoad } from '../../../../../.svelte-kit/types/src/routes/admin/users/$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (!(perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0)) {
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const username = data.get('username')?.toString().trim();
|
||||||
|
const password = data.get('password')?.toString().trim();
|
||||||
|
const view = data.get('view')?.toString();
|
||||||
|
const apply = data.get('apply')?.toString();
|
||||||
|
const submitPostings = data.get('submitPostings')?.toString();
|
||||||
|
const manageEmployers = data.get('manageEmployers')?.toString();
|
||||||
|
const manageTags = data.get('manageTags')?.toString();
|
||||||
|
const managePostings = data.get('managePostings')?.toString();
|
||||||
|
const manageUsers = data.get('manageUsers')?.toString();
|
||||||
|
const accountActive = data.get('accountActive')?.toString();
|
||||||
|
let email: string | undefined | null = data.get('email')?.toString().trim();
|
||||||
|
let phone: string | undefined | null = data.get('phone')?.toString().trim();
|
||||||
|
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
|
||||||
|
let companyCode: string | undefined | null = data
|
||||||
|
.get('companyCode')
|
||||||
|
?.toString()
|
||||||
|
.toUpperCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (phone === '' || phone == undefined) phone = null;
|
||||||
|
if (companyCode === '' || companyCode == undefined) companyCode = null;
|
||||||
|
|
||||||
|
if (email && !email.includes('@')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone && !phone.match(/[0-9]{3}-[0-9]{3}-[0-9]{4}/)) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid phone number' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let newUserPerms = 0;
|
||||||
|
newUserPerms += PERMISSIONS.VIEW * (view === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.APPLY_FOR_JOBS * (apply === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.SUBMIT_POSTINGS * (submitPostings === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_EMPLOYERS * (manageEmployers === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_TAGS * (manageTags === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_POSTINGS * (managePostings === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_USERS * (manageUsers === 'on' ? 1 : 0);
|
||||||
|
newUserPerms += PERMISSIONS.MANAGE_COMPANIES * (manageUsers === 'on' ? 1 : 0);
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
if (((requestPerms | userPerms | employerPerms) & newUserPerms) !== newUserPerms) {
|
||||||
|
return fail(403, {
|
||||||
|
errorMessage: 'Cannot create a user with higher permissions than yourself!'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!username ||
|
||||||
|
username === '' ||
|
||||||
|
!password ||
|
||||||
|
!email ||
|
||||||
|
email === '' ||
|
||||||
|
!fullName ||
|
||||||
|
fullName === ''
|
||||||
|
) {
|
||||||
|
return fail(400, { errorMessage: 'Please fill out all required fields' });
|
||||||
|
}
|
||||||
|
if (password.length < 4) {
|
||||||
|
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
|
||||||
|
}
|
||||||
|
if (username.length < 4) {
|
||||||
|
return fail(400, { errorMessage: 'Username must be at least 4 characters' });
|
||||||
|
}
|
||||||
|
let id = -1;
|
||||||
|
try {
|
||||||
|
id = await createUser(<User>{
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
perms: newUserPerms,
|
||||||
|
active: accountActive === 'on',
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
fullName: fullName,
|
||||||
|
companyCode: companyCode
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
if (id !== -1) {
|
||||||
|
return redirect(301, `/admin/users/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { telFormatter } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
let permsAccordions: boolean[] = [false, false, false];
|
||||||
|
|
||||||
|
let passwordVisible = $state(false);
|
||||||
|
|
||||||
|
function showPassword() {
|
||||||
|
const password = document.querySelector('input[name="newPassword"]');
|
||||||
|
if (password) {
|
||||||
|
if (password.getAttribute('type') === 'password') {
|
||||||
|
password.setAttribute('type', 'text');
|
||||||
|
passwordVisible = true;
|
||||||
|
} else {
|
||||||
|
password.setAttribute('type', 'password');
|
||||||
|
passwordVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let acc = document.getElementsByClassName('accordion');
|
||||||
|
for (let i = 0; i < acc.length; i++) {
|
||||||
|
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
|
||||||
|
const target = event?.target as HTMLElement | null;
|
||||||
|
if (target?.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
|
||||||
|
return; // Do nothing if it's the checkbox
|
||||||
|
}
|
||||||
|
|
||||||
|
this.classList.toggle('active');
|
||||||
|
permsAccordions[i] = !permsAccordions[i];
|
||||||
|
|
||||||
|
let panel = this.nextElementSibling as HTMLElement;
|
||||||
|
if (panel.style.display === 'block') {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectAllCheckbox = acc[i].querySelector('.select-all') as HTMLInputElement;
|
||||||
|
// console.log(selectAllCheckbox);
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
let checkboxes =
|
||||||
|
this.parentElement!.parentElement!.parentElement!.nextElementSibling!.querySelectorAll(
|
||||||
|
'.permCheckbox'
|
||||||
|
) as NodeListOf<HTMLInputElement>;
|
||||||
|
// console.log(checkboxes);
|
||||||
|
checkboxes.forEach((checkbox) => {
|
||||||
|
checkbox.checked = selectAllCheckbox.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let permCheckboxes = acc[i].nextElementSibling!.querySelectorAll(
|
||||||
|
'.permCheckbox'
|
||||||
|
) as NodeListOf<HTMLInputElement>;
|
||||||
|
permCheckboxes.forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener('change', function () {
|
||||||
|
let allChecked = true;
|
||||||
|
let someChecked = false;
|
||||||
|
permCheckboxes.forEach((cb) => {
|
||||||
|
if (cb.checked) {
|
||||||
|
someChecked = true;
|
||||||
|
} else {
|
||||||
|
allChecked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectAllCheckbox.checked = allChecked;
|
||||||
|
selectAllCheckbox.indeterminate = !allChecked && someChecked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
|
||||||
|
this.value = telFormatter(this.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Create new user</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative mt-4 text-sm font-semibold">
|
||||||
|
Password <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute right-2.5 -translate-y-1/2 transform pt-12"
|
||||||
|
onclick={showPassword}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Full name <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="fullName"
|
||||||
|
name="fullName"
|
||||||
|
placeholder="Full Name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Email <span class="danger-color">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Phone (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
|
||||||
|
placeholder="Phone"
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Company code (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="companyCode"
|
||||||
|
name="companyCode"
|
||||||
|
placeholder="Company code"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="low-emphasis-text">
|
||||||
|
This code can be used to associate an employer with their company. If left blank, they will
|
||||||
|
not be able to create any postings.
|
||||||
|
</p>
|
||||||
|
<div class="bg-color separator-borders mb-2 mt-4 rounded">
|
||||||
|
<button
|
||||||
|
class="accordion flex w-full place-content-between rounded p-2 text-left"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex place-items-center">
|
||||||
|
<span class="ml-1 mr-3"
|
||||||
|
><input
|
||||||
|
class="select-all"
|
||||||
|
id="userPerms"
|
||||||
|
indeterminate={true}
|
||||||
|
name="userPerms"
|
||||||
|
type="checkbox"
|
||||||
|
/></span
|
||||||
|
>User Permissions
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="view">
|
||||||
|
<input
|
||||||
|
checked={true}
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="view"
|
||||||
|
name="view"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">View access</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="apply">
|
||||||
|
<input class="permCheckbox mx-1" id="apply" name="apply" type="checkbox" />
|
||||||
|
<span class="ml-2">Apply for jobs</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-color separator-borders my-2 rounded">
|
||||||
|
<button
|
||||||
|
class="accordion flex w-full place-content-between rounded p-2 text-left"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex place-items-center">
|
||||||
|
<span class="ml-1 mr-3"
|
||||||
|
><input
|
||||||
|
class="select-all"
|
||||||
|
id="companyPerms"
|
||||||
|
name="companyPerms"
|
||||||
|
type="checkbox"
|
||||||
|
/></span
|
||||||
|
>Company Permissions
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{permsAccordions[1] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="submitPostings">
|
||||||
|
<input
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="submitPostings"
|
||||||
|
name="submitPostings"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Submit postings</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageEmployers">
|
||||||
|
<input
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageEmployers"
|
||||||
|
name="manageEmployers"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage employers (within their company)</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-color separator-borders mt-2 rounded">
|
||||||
|
<button
|
||||||
|
class="accordion flex w-full place-content-between rounded p-2 text-left"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="flex place-items-center">
|
||||||
|
<span class="ml-1 mr-3"
|
||||||
|
><input class="select-all" id="adminPerms" name="adminPerms" type="checkbox" /></span
|
||||||
|
>Admin Permissions
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{permsAccordions[0] ? 'arrow_drop_up' : 'arrow_drop_down'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageTags">
|
||||||
|
<input
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageTags"
|
||||||
|
name="manageTags"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage tags</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="managePostings">
|
||||||
|
<input
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="managePostings"
|
||||||
|
name="managePostings"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage postings</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageUsers">
|
||||||
|
<input
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageUsers"
|
||||||
|
name="manageUsers"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage users</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="flex place-items-center" for="manageCompanies">
|
||||||
|
<input
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="manageCompanies"
|
||||||
|
name="manageCompanies"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Manage companies</span></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex place-items-center p-2" for="accountActive">
|
||||||
|
<input
|
||||||
|
checked
|
||||||
|
class="permCheckbox mx-1"
|
||||||
|
id="accountActive"
|
||||||
|
name="accountActive"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="ml-2">Account active</span></label
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Create user
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import '../../app.css';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="pt-2 text-center">
|
|
||||||
<a
|
|
||||||
href="/administration/postings"
|
|
||||||
class="p-2 {$page.url.pathname === '/administration/postings'
|
|
||||||
? 'primary-underline font-bold'
|
|
||||||
: 'low-emphasis-text'}"
|
|
||||||
><span class="material-symbols-outlined small-icon align-bottom">work</span> Postings</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/administration/users"
|
|
||||||
class="p-2 {$page.url.pathname === '/administration/users'
|
|
||||||
? 'primary-underline font-bold'
|
|
||||||
: 'low-emphasis-text'}"
|
|
||||||
><span class="material-symbols-outlined small-icon align-bottom">group</span> Users</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="/administration/tags"
|
|
||||||
class="{$page.url.pathname === '/administration/tags'
|
|
||||||
? 'primary-underline font-bold'
|
|
||||||
: 'low-emphasis-text'} p-2"
|
|
||||||
><span class="material-symbols-outlined small-icon align-bottom">sell</span> Tags</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getPostingWithCompanyUser } from '$lib/db/index.server';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
if (!id) return new Response(error(400, 'No id provided'));
|
||||||
|
return json(await getPostingWithCompanyUser(parseInt(id)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getCompanies } from '$lib/db/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return { companies: await getCompanies() };
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { Company } from '$lib/types';
|
||||||
|
import { userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
|
||||||
|
function logoFallback(e: Event, company: Company) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(company.name!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container-small">
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="inline-block p-3 font-semibold">Companies</div>
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0 || (userState.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0}
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color m-2 inline-block rounded-md px-2.5 py-1"
|
||||||
|
href="/companies/create">Create new company</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#each data.companies as company}
|
||||||
|
<a class="top-border hover-bg-color flex w-full p-3" href="/companies/{company.id}">
|
||||||
|
<img
|
||||||
|
class="mb-2 inline-block h-16 rounded-lg"
|
||||||
|
src="/uploads/logos/{company.id}.jpg?timestamp=${Date.now()}"
|
||||||
|
alt="Company logo"
|
||||||
|
onerror={(e) => logoFallback(e, company)}
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
/>
|
||||||
|
<div class="inline-block h-min pl-4 align-top">
|
||||||
|
<h2 class="font-bold">{company.name}</h2>
|
||||||
|
<p class="max-char-length">{company.description}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getCompanyFullData } from '$lib/db/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const id = parseInt(params.company!);
|
||||||
|
return await getCompanyFullData(id);
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
function avatarFallback(e: Event, user: User): null {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${user.fullName ? encodeURIComponent(user.fullName) : encodeURIComponent(user.username)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoFallback(e: Event) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.company.name!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeScreen = new MediaQuery('min-width: 768px');
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content my-2">
|
||||||
|
<div class="bottom-border mb-4 flex justify-between">
|
||||||
|
<div class="flex">
|
||||||
|
<img
|
||||||
|
alt="Company Logo"
|
||||||
|
class="mb-2 inline-block h-32 rounded-lg"
|
||||||
|
height="128"
|
||||||
|
onerror={logoFallback}
|
||||||
|
src="/uploads/logos/{data.company.id}.jpg?timestamp=${Date.now()}"
|
||||||
|
width="128"
|
||||||
|
/>
|
||||||
|
<div class="inline-block h-min pl-4">
|
||||||
|
<h1 class="font-bold">{data.company.name}</h1>
|
||||||
|
<p>{data.company.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0 || ((userState.perms & PERMISSIONS.MANAGE_EMPLOYERS) !== 0 && userState.companyId === data.company.id)}
|
||||||
|
<div class="mt-1 inline-block min-w-max">
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color rounded px-3 py-1.5"
|
||||||
|
href="/companies/{data.company.id}/edit">Edit company</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class={largeScreen.current ? 'flex' : 'block'}>
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders mr-2 h-min {largeScreen.current
|
||||||
|
? 'inline-block w-3/4'
|
||||||
|
: 'block w-full'} rounded"
|
||||||
|
>
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
{data.postings
|
||||||
|
? data.company.name + "'s Postings"
|
||||||
|
: data.company.name + ' has no current postings!'}
|
||||||
|
</div>
|
||||||
|
{#each data.postings as posting}
|
||||||
|
<a class="top-border hover-bg-color flex p-2" href="/postings/{posting.id}">
|
||||||
|
<img
|
||||||
|
class="inline-block h-12 rounded"
|
||||||
|
src="/uploads/logos/{data.company.id}.jpg"
|
||||||
|
alt="Company Logo"
|
||||||
|
height="48"
|
||||||
|
width="48"
|
||||||
|
onerror={logoFallback}
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<h2 class="font-semibold">{posting.title}</h2>
|
||||||
|
<h3 class="max-char-length whitespace-pre-wrap">{posting.description}</h3>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders h-min rounded {largeScreen.current
|
||||||
|
? 'inline-block w-1/4'
|
||||||
|
: 'mt-2 block w-full'}"
|
||||||
|
>
|
||||||
|
<div class="flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Employers</div>
|
||||||
|
</div>
|
||||||
|
{#each data.users as user}
|
||||||
|
<div class="top-border px-3 pb-2 pt-3">
|
||||||
|
<div class="flex">
|
||||||
|
<img
|
||||||
|
class="mb-2 inline-block h-min rounded"
|
||||||
|
src="/uploads/avatars/{user.id}.svg?timestamp=${Date.now()}"
|
||||||
|
alt="User avatar"
|
||||||
|
onerror={(e) => avatarFallback(e, user)}
|
||||||
|
height="32"
|
||||||
|
width="32"
|
||||||
|
/>
|
||||||
|
<div class="pl-2">
|
||||||
|
<div class="pb-1 font-semibold">
|
||||||
|
{user.fullName}{` (${user.username})`}
|
||||||
|
</div>
|
||||||
|
{#if user.email}
|
||||||
|
<div class="pb-1">
|
||||||
|
<span class="material-symbols-outlined align-middle">mail</span>
|
||||||
|
<a class="hover-hyperlink" href="mailto:{user.email}">{user.email}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if user.phone}
|
||||||
|
<div class="pb-1">
|
||||||
|
<span class="material-symbols-outlined align-middle">call</span>
|
||||||
|
<a class="hover-hyperlink" href="tel:{user.phone}">{user.phone}</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
|
let { data, children }: LayoutProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bottom-border h-10 pt-2 text-center">
|
||||||
|
<a
|
||||||
|
class="p-2 {page.url.pathname.endsWith('edit')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
|
href={page.url.pathname.endsWith('employers') ? '.' : ''}
|
||||||
|
><span class="material-symbols-outlined align-bottom">store</span> Details</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="p-2 {page.url.pathname.endsWith('employers')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
|
href={page.url.pathname.endsWith('edit') ? 'edit/employers' : ''}
|
||||||
|
><span class="material-symbols-outlined align-bottom">group</span> Employers</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ params }) => {
|
||||||
|
return { id: parseInt(params.company) };
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { deleteCompany, editCompany, getCompany } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import type { Company } from '$lib/types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.company);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
|
||||||
|
return {
|
||||||
|
company: await getCompany(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies, params }) => {
|
||||||
|
const id = parseInt(params.company!);
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name')?.toString().trim();
|
||||||
|
let website = data.get('website')?.toString().trim();
|
||||||
|
const description = data.get('description')?.toString().trim();
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_COMPANIES) > 0) ||
|
||||||
|
(requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
if (!name || name === '' || !website || website === '' || !description || description === '') {
|
||||||
|
return fail(400, { errorMessage: 'All fields are required' });
|
||||||
|
}
|
||||||
|
if (!website.includes('.')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid website' });
|
||||||
|
}
|
||||||
|
if (!website.startsWith('http://') && !website.startsWith('https://'))
|
||||||
|
website = `https://${website}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editCompany(<Company>{
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
website: website,
|
||||||
|
description: description
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
redirect(301, `/companies/${id}`);
|
||||||
|
},
|
||||||
|
delete: async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.company!);
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_COMPANIES) > 0)) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteCompany(id);
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoFallback(e: Event) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.company.name!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content">
|
||||||
|
<div class="m-4">
|
||||||
|
<img
|
||||||
|
alt="User avatar"
|
||||||
|
class="mb-2 inline-block rounded-lg"
|
||||||
|
height="80"
|
||||||
|
onerror={logoFallback}
|
||||||
|
src="/uploads/logos/{data.company.id}.jpg?timestamp=${Date.now()}"
|
||||||
|
width="80"
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-4 align-top">
|
||||||
|
<h1 class="font-bold">{data.company.name}</h1>
|
||||||
|
<h2>Company code: <span class="font-semibold">{data.company.companyCode}</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Edit Company {data.company.name}</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Name <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={data.company?.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Description <span class="text-red-500">*</span>
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
rows="4">{data.company?.description}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Website <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="website"
|
||||||
|
name="website"
|
||||||
|
placeholder="Website"
|
||||||
|
type="text"
|
||||||
|
value={data.company?.website}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Save company
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
onclick={openConfirm}
|
||||||
|
type="button"
|
||||||
|
>Delete company
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form class="modal" id="deleteConfirmModal" method="POST" use:enhance>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Are you sure?</h2>
|
||||||
|
<button class="material-symbols-outlined" onclick={closeConfirm} type="button"
|
||||||
|
>close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
This will permanently delete company <span class="font-semibold"
|
||||||
|
>{data.company?.name}.</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p>Please type "I understand" into the box below to confirm</p>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="confirm"
|
||||||
|
name="confirm"
|
||||||
|
pattern="I understand"
|
||||||
|
placeholder="I understand"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button class="danger-bg-color rounded px-2 py-1" formaction="?/delete" type="submit"
|
||||||
|
>Delete company
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
onclick={closeConfirm}
|
||||||
|
type="button"
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type Actions, error, fail } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
addEmployerToCompany,
|
||||||
|
getCompanyEmployersAndRequests,
|
||||||
|
removeEmployerFromCompany
|
||||||
|
} from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { getUserCompanyId, getUserPerms } from '$lib/index.server';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.company);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
|
||||||
|
return await getCompanyEmployersAndRequests(id);
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
removeEmployer: async ({ params, url, cookies }) => {
|
||||||
|
const id = parseInt(params.company!);
|
||||||
|
const data = url.searchParams;
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0) ||
|
||||||
|
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeEmployerFromCompany(id, parseInt(data.get('userId')!));
|
||||||
|
},
|
||||||
|
addEmployer: async ({ params, url, cookies }) => {
|
||||||
|
const id = parseInt(params.company!);
|
||||||
|
const data = url.searchParams;
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0) ||
|
||||||
|
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await addEmployerToCompany(id, parseInt(data.get('userId')!));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function logoFallback(e: Event) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.company.name!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idToRemove: number | null = $state(null);
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="m-4">
|
||||||
|
<img
|
||||||
|
alt="User avatar"
|
||||||
|
class="mb-2 inline-block rounded-lg"
|
||||||
|
height="80"
|
||||||
|
onerror={logoFallback}
|
||||||
|
src="/uploads/logos/{data.company.id}.jpg?timestamp=${Date.now()}"
|
||||||
|
width="80"
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-4 align-top">
|
||||||
|
<h1 class="font-bold">{data.company.name}</h1>
|
||||||
|
<h2>Company code: <span class="font-semibold">{data.company.companyCode}</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Current Employers</div>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left w-16 py-1">ID</th>
|
||||||
|
<th class="py-1">Username</th>
|
||||||
|
<th class="py-1">Full Name</th>
|
||||||
|
<th class="py-1">Email</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
|
<th class="py-1">Last Sign-In</th>
|
||||||
|
<th class="py-1">Remove</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if data.employers !== undefined}
|
||||||
|
{#each data.employers as user}
|
||||||
|
{#if user.company?.id === data.id}
|
||||||
|
<tr class="h-8">
|
||||||
|
<td class="left">{user.id}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.fullName || 'N/A'}</td>
|
||||||
|
<td>{user.email || 'N/A'}</td>
|
||||||
|
<td
|
||||||
|
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td class="material-symbols-outlined hover-bg-color danger-color m-1 rounded">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
idToRemove = user.id;
|
||||||
|
}}
|
||||||
|
>close
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if idToRemove !== null}
|
||||||
|
<form method="POST" id="deleteConfirmModal" class="modal-always-display">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Are you sure?</h2>
|
||||||
|
<button
|
||||||
|
class="material-symbols-outlined"
|
||||||
|
onclick={() => {
|
||||||
|
idToRemove = null;
|
||||||
|
}}
|
||||||
|
>close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>This will remove this employer from the company.</p>
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button
|
||||||
|
class="danger-bg-color rounded px-2 py-1"
|
||||||
|
type="submit"
|
||||||
|
formaction="?/removeEmployer&userId={idToRemove}"
|
||||||
|
>Remove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
idToRemove = null;
|
||||||
|
}}
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if data.requests !== null}
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Pending requests</div>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="left w-16 py-1">ID</th>
|
||||||
|
<th class="py-1">Username</th>
|
||||||
|
<th class="py-1">Full Name</th>
|
||||||
|
<th class="py-1">Email</th>
|
||||||
|
<th class="py-1">Created</th>
|
||||||
|
<th class="py-1">Last Sign-In</th>
|
||||||
|
<th class="py-1">Approve</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.requests as user}
|
||||||
|
{#if user.company?.id !== data.id}
|
||||||
|
<tr class="h-8">
|
||||||
|
<td class="left">{user.id}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.fullName || 'N/A'}</td>
|
||||||
|
<td>{user.email || 'N/A'}</td>
|
||||||
|
<td
|
||||||
|
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
|
||||||
|
'unknown'}</td
|
||||||
|
>
|
||||||
|
<td class="material-symbols-outlined">
|
||||||
|
<form method="POST" class="flex">
|
||||||
|
<button
|
||||||
|
class="hover-bg-color m-1 rounded text-green-600"
|
||||||
|
formaction="?/addEmployer&userId={user.id}"
|
||||||
|
>check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="hover-bg-color danger-color m-1 rounded"
|
||||||
|
formaction="?/removeEmployer&userId={user.id}"
|
||||||
|
>close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { type Actions, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createCompany } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { getUserPerms } from '$lib/index.server';
|
||||||
|
import type { Company } from '$lib/types';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name')?.toString().trim();
|
||||||
|
let website = data.get('website')?.toString().trim();
|
||||||
|
const description = data.get('description')?.toString().trim();
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_COMPANIES) > 0) ||
|
||||||
|
(requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
if (!name || name === '' || !website || website === '' || !description || description === '') {
|
||||||
|
return fail(400, { errorMessage: 'All fields are required' });
|
||||||
|
}
|
||||||
|
if (!website.includes('.')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid website' });
|
||||||
|
}
|
||||||
|
if (!website.startsWith('http://') && !website.startsWith('https://'))
|
||||||
|
website = `https://${website}`;
|
||||||
|
|
||||||
|
let id: number;
|
||||||
|
try {
|
||||||
|
id = await createCompany(<Company>{
|
||||||
|
name: name,
|
||||||
|
website: website,
|
||||||
|
description: description
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
redirect(301, `/companies/${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Create new company</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Name <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Description <span class="text-red-500">*</span>
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Website <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="website"
|
||||||
|
name="website"
|
||||||
|
placeholder="Website"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Create company
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="base-container">
|
||||||
|
<div class="content elevated separator-borders m-2 rounded p-2">
|
||||||
|
<h1 class="font-bold">Info</h1>
|
||||||
|
<p>This page contains additional info about different parts of the app</p>
|
||||||
|
<h2 class="pt-2 font-semibold" id="company-codes">Company Codes</h2>
|
||||||
|
<p>
|
||||||
|
Company codes are unique identifiers of companies in order for an employer to associate
|
||||||
|
themselves to a company.
|
||||||
|
</p>
|
||||||
|
<p>If you are an applicant, you can safely ignore them.</p>
|
||||||
|
<p>
|
||||||
|
If you are an employer, get a code from your company admin, and input it into your account
|
||||||
|
page to request access to your company. Once approved by your administrator, you will have
|
||||||
|
access to create job postings.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you are your company admin, first create your account (without inputting a code). Reach out
|
||||||
|
to a CareerConnect admin to elevate your account privileges. Then, go to the company page, and
|
||||||
|
use the button in the top right to create a new company. Once created, you will be able to see
|
||||||
|
the company code, which you can then give to your employees.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getPostings } from '$lib/db/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
return {
|
||||||
|
postings: await getPostings(url.searchParams.get('searchQuery') as string)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { Posting } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { employmentTypeDisplayName, userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
let details: Posting | undefined = $state<Posting>();
|
||||||
|
|
||||||
|
// Formating for all dates on the page
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
// If logo isn't found on the server, use a placeholder
|
||||||
|
function logoFallback(e: Event, posting: Posting | undefined) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting?.company.name || 'COMPANY')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the detail pane content of a posting
|
||||||
|
async function fetchDetails(id: number) {
|
||||||
|
const response = await fetch(`/api/posting?id=${id}`);
|
||||||
|
details = await response.json();
|
||||||
|
if (details?.createdAt) {
|
||||||
|
details.createdAt = new Date(details.createdAt);
|
||||||
|
}
|
||||||
|
if (details?.updatedAt) {
|
||||||
|
details.updatedAt = new Date(details.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
// Initial fetch of the first posting
|
||||||
|
onMount(async () => {
|
||||||
|
await fetchDetails(data.postings[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wideScreen = new MediaQuery('min-width: 768px');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content">
|
||||||
|
<div class="bottom-border flex justify-between pb-2">
|
||||||
|
<h1 class="inline-block p-2">Job Postings</h1>
|
||||||
|
{#if userState.companyId && (userState.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0}
|
||||||
|
<a class="dull-primary-bg-color m-2 h-min rounded-md px-2.5 py-1" href="/postings/create"
|
||||||
|
>Create new posting</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<form action="" class="flex p-4">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
class="search-cancel"
|
||||||
|
id="searchQuery"
|
||||||
|
name="searchQuery"
|
||||||
|
placeholder="Search Postings"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
<button><span class="material-symbols-outlined">search</span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="right-border top-border inline-block {wideScreen.current ? 'w-1/3' : 'w-full'}">
|
||||||
|
{#each data.postings as posting}
|
||||||
|
<button
|
||||||
|
class="bottom-border left-border block w-full p-4 text-left {details?.id ===
|
||||||
|
posting.id && wideScreen.current
|
||||||
|
? 'accent-bg-color'
|
||||||
|
: 'hover-bg-color'}"
|
||||||
|
onclick={() => {
|
||||||
|
if (wideScreen.current) {
|
||||||
|
fetchDetails(posting.id);
|
||||||
|
} else {
|
||||||
|
window.location.href = `/postings/${posting.id}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="inline-block rounded"
|
||||||
|
src="/uploads/logos/{posting.companyId}.jpg"
|
||||||
|
alt="Company Logo"
|
||||||
|
height="48"
|
||||||
|
width="48"
|
||||||
|
onerror={(e) => logoFallback(e, posting)}
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<h2 class="font-semibold">{posting.title}</h2>
|
||||||
|
<h3>{posting.company.name}</h3>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if wideScreen.current}
|
||||||
|
{#if details !== undefined}
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders top-with-navbar sticky mb-2 ml-2 inline-block h-min w-2/3 rounded p-4"
|
||||||
|
>
|
||||||
|
<div class="bottom-border flex justify-between pb-2">
|
||||||
|
<div class="inline-block">
|
||||||
|
<img
|
||||||
|
class="inline-block rounded"
|
||||||
|
src="/uploads/logos/{details.company.id || 'default'}.jpg"
|
||||||
|
alt="Company Logo"
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
onerror={(e) => logoFallback(e, details)}
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<h1>{details.title}</h1>
|
||||||
|
<a href="/companies/{details.company.id}" class="hover-hyperlink text-xl"
|
||||||
|
>{details.company.name}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if userState.perms >= 0 && ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0 || ((userState.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && userState.companyId === details.company.id))}
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
|
||||||
|
href="/postings/{details.id}/manage/applications/">Manage posting</a
|
||||||
|
>
|
||||||
|
{:else if (userState.perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
|
||||||
|
href="/postings/{details.id}/apply">Apply</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
Sign-in to apply
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="scrollbar-on-elevated details-height overflow-y-scroll">
|
||||||
|
<h2 class="pt-2 font-semibold">Contact</h2>
|
||||||
|
<p>{details.employer?.fullName} ({details.employer?.username})</p>
|
||||||
|
<a class="hover-hyperlink block" href="mailto:{details.employer?.email}"
|
||||||
|
>{details.employer?.email}</a
|
||||||
|
>
|
||||||
|
<a class="hover-hyperlink block" href="tel:{details.employer?.phone}"
|
||||||
|
>{details.employer?.phone}</a
|
||||||
|
>
|
||||||
|
<h2 class="pt-2 font-semibold">Details</h2>
|
||||||
|
{#if details.employmentType}
|
||||||
|
<p>{employmentTypeDisplayName(details.employmentType)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if details.address}
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={details.address}"
|
||||||
|
class="block"
|
||||||
|
>Address: <span class="hover-hyperlink break-words">{details.address}</span></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if details.wage}
|
||||||
|
<p>Wage: {details.wage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if details.createdAt}
|
||||||
|
<p>Posted: {details.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if details.link}
|
||||||
|
<a href={details.link} class="block"
|
||||||
|
>More information: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{details.link}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if details.flyerLink}
|
||||||
|
<a href={details.flyerLink} class="block"
|
||||||
|
>Flyer: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{details.flyerLink}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<h2 class="pt-2 font-semibold">Job Description</h2>
|
||||||
|
<p class="whitespace-pre-wrap">{details.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getPostingWithCompanyUser } from '$lib/db/index.server';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
return {
|
||||||
|
posting: await getPostingWithCompanyUser(parseInt(params.posting))
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { Posting } from '$lib/types';
|
||||||
|
import { employmentTypeDisplayName, userState } from '$lib/shared.svelte';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function logoFallback(e: Event, posting: Posting) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container-small">
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 inline-block h-min w-full rounded p-4">
|
||||||
|
<div class="bottom-border elevated-bg flex justify-between pb-2">
|
||||||
|
<div class="inline-block">
|
||||||
|
<img
|
||||||
|
alt="Company Logo"
|
||||||
|
class="inline-block rounded"
|
||||||
|
height="64"
|
||||||
|
onerror={(e) => logoFallback(e, data.posting)}
|
||||||
|
src="/uploads/logos/{data.posting.company.id}.jpg"
|
||||||
|
width="64"
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<h1>{data.posting.title}</h1>
|
||||||
|
<a class="hover-hyperlink text-xl" href="/companies/{data.posting.company.id}"
|
||||||
|
>{data.posting.company.name}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if userState.perms >= 0 && ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0 || ((userState.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && userState.companyId === data.posting.company.id))}
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
|
||||||
|
href="/postings/{data.posting.id}/manage/applications/">Manage posting</a
|
||||||
|
>
|
||||||
|
{:else if (userState.perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
|
||||||
|
<a
|
||||||
|
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
|
||||||
|
href="/postings/{data.posting.id}/apply">Apply</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="scrollbar-on-elevated overflow-y-scroll">
|
||||||
|
<h2 class="pt-2 font-semibold">Contact</h2>
|
||||||
|
<p>{data.posting.employer?.fullName} ({data.posting.employer?.username})</p>
|
||||||
|
<a class="hover-hyperlink block" href="mailto:{data.posting.employer?.email}"
|
||||||
|
>{data.posting.employer?.email}</a
|
||||||
|
>
|
||||||
|
<a class="hover-hyperlink block" href="tel:{data.posting.employer?.phone}"
|
||||||
|
>{data.posting.employer?.phone}</a
|
||||||
|
>
|
||||||
|
<h2 class="pt-2 font-semibold">Details</h2>
|
||||||
|
{#if data.posting.employmentType}
|
||||||
|
<p>{employmentTypeDisplayName(data.posting.employmentType)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.address}
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={data.posting.address}"
|
||||||
|
class="block"
|
||||||
|
>Address: <span class="hover-hyperlink break-words">{data.posting.address}</span></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.wage}
|
||||||
|
<p>Wage: {data.posting.wage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.createdAt}
|
||||||
|
<p>Posted: {data.posting.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.link}
|
||||||
|
<a href={data.posting.link} class="block"
|
||||||
|
>More information: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{data.posting.link}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.flyerLink}
|
||||||
|
<a href={data.posting.flyerLink} class="block"
|
||||||
|
>Flyer: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{data.posting.flyerLink}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<h2 class="pt-2 font-semibold">Job Description</h2>
|
||||||
|
<p class="whitespace-pre-wrap break-words">
|
||||||
|
{data.posting.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { createApplication, getPostingWithCompanyUser } from '$lib/db/index.server';
|
||||||
|
import { getUserId, getUserPerms } from '$lib/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import type { Application } from '$lib/types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
|
|
||||||
|
// Permission check (apply perm)
|
||||||
|
const id = parseInt(params.posting);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (perms >= 0 && (perms & PERMISSIONS.APPLY_FOR_JOBS) > 0) {
|
||||||
|
return {
|
||||||
|
posting: await getPostingWithCompanyUser(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
// Application submission
|
||||||
|
submit: async ({ request, cookies, params }) => {
|
||||||
|
// Permission check (apply perm)
|
||||||
|
if (!((getUserPerms(cookies) & PERMISSIONS.APPLY_FOR_JOBS) > 0)) {
|
||||||
|
return fail(403, { errorMessage: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const candidateStatement = data.get('candidateStatement')?.toString().trim();
|
||||||
|
|
||||||
|
const postingId = parseInt(params.posting!);
|
||||||
|
const userId = getUserId(cookies);
|
||||||
|
|
||||||
|
// Statement data validation
|
||||||
|
if (!candidateStatement || candidateStatement === '') {
|
||||||
|
return fail(400, { errorMessage: 'Candidate statement is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to DB and redirect
|
||||||
|
await createApplication(<Application>{ userId, postingId, candidateStatement });
|
||||||
|
redirect(301, `/postings`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { employmentTypeDisplayName } from '$lib/shared.svelte';
|
||||||
|
import type { Posting } from '$lib/types';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function logoFallback(e: Event, posting: Posting) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeScreen = new MediaQuery('min-width: 1024px');
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet jobDetails()}
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders my-2 h-min rounded p-4 {largeScreen.current
|
||||||
|
? 'mr-2 inline-block w-1/2'
|
||||||
|
: 'block w-full'}"
|
||||||
|
>
|
||||||
|
<div class="bottom-border elevated-bg flex justify-between pb-2">
|
||||||
|
<div class="inline-block">
|
||||||
|
<img
|
||||||
|
alt="Company Logo"
|
||||||
|
class="inline-block rounded"
|
||||||
|
height="64"
|
||||||
|
onerror={(e) => logoFallback(e, data.posting)}
|
||||||
|
src="/uploads/logos/{data.posting?.company.id}.jpg"
|
||||||
|
width="64"
|
||||||
|
/>
|
||||||
|
<div class="inline-block pl-2 align-top">
|
||||||
|
<h1>{data.posting.title}</h1>
|
||||||
|
<h2>Company: {data.posting.company.name}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scrollbar-on-elevated overflow-y-scroll">
|
||||||
|
<h2 class="pt-2 font-semibold">Contact</h2>
|
||||||
|
<p>{data.posting.employer?.fullName} ({data.posting.employer?.username})</p>
|
||||||
|
<a class="hover-hyperlink" href="mailto:{data.posting.employer?.email}"
|
||||||
|
>{data.posting.employer?.email}</a
|
||||||
|
>
|
||||||
|
<a class="hover-hyperlink" href="tel:{data.posting.employer?.phone}"
|
||||||
|
>{data.posting.employer?.phone}</a
|
||||||
|
>
|
||||||
|
<h2 class="pt-2 font-semibold">Details</h2>
|
||||||
|
{#if data.posting.employmentType}
|
||||||
|
<p>{employmentTypeDisplayName(data.posting.employmentType)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.address}
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={data.posting.address}"
|
||||||
|
class="block"
|
||||||
|
>Address: <span class="hover-hyperlink break-words">{data.posting.address}</span></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.wage}
|
||||||
|
<p>Wage: {data.posting.wage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.createdAt}
|
||||||
|
<p>Posted: {data.posting.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.link}
|
||||||
|
<a href={data.posting.link} class="block"
|
||||||
|
>More information: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{data.posting.link}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.posting.flyerLink}
|
||||||
|
<a href={data.posting.flyerLink} class="block"
|
||||||
|
>Flyer: <span class="hyperlink-color hyperlink-underline break-all"
|
||||||
|
>{data.posting.flyerLink}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<h2 class="pt-2 font-semibold">Job Description</h2>
|
||||||
|
<p class="whitespace-pre-wrap break-words">{data.posting.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content {largeScreen.current ? 'flex' : 'block'}">
|
||||||
|
{#if largeScreen.current}
|
||||||
|
{@render jobDetails()}
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="elevated separator-borders my-2 h-min rounded {largeScreen.current
|
||||||
|
? 'inline-block w-1/2'
|
||||||
|
: 'block w-full'}"
|
||||||
|
>
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Apply</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Why do you believe you are the best fit for this role? <span class="text-red-500">*</span>
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="candidateStatement"
|
||||||
|
name="candidateStatement"
|
||||||
|
placeholder="Answer here"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Your account information and résumé (if supplied) will be submitted along with this
|
||||||
|
application. If there is any other information you would like the employer to know, please
|
||||||
|
add it in the box above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Submit application
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{#if !largeScreen.current}
|
||||||
|
{@render jobDetails()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
|
let { children }: LayoutProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bottom-border h-10 pt-2 text-center">
|
||||||
|
<a
|
||||||
|
class="p-2 {page.url.pathname.endsWith('applications')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
|
href="applications"
|
||||||
|
><span class="material-symbols-outlined align-bottom">description</span> Applications</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="p-2 {page.url.pathname.endsWith('edit')
|
||||||
|
? 'primary-underline font-bold'
|
||||||
|
: 'low-emphasis-text low-emphasis-text-button'}"
|
||||||
|
href="edit"><span class="material-symbols-outlined align-bottom">work</span> Details</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script>editApplication
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.location.href = 'manage/applications';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getUserCompanyId, getUserPerms } from '$lib/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { deleteApplication, getApplications } from '$lib/db/index.server';
|
||||||
|
import { type Actions, error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
|
const id = parseInt(params.posting);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
perms >= 0 &&
|
||||||
|
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
|
||||||
|
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies)))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
applications: await getApplications(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
delete: async ({ url, cookies }) => {
|
||||||
|
const id = parseInt(url.searchParams.get('id')!);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
perms >= 0 &&
|
||||||
|
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
|
||||||
|
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
|
||||||
|
) {
|
||||||
|
return await deleteApplication(id);
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { Application } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
function logoFallback(e: Event, application: Application) {
|
||||||
|
(e.target as HTMLImageElement).src =
|
||||||
|
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(application.user.fullName || 'USER')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const acc = document.getElementsByClassName('accordion');
|
||||||
|
|
||||||
|
for (let i = 0; i < acc.length; i++) {
|
||||||
|
acc[i].addEventListener('click', function (this: HTMLElement) {
|
||||||
|
this.classList.toggle('active');
|
||||||
|
this.children[1].innerHTML = this.classList.contains('active')
|
||||||
|
? 'arrow_drop_up'
|
||||||
|
: 'arrow_drop_down';
|
||||||
|
|
||||||
|
/* Toggle between hiding and showing the active panel */
|
||||||
|
let panel = this.nextElementSibling as HTMLElement;
|
||||||
|
if (panel.style.display === 'flex') {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">
|
||||||
|
Application Management (Total: {data.applications?.length || 0})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#each data.applications as application}
|
||||||
|
<button class="top-border accordion flex place-content-between">
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
class="m-2 inline-block rounded"
|
||||||
|
src="/uploads/avatars/{application.user?.id || 'default'}.svg"
|
||||||
|
alt="User Avatar"
|
||||||
|
height="32"
|
||||||
|
width="32"
|
||||||
|
onerror={(e) => logoFallback(e, application)}
|
||||||
|
/>
|
||||||
|
<div class="inline-block py-1 text-left align-top">
|
||||||
|
<div class="font-semibold">
|
||||||
|
{application.user?.fullName}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{application.user?.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="material-symbols-outlined p-4">arrow_drop_down</div>
|
||||||
|
</button>
|
||||||
|
<div class="panel hidden p-2">
|
||||||
|
<div class="flex w-full items-start justify-between">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="inline-block min-w-max pr-4">
|
||||||
|
{#if application.user?.email}
|
||||||
|
<p>Email: {application.user.email}</p>
|
||||||
|
{/if}
|
||||||
|
{#if application.user?.phone}
|
||||||
|
<p>Phone: {application.user.phone}</p>
|
||||||
|
{/if}
|
||||||
|
{#if application.createdAt}
|
||||||
|
<p>
|
||||||
|
Applied: {application.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if application.user?.resume}
|
||||||
|
<a
|
||||||
|
class="hyperlink-underline hyperlink-color"
|
||||||
|
href="/uploads/resumes/{application.user.id}.pdf"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Résumé
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="left-border inline-block pl-4">
|
||||||
|
<h2>Candidate Statement</h2>
|
||||||
|
<h3 class="low-emphasis-text">
|
||||||
|
Why do you believe you are the best fit for this role?
|
||||||
|
</h3>
|
||||||
|
<p class="whitespace-pre-wrap">{application.candidateStatement}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" class="inline-block">
|
||||||
|
<button
|
||||||
|
class="material-symbols-outlined danger-color inline-block p-2 align-top"
|
||||||
|
type="submit"
|
||||||
|
formaction="?/delete&id={application.id}"
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { deletePosting, editPosting, getPosting } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { getUserCompanyId, getUserId, getUserPerms } from '$lib/index.server';
|
||||||
|
import type { Posting } from '$lib/types';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.posting);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
|
||||||
|
// TODO check if user is allowed to edit this posting
|
||||||
|
if (
|
||||||
|
perms >= 0 &&
|
||||||
|
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
|
||||||
|
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies)))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
posting: await getPosting(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies, params }) => {
|
||||||
|
const id = parseInt(params.posting!);
|
||||||
|
const employerId = getUserId(cookies);
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
let companyId: number | null | undefined = getUserCompanyId(cookies);
|
||||||
|
if (!companyId) {
|
||||||
|
if (
|
||||||
|
data.get('companyId')?.toString().trim() === undefined ||
|
||||||
|
data.get('companyId')?.toString().trim() === null ||
|
||||||
|
data.get('companyId')?.toString().trim() === ''
|
||||||
|
) {
|
||||||
|
return fail(400, { errorMessage: 'Company ID is required' });
|
||||||
|
} else {
|
||||||
|
companyId = parseInt(data.get('companyId')?.toString().trim()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.get('title')?.toString().trim();
|
||||||
|
const description = data.get('description')?.toString().trim();
|
||||||
|
const address = data.get('address')?.toString().trim();
|
||||||
|
const employmentType = data.get('employmentType')?.toString().trim();
|
||||||
|
const wage = data.get('wage')?.toString().trim();
|
||||||
|
let link = data.get('link')?.toString().trim();
|
||||||
|
const tagIds = data.get('tagIds')?.toString().trim() || null;
|
||||||
|
let flyerLink = data.get('flyerLink')?.toString().trim();
|
||||||
|
|
||||||
|
if (link && !link?.includes('.')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid link' });
|
||||||
|
}
|
||||||
|
if (flyerLink && !flyerLink?.includes('.')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid flyer link' });
|
||||||
|
}
|
||||||
|
if (link && !link.startsWith('http://') && !link.startsWith('https://'))
|
||||||
|
link = `https://${link}`;
|
||||||
|
if (flyerLink && !flyerLink.startsWith('http://') && !flyerLink.startsWith('https://'))
|
||||||
|
flyerLink = `https://${flyerLink}`;
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
requestPerms >= 0 &&
|
||||||
|
((requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
|
||||||
|
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 &&
|
||||||
|
getUserCompanyId(cookies) === employerId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!title ||
|
||||||
|
title === '' ||
|
||||||
|
!description ||
|
||||||
|
description === '' ||
|
||||||
|
!address ||
|
||||||
|
address === '' ||
|
||||||
|
!employmentType ||
|
||||||
|
employmentType === ''
|
||||||
|
) {
|
||||||
|
return fail(400, { errorMessage: 'All fields are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editPosting(<Posting>{
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
employerId,
|
||||||
|
address,
|
||||||
|
employmentType,
|
||||||
|
wage,
|
||||||
|
link,
|
||||||
|
tagIds: tagIds?.split(',').map((tag) => parseInt(tag)),
|
||||||
|
companyId,
|
||||||
|
flyerLink
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
redirect(301, `/postings/${id}`);
|
||||||
|
},
|
||||||
|
delete: async ({ cookies, params }) => {
|
||||||
|
const id = parseInt(params.posting!);
|
||||||
|
const perms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
perms >= 0 &&
|
||||||
|
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
|
||||||
|
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await deletePosting(id);
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
redirect(301, '/postings');
|
||||||
|
}
|
||||||
|
error(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { userState } from '$lib/shared.svelte.js';
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('deleteConfirmModal')!.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders my-2 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Edit {data.posting.title}</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
{#if !userState.companyId}
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Company ID <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="companyId"
|
||||||
|
id="companyId"
|
||||||
|
placeholder="Company ID"
|
||||||
|
value={data.posting.companyId}
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Title <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={data.posting.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Description <span class="text-red-500">*</span>
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
required
|
||||||
|
rows="4">{data.posting.description}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Address <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="address"
|
||||||
|
name="address"
|
||||||
|
placeholder="Address"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={data.posting.address}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
<label for="employmentType">Employment type <span class="text-red-500">*</span></label>
|
||||||
|
<select
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="employmentType"
|
||||||
|
name="employmentType"
|
||||||
|
value={data.posting.employmentType}
|
||||||
|
>
|
||||||
|
<option value="full_time">Full time</option>
|
||||||
|
<option value="part_time">Part time</option>
|
||||||
|
<option value="internship">Internship</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Wage (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="wage"
|
||||||
|
name="wage"
|
||||||
|
placeholder="Wage"
|
||||||
|
type="text"
|
||||||
|
value={data.posting.wage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Link to external posting information (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="link"
|
||||||
|
name="link"
|
||||||
|
placeholder="Link"
|
||||||
|
type="text"
|
||||||
|
value={data.posting.link}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Link to flyer (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="flyerLink"
|
||||||
|
name="flyerLink"
|
||||||
|
placeholder="Flyer link"
|
||||||
|
type="text"
|
||||||
|
value={data.posting.flyerLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Save posting
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
|
||||||
|
onclick={openConfirm}
|
||||||
|
type="button"
|
||||||
|
>Delete posting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="modal" id="deleteConfirmModal" method="POST">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="mb-2 inline-flex w-full justify-between">
|
||||||
|
<h2 class="font-semibold">Are you sure?</h2>
|
||||||
|
<button class="material-symbols-outlined" onclick={closeConfirm}>close</button>
|
||||||
|
</div>
|
||||||
|
<p>This will permanently delete this posting. This action cannot be undone.</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button class="danger-bg-color rounded px-2 py-1" formaction="?/delete" type="submit"
|
||||||
|
>Delete posting
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="separator-borders bg-color rounded px-2 py-1"
|
||||||
|
onclick={closeConfirm}
|
||||||
|
type="button"
|
||||||
|
>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { type Actions, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createPosting } from '$lib/db/index.server';
|
||||||
|
import { PERMISSIONS } from '$lib/consts';
|
||||||
|
import { getUserCompanyId, getUserId, getUserPerms } from '$lib/index.server';
|
||||||
|
import type { Posting } from '$lib/types';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
submit: async ({ request, cookies }) => {
|
||||||
|
const employerId = getUserId(cookies);
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
let companyId: number | null | undefined = getUserCompanyId(cookies);
|
||||||
|
if (!companyId) {
|
||||||
|
if (
|
||||||
|
data.get('companyId')?.toString().trim() === undefined ||
|
||||||
|
data.get('companyId')?.toString().trim() === null ||
|
||||||
|
data.get('companyId')?.toString().trim() === ''
|
||||||
|
) {
|
||||||
|
return fail(400, { errorMessage: 'Company ID is required' });
|
||||||
|
} else {
|
||||||
|
companyId = parseInt(data.get('companyId')?.toString().trim()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.get('title')?.toString().trim();
|
||||||
|
const description = data.get('description')?.toString().trim();
|
||||||
|
const address = data.get('address')?.toString().trim();
|
||||||
|
const employmentType = data.get('employmentType')?.toString().trim();
|
||||||
|
const wage = data.get('wage')?.toString().trim();
|
||||||
|
let link = data.get('link')?.toString().trim();
|
||||||
|
const tagIds = data.get('tagIds')?.toString().trim() || null;
|
||||||
|
let flyerLink = data.get('flyerLink')?.toString().trim();
|
||||||
|
|
||||||
|
if (link && !link?.includes('.')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid link' });
|
||||||
|
}
|
||||||
|
if (flyerLink && !flyerLink?.includes('.')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid flyer link' });
|
||||||
|
}
|
||||||
|
if (link && !link.startsWith('http://') && !link.startsWith('https://'))
|
||||||
|
link = `https://${link}`;
|
||||||
|
if (flyerLink && !flyerLink.startsWith('http://') && !flyerLink.startsWith('https://'))
|
||||||
|
flyerLink = `https://${flyerLink}`;
|
||||||
|
|
||||||
|
const requestPerms = getUserPerms(cookies);
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
requestPerms >= 0 &&
|
||||||
|
((requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
|
||||||
|
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 &&
|
||||||
|
getUserCompanyId(cookies) === companyId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(403, { errorMessage: 'You cannot preform this action!' });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!title ||
|
||||||
|
title === '' ||
|
||||||
|
!address ||
|
||||||
|
address === '' ||
|
||||||
|
!description ||
|
||||||
|
description === '' ||
|
||||||
|
!employmentType ||
|
||||||
|
employmentType === '' ||
|
||||||
|
!address ||
|
||||||
|
address === ''
|
||||||
|
) {
|
||||||
|
return fail(400, { errorMessage: 'All fields are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: number;
|
||||||
|
try {
|
||||||
|
id = await createPosting(<Posting>{
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
employerId,
|
||||||
|
address,
|
||||||
|
employmentType,
|
||||||
|
wage,
|
||||||
|
link,
|
||||||
|
tagIds: tagIds?.split(',').map((tag) => parseInt(tag)),
|
||||||
|
companyId,
|
||||||
|
flyerLink
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
redirect(301, `/postings/${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { userState } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="base-container">
|
||||||
|
<div class="content">
|
||||||
|
<div class="elevated separator-borders m-4 rounded">
|
||||||
|
<div class="bottom-border flex place-content-between">
|
||||||
|
<div class="p-3 font-semibold">Create new posting</div>
|
||||||
|
</div>
|
||||||
|
<form autocomplete="off" class="px-4" method="POST" use:enhance>
|
||||||
|
{#if !userState.companyId}
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Company ID <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="companyId"
|
||||||
|
id="companyId"
|
||||||
|
placeholder="Company ID"
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Title <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
placeholder="Title"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Description <span class="text-red-500">*</span>
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Address <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="address"
|
||||||
|
name="address"
|
||||||
|
placeholder="Address"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
<label for="employmentType">Employment type <span class="text-red-500">*</span></label>
|
||||||
|
<select class="w-full rounded font-normal" id="employmentType" name="employmentType">
|
||||||
|
<option value="full_time">Full time</option>
|
||||||
|
<option value="part_time">Part time</option>
|
||||||
|
<option value="internship">Internship</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Wage (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="wage"
|
||||||
|
name="wage"
|
||||||
|
placeholder="Wage"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Link to external posting information (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="link"
|
||||||
|
name="link"
|
||||||
|
placeholder="Link"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Link to flyer (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="flyerLink"
|
||||||
|
name="flyerLink"
|
||||||
|
placeholder="Flyer link"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="mb-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
|
||||||
|
formaction="?/submit"
|
||||||
|
type="submit"
|
||||||
|
>Create posting
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import { type Actions, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createUser } from '$lib/db/index.server';
|
||||||
|
|
||||||
|
import { setJWT } from '$lib/shared.server';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env' });
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
register: async ({ request, cookies }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const username = data.get('username')?.toString().trim();
|
||||||
|
const password = data.get('password')?.toString().trim();
|
||||||
|
const confirmPassword = data.get('confirmPassword')?.toString().trim();
|
||||||
|
let email: string | undefined | null = data.get('email')?.toString().trim();
|
||||||
|
let phone: string | undefined | null = data.get('phone')?.toString().trim();
|
||||||
|
let companyCode: string | undefined | null = data.get('companyCode')?.toString().trim();
|
||||||
|
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
|
||||||
|
if (phone === '') phone = null;
|
||||||
|
if (companyCode === '') companyCode = null;
|
||||||
|
|
||||||
|
if (email && !email.includes('@')) {
|
||||||
|
return fail(400, { errorMessage: 'Invalid email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
username &&
|
||||||
|
password &&
|
||||||
|
confirmPassword &&
|
||||||
|
email &&
|
||||||
|
fullName &&
|
||||||
|
username !== '' &&
|
||||||
|
password !== '' &&
|
||||||
|
confirmPassword !== '' &&
|
||||||
|
email !== '' &&
|
||||||
|
fullName !== ''
|
||||||
|
) {
|
||||||
|
if (password.length < 4) {
|
||||||
|
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
|
||||||
|
}
|
||||||
|
const user: User = <User>{
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
perms: 3,
|
||||||
|
active: true,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
fullName: fullName,
|
||||||
|
companyCode: companyCode
|
||||||
|
};
|
||||||
|
if (password === confirmPassword) {
|
||||||
|
try {
|
||||||
|
await createUser(user);
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
|
||||||
|
}
|
||||||
|
setJWT(cookies, user);
|
||||||
|
throw redirect(303, '/account');
|
||||||
|
} else {
|
||||||
|
return fail(400, { errorMessage: 'Passwords do not match' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fail(400, { errorMessage: 'Please fill out all required fields' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { telFormatter } from '$lib/shared.svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (document.cookie.includes('jwt=')) {
|
||||||
|
window.location.href = '/account';
|
||||||
|
}
|
||||||
|
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
|
||||||
|
this.value = telFormatter(this.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// receive form data from server
|
||||||
|
let { data, form }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="signin-container place-items-center pt-8">
|
||||||
|
<div class="elevated separator-borders bg content mb-4 rounded-md p-8">
|
||||||
|
<h1 class="text-weight-semibold mb-4 text-center">Register</h1>
|
||||||
|
<p>Create your account. Its free and only takes a minute!</p>
|
||||||
|
<form class="arrange-vertically" method="POST" use:enhance>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Username <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="input-field w-full font-normal"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative mt-4 text-sm font-semibold">
|
||||||
|
Password <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="input-field w-full font-normal"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative mt-4 text-sm font-semibold">
|
||||||
|
Confirm password <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="input-field w-full font-normal"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Full name <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="input-field w-full font-normal"
|
||||||
|
id="fullName"
|
||||||
|
name="fullName"
|
||||||
|
placeholder="Full name"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Email <span class="text-red-500">*</span>
|
||||||
|
<input
|
||||||
|
class="input-field w-full font-normal"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-sm font-semibold">
|
||||||
|
Phone (optional)
|
||||||
|
<input
|
||||||
|
class="w-full rounded font-normal"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
|
||||||
|
placeholder="Phone"
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative mt-4 text-sm font-semibold">
|
||||||
|
<label for="companyCode"> Company code (optional) </label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
class="input-field w-full pr-10 font-normal"
|
||||||
|
id="companyCode"
|
||||||
|
name="companyCode"
|
||||||
|
placeholder="Company code"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
class="hyperlink-color tooltip absolute inset-y-0 right-2 flex items-center"
|
||||||
|
href="/info#company-codes"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined">info</span><span
|
||||||
|
class="tooltip-text font-sans text-sm font-normal">About company codes</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.errorMessage}
|
||||||
|
<div class="my-2 text-red-500">{form.errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
|
||||||
|
formaction="?/register"
|
||||||
|
type="submit"
|
||||||
|
>Create account
|
||||||
|
</button>
|
||||||
|
<a class="low-emphasis-text-button mt-2" href="/signin">I already have an account.</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,60 +1,36 @@
|
|||||||
import { checkUserCreds, createUser } from '$lib/db/index.server';
|
import { checkUserCreds, updateLastSignin } from '$lib/db/index.server';
|
||||||
import { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit';
|
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import { setJWT } from '$lib/shared.server';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
dotenv.config({ path: '.env' });
|
dotenv.config({ path: '.env' });
|
||||||
|
|
||||||
function setJWT(cookies: Cookies, username: string, perms: number) {
|
|
||||||
const payload = {
|
|
||||||
username: username,
|
|
||||||
perms: perms
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.env.JWT_SECRET === undefined) {
|
|
||||||
throw new Error('JWT_SECRET not defined');
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAge = 60 * 60 * 24 * 30; // 30 days
|
|
||||||
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
|
|
||||||
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
|
|
||||||
console.log(cookies.get('jwt'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
register: async ({ request, cookies }) => {
|
signin: async ({ request, cookies }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const username = data.get('username')?.toString();
|
const username = data.get('username')?.toString().trim();
|
||||||
const password = data.get('password')?.toString();
|
const password = data.get('password')?.toString().trim();
|
||||||
|
|
||||||
if (username && password) {
|
if (username && password && username !== '' && password !== '') {
|
||||||
try {
|
const user: User | null = await checkUserCreds(username, password);
|
||||||
await createUser(username, password);
|
|
||||||
} catch (err) {
|
|
||||||
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fail(400, { errorMessage: 'Missing username or password' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
login: async ({ request, cookies }) => {
|
if (!user) {
|
||||||
const data = await request.formData();
|
return fail(400, { errorMessage: 'Invalid username or password' });
|
||||||
const username = data.get('username')?.toString();
|
|
||||||
const password = data.get('password')?.toString();
|
|
||||||
|
|
||||||
if (username && password) {
|
|
||||||
const perms = await checkUserCreds(username, password);
|
|
||||||
|
|
||||||
if (perms === -1) {
|
|
||||||
return fail(401, { errorMessage: 'Invalid username or password' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setJWT(cookies, username, perms);
|
if (!user.active) {
|
||||||
|
return fail(400, {
|
||||||
|
errorMessage:
|
||||||
|
'Account is disabled. Please contact your admin if you think this is a mistake.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setJWT(cookies, user);
|
||||||
|
await updateLastSignin(username);
|
||||||
// redirect to home page
|
// redirect to home page
|
||||||
// return { perms: perms };
|
throw redirect(303, '/account?refresh=true');
|
||||||
throw redirect(303, '/');
|
|
||||||
} else {
|
} else {
|
||||||
return fail(400, { errorMessage: 'Missing username or password' });
|
return fail(400, { errorMessage: 'Missing username or password' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (document.cookie.includes('jwt=')) {
|
||||||
|
window.location.href = '/account';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let passwordVisible = $state(false);
|
||||||
|
|
||||||
|
function showPassword() {
|
||||||
|
const password = document.querySelector('input[name="password"]');
|
||||||
|
if (password) {
|
||||||
|
if (password.getAttribute('type') === 'password') {
|
||||||
|
password.setAttribute('type', 'text');
|
||||||
|
passwordVisible = true;
|
||||||
|
} else {
|
||||||
|
password.setAttribute('type', 'password');
|
||||||
|
passwordVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// receive form data from server
|
// receive form data from server
|
||||||
let form: ActionData;
|
let { data, form }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="signin-container">
|
||||||
<h1 class="is-size-3 has-text-weight-semibold my-4">Sign In or Register</h1>
|
<div class="content my-2">
|
||||||
<form method="POST">
|
<div class="separator-borders elevated rounded-md px-2 py-8">
|
||||||
<input class="input my-2" type="text" placeholder="Username" name="username" required />
|
<h1 class="text-weight-semibold mb-4 text-center">Welcome Back!</h1>
|
||||||
<input class="input my-2" type="password" placeholder="Password" name="password" required />
|
<form class="arrange-vertically" method="POST" use:enhance>
|
||||||
|
<div class="bg-color my-2 rounded">
|
||||||
|
<input
|
||||||
|
class="input-field w-full"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full">
|
||||||
|
<div class="bg-color mt-4 rounded">
|
||||||
|
<input
|
||||||
|
class="input-field w-full pr-10"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
type={passwordVisible ? 'text' : 'password'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="absolute right-2.5 top-6 -translate-y-1/2 transform"
|
||||||
|
onclick={showPassword}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined"
|
||||||
|
>{passwordVisible ? 'visibility' : 'visibility_off'}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- display error message -->
|
|
||||||
{#if form?.errorMessage}
|
{#if form?.errorMessage}
|
||||||
<div class="has-text-danger my-2">{form.errorMessage}</div>
|
<div class="my-2 text-red-500">{form.errorMessage}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button class="button mr-3 mt-4" type="submit" formaction="?/register">Register</button>
|
<button
|
||||||
<button class="button is-primary mt-4" type="submit" formaction="?/login">Sign In</button>
|
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
|
||||||
|
formaction="?/signin"
|
||||||
|
type="submit"
|
||||||
|
>Sign In
|
||||||
|
</button>
|
||||||
|
<a class="low-emphasis-text-button mt-2" href="/register"
|
||||||
|
>Don't have an account? Register here.</a
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { sveltePreprocess } from 'svelte-preprocess';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
preprocess: vitePreprocess(),
|
preprocess: sveltePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter()
|
adapter: adapter()
|
||||||
|
|||||||
Reference in New Issue
Block a user