Compare commits

..
38 Commits
Author SHA1 Message Date
drake d10d64ec0a change dark colors
ci / docker_image (push) Successful in 2m23s
ci / deploy (push) Successful in 24s
2025-06-30 04:18:01 +00:00
jenkins 4146352697 COLORS
ci / docker_image (push) Successful in 2m23s
ci / deploy (push) Successful in 24s
2025-06-21 22:50:18 -05:00
jenkins 8e1e90761b constrast + bug fixes
ci / docker_image (push) Successful in 2m25s
ci / deploy (push) Successful in 24s
2025-06-21 22:42:06 -05:00
jenkins 5a48d5a2bb fixes
ci / docker_image (push) Successful in 2m26s
ci / deploy (push) Successful in 23s
2025-06-19 22:23:54 -05:00
jenkins eebd44faf8 fix signin, apply, and other
ci / docker_image (push) Successful in 2m41s
ci / deploy (push) Successful in 24s
2025-06-19 22:12:51 -05:00
jenkins c4567270b5 fix companies and improve landing page
ci / docker_image (push) Successful in 2m28s
ci / deploy (push) Successful in 23s
2025-06-19 21:04:37 -05:00
jenkins 7d4ad34587 fix mobile account
ci / docker_image (push) Successful in 2m24s
ci / deploy (push) Successful in 24s
2025-06-19 20:42:33 -05:00
jenkins 95bc2dfabc theming
ci / docker_image (push) Successful in 2m25s
ci / deploy (push) Successful in 24s
2025-06-19 20:36:11 -05:00
jenkins 0752bf129e Mobile Admin and contrast
ci / docker_image (push) Successful in 2m26s
ci / deploy (push) Successful in 24s
2025-06-19 20:22:03 -05:00
jenkins 1474ba29db Mobile Compatibility
ci / docker_image (push) Successful in 2m26s
ci / deploy (push) Successful in 24s
2025-06-19 20:17:11 -05:00
jenkins cbe002f4c4 Fixes
ci / docker_image (push) Successful in 2m28s
ci / deploy (push) Successful in 24s
2025-06-19 17:14:18 -05:00
jenkins 8f41c04ca1 Contrast on dark theme
ci / docker_image (push) Successful in 2m29s
ci / deploy (push) Successful in 47s
2025-06-12 19:13:18 -05:00
drake 8f5545de7c formatting
ci / deploy (push) Has been cancelled
ci / docker_image (push) Has been cancelled
2025-03-30 23:07:40 +00:00
drake 5e99edc503 formatting
ci / deploy (push) Has been cancelled
ci / docker_image (push) Has been cancelled
2025-03-30 22:59:14 +00:00
drake 368cb53528 formatting
ci / deploy (push) Has been cancelled
ci / docker_image (push) Has been cancelled
2025-03-30 22:57:05 +00:00
drake 77655c779d import cleanup
ci / deploy (push) Successful in 16s
ci / docker_image (push) Successful in 1m37s
2025-03-29 18:13:02 -05:00
drake 1270fb3ae9 500 fix
ci / docker_image (push) Successful in 1m39s
ci / deploy (push) Successful in 16s
2025-03-29 17:59:30 -05:00
drake 705c3d63aa README.md
ci / docker_image (push) Successful in 1m38s
ci / deploy (push) Successful in 16s
2025-03-29 17:43:50 -05:00
drake 8cec569f22 fixes from suggestions
ci / docker_image (push) Successful in 1m38s
ci / deploy (push) Successful in 16s
2025-03-29 17:20:21 -05:00
drake 3b428215e3 static/uploads -> uploads
ci / docker_image (push) Successful in 1m36s
ci / deploy (push) Successful in 17s
2025-03-28 13:25:02 -05:00
drake 851309f58f docker-compose.yml upload fix
ci / docker_image (push) Successful in 1m36s
ci / deploy (push) Successful in 17s
2025-03-28 13:16:19 -05:00
drake 585ae4924b gitignore fix
ci / docker_image (push) Successful in 1m36s
ci / deploy (push) Successful in 16s
2025-03-28 13:12:43 -05:00
drake 638fb4c0f8 gitignore fix
ci / docker_image (push) Successful in 1m36s
ci / deploy (push) Successful in 16s
2025-03-28 12:23:52 -05:00
drake f79aafab0b server fixes
ci / docker_image (push) Successful in 1m38s
ci / deploy (push) Successful in 16s
2025-03-28 12:04:31 -05:00
drake 77f3d182d4 Resume Logic
ci / docker_image (push) Successful in 1m41s
ci / deploy (push) Successful in 17s
2025-03-28 11:16:15 -05:00
drake fc97a4271c logo on application page
ci / docker_image (push) Successful in 1m36s
ci / deploy (push) Successful in 16s
2025-03-23 12:50:56 -05:00
drake 1877aee8a5 emailer and resume start
ci / docker_image (push) Successful in 1m34s
ci / deploy (push) Successful in 16s
2025-03-23 12:26:12 -05:00
drake 4f9e770270 added user suggestions
ci / docker_image (push) Successful in 1m37s
ci / deploy (push) Successful in 17s
2025-03-22 15:34:33 -05:00
drake 8932165f7b changes
ci / docker_image (push) Successful in 2m35s
ci / deploy (push) Successful in 31s
2025-03-20 21:30:54 -05:00
drake cd1fbeb3b0 final touches before regionals
ci / docker_image (push) Successful in 1m35s
ci / deploy (push) Successful in 17s
2025-02-01 01:48:06 -06:00
drake a550abba16 base route impl
ci / docker_image (push) Successful in 1m37s
ci / deploy (push) Successful in 16s
2025-02-01 00:46:11 -06:00
drake fceef893c3 dev
ci / docker_image (push) Successful in 1m36s
ci / deploy (push) Successful in 16s
2025-01-31 20:47:20 -06:00
drake 2acc65823b dev
ci / docker_image (push) Successful in 1m37s
ci / deploy (push) Successful in 16s
2025-01-31 18:51:20 -06:00
drake 5eca1635f5 final touches
ci / docker_image (push) Successful in 1m37s
ci / deploy (push) Successful in 16s
2025-01-30 22:12:08 -06:00
drake fa14fe0496 dev
ci / docker_image (push) Successful in 1m38s
ci / deploy (push) Successful in 18s
2025-01-29 22:38:07 -06:00
drake be83b7570d dev
ci / docker_image (push) Successful in 1m32s
ci / deploy (push) Successful in 16s
2025-01-26 19:12:15 -06:00
drake 76c2680c60 Start of many new pages
ci / docker_image (push) Successful in 1m28s
ci / deploy (push) Successful in 17s
2025-01-21 21:49:33 -06:00
drake 95fb591b4b admin route dev
ci / docker_image (push) Successful in 1m28s
ci / deploy (push) Successful in 16s
2025-01-19 09:21:54 -06:00
84 changed files with 9068 additions and 1017 deletions
+12
View File
@@ -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
+6
View File
@@ -26,3 +26,9 @@ vite.config.ts.timestamp-*
# Postgres # Postgres
postgresql postgresql
# User uploads
/uploads/logos/*
/uploads/avatars/*
/uploads/resumes/*
+1 -1
View File
@@ -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
+65 -20
View File
@@ -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.
+3
View File
@@ -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:
+2303 -802
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -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"
} }
} }
+12 -8
View File
@@ -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).
+18
View File
@@ -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');
});
+397 -20
View File
@@ -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 {
@@ -27,12 +40,11 @@ body {
h1 { h1 {
color: var(--text-color); color: var(--text-color);
@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,
'wght' 400,
'GRAD' 0,
'opsz' 20
} }
.right-border {
border-right: 1px solid var(--separator-line-color);
}
.icon-16 {
font-size: 16px !important;
font-variation-settings: 'FILL' 0,
'wght' 400,
'GRAD' 0,
'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
View File
@@ -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">
+10
View File
@@ -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
};
+951 -11
View File
@@ -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
};
} }
+25
View File
@@ -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`
});
}
+94
View File
@@ -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
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
+25
View File
@@ -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 });
}
+52 -1
View File
@@ -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';
}
}
+70
View File
@@ -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;
}
+34
View File
@@ -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>
+179 -52
View File
@@ -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">
<img <div class="pt-1">
class="inline-block" <a
src="/mdevtriangle.svg" class="hover-bg-color mr-1 rounded-md px-2 pb-2 pt-1.5"
alt="MarinoDev Logo" href="/"
height="24" onclick={(document.getElementById('mobile-menu').style.display = 'none')}
width="24" >
/> <img
</a> alt="MarinoDev Logo"
<a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a> class="inline-block"
{#if (userState.perms & 0b00000001) !== 0} height="24"
<a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a> src="/mdevtriangle.svg"
{/if} width="24"
{#if (userState.perms & 0b00001000) !== 0} />
<a href="/administration/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm" <!-- <div class="inline-block text-sm">Home</div>-->
>Administration</a </a>
<!-- <a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>-->
{#if largeScreen.current}
{#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 (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 rounded px-3 py-2 text-sm">Administration</a
>
{/if}
{/if}
</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}
> >
{/if} <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> </nav>
<div>
<button onclick={toggleTheme} class=""> <div class="flex-grow">
<span class="material-symbols-outlined rounded-full p-1 dark:invisible"> {@render children()}
{'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>
<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-->
<!--&gt;-->
<!--<p>Chetan Malkan:</p>-->
<!--<a href="mailto:chetan@marinodev.com" class="hyperlink-color hyperlink-underline"-->
<!-- >chetan@marinodev.com</a-->
<!--&gt;-->
<!--<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-->
<!--&gt;-->
+40
View File
@@ -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 -1
View File
@@ -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>
+43
View File
@@ -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 };
}
};
+380
View File
@@ -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}` });
}
}
};
+211
View File
@@ -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>
+60
View File
@@ -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>
+17
View File
@@ -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');
};
+86
View File
@@ -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>
+16
View File
@@ -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');
};
+85
View File
@@ -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>
+16
View File
@@ -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');
};
+75
View File
@@ -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>
+16
View File
@@ -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');
};
+99
View File
@@ -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');
};
+151
View File
@@ -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}`);
}
}
};
+342
View File
@@ -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>
-34
View File
@@ -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>
+8
View File
@@ -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)));
}
+6
View File
@@ -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() };
};
+45
View File
@@ -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);
};
+120
View File
@@ -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}`);
}
};
+61
View File
@@ -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>
+23
View File
@@ -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>
View File
+8
View File
@@ -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)
};
};
+183
View File
@@ -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))
};
};
+100
View File
@@ -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}`);
}
};
+113
View File
@@ -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>
+67
View File
@@ -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' });
}
}
};
+124
View File
@@ -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>
+21 -45
View File
@@ -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' });
} }
+76 -14
View File
@@ -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="my-2 text-red-500">{form.errorMessage}</div>
<div class="has-text-danger my-2">{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"
</form> 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>
</div>
</div>
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+2 -2
View File
@@ -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()