13
frontend/.eslintignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
20
frontend/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
||||
8
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
1
frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
13
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
9
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
38
frontend/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
46
frontend/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "portfolio-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.25.0",
|
||||
"@sveltejs/adapter-auto": "next",
|
||||
"@sveltejs/kit": "next",
|
||||
"@typescript-eslint/eslint-plugin": "^5.44.0",
|
||||
"@typescript-eslint/parser": "^5.44.0",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-svelte": "^2.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.2.0",
|
||||
"svelte": "^3.53.1",
|
||||
"svelte-check": "^2.9.2",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"svelte-windicss-preprocess": "^4.2.8",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^3.2.4",
|
||||
"windicss": "^3.5.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.2.0",
|
||||
"filedrop-svelte": "^0.1.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"isomorphic-dompurify": "^0.24.0",
|
||||
"svelte-forms-lib": "^2.0.1",
|
||||
"swiper": "^8.4.5",
|
||||
"yup": "^0.32.11"
|
||||
}
|
||||
}
|
||||
10
frontend/playwright.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
2390
frontend/pnpm-lock.yaml
Normal file
9
frontend/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
12
frontend/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
18
frontend/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { API_URL } from '$lib/@api';
|
||||
import type { HandleFetch } from '@sveltejs/kit';
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
|
||||
console.log(`SSR: handleFetch() BEFORE: ${request.method} ${request.url}`);
|
||||
|
||||
const cookie = event.request.headers.get('cookie') || '';
|
||||
|
||||
console.log(`SSR: handleFetch() cookie: ${cookie}`);
|
||||
|
||||
request.headers.set('cookie', cookie);
|
||||
|
||||
request = new Request(request.url.replace(API_URL, 'http://127.0.0.1:8000'), request);
|
||||
|
||||
console.log(`SSR: handleFetch() AFTER: ${request.method} ${request.url}`);
|
||||
|
||||
return fetch(request);
|
||||
};
|
||||
112
frontend/src/lib/@api/admin.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { AdminLogin } from '$lib/stores/admin';
|
||||
import type {
|
||||
CandidateData,
|
||||
CandidatePreview,
|
||||
CreateCandidate,
|
||||
CreateCandidateLogin
|
||||
} from '$lib/stores/candidate';
|
||||
import axios from 'axios';
|
||||
import { API_URL, errorHandler, type Fetch } from '.';
|
||||
|
||||
// Login as admin /admin/login
|
||||
export const apiLogin = async (data: AdminLogin): Promise<number> => {
|
||||
try {
|
||||
await axios.post(API_URL + '/admin/login', data, { withCredentials: true });
|
||||
return data.adminId;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Create new candidate /admin/create
|
||||
// return created candidate's applicationId, personalIdNumber and password
|
||||
export const apiCreateCandidate = async (data: CreateCandidate): Promise<CreateCandidateLogin> => {
|
||||
try {
|
||||
const res = await axios.post(API_URL + '/admin/create', data, { withCredentials: true });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Candidate creation failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Reset candidate password /admin/candidate/{id}/reset_password
|
||||
export const apiResetCandidatePassword = async (id: number): Promise<CreateCandidateLogin> => {
|
||||
try {
|
||||
const res = await axios.post(
|
||||
API_URL + '/admin/candidate/' + id + '/reset_password',
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Candidate creation failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiGetCandidatePortfolio = async (id: number): Promise<Blob> => {
|
||||
try {
|
||||
const res = await fetch(API_URL + '/admin/candidate/' + id + '/portfolio', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
return await res.blob();
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Candidate portfolio failed');
|
||||
}
|
||||
};
|
||||
|
||||
// SSR compatible
|
||||
// Logout as admin /admin/logout
|
||||
export const apiLogout = async (fetchSsr?: Fetch) => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(API_URL + '/admin/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'Logout failed');
|
||||
}
|
||||
};
|
||||
|
||||
// SSR compatible
|
||||
// List all candidates /admin/list/candidates
|
||||
export const apiListCandidates = async (
|
||||
fetchSsr?: Fetch,
|
||||
field?: string
|
||||
): Promise<Array<CandidatePreview>> => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
const params = new URLSearchParams();
|
||||
if (field) {
|
||||
params.append('field', field);
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(API_URL + '/admin/list/candidates?' + params.toString(), {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.status != 200) {
|
||||
throw Error(await res.text());
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'List candidates failed');
|
||||
}
|
||||
};
|
||||
|
||||
// SSR compatible
|
||||
// Get candidate data /admin/candidate/{id}
|
||||
export const apiFetchCandidate = async (id: number, fetchSsr?: Fetch): Promise<CandidateData> => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
try {
|
||||
const res = await apiFetch(API_URL + '/admin/candidate/' + id, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'Failed to fetch candidate data');
|
||||
}
|
||||
};
|
||||
168
frontend/src/lib/@api/candidate.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import axios, { type AxiosProgressEvent } from 'axios';
|
||||
import type { CandidateData, CandidateLogin } from '$lib/stores/candidate';
|
||||
import type { SubmissionProgress } from '$lib/stores/portfolio';
|
||||
import { API_URL, errorHandler, type Fetch } from '.';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
// SSR Compatible
|
||||
export const apiLogout = async (fetchSsr?: Fetch) => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
try {
|
||||
const res = await apiFetch(API_URL + '/candidate/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'Logout failed');
|
||||
}
|
||||
};
|
||||
|
||||
// SSR Compatible
|
||||
export const apiFetchDetails = async (fetchSsr?: Fetch): Promise<CandidateData> => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
try {
|
||||
const res = await apiFetch(API_URL + '/candidate/details', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.status != 200) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'Fetch details failed');
|
||||
}
|
||||
};
|
||||
|
||||
// SSR Compatible
|
||||
export const apiFetchSubmissionProgress = async (fetchSsr?: Fetch): Promise<SubmissionProgress> => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
try {
|
||||
const res = await apiFetch(API_URL + '/candidate/portfolio/submission_progress', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.status != 200) {
|
||||
throw Error(await res.text());
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'Failed to fetch submission progress');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiWhoami = async (fetchSsr?: Fetch): Promise<string> => {
|
||||
const apiFetch = fetchSsr || fetch;
|
||||
try {
|
||||
console.log(API_URL + '/candidate/whoami');
|
||||
const res = await apiFetch(API_URL + '/candidate/whoami', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.status != 200) {
|
||||
throw Error(await res.text());
|
||||
}
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
throw errorHandler(e, 'Failed to fetch whoami');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiLogin = async (data: CandidateLogin): Promise<number> => {
|
||||
try {
|
||||
const res = await axios.post(API_URL + '/candidate/login', data, { withCredentials: true });
|
||||
return data.applicationId;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiFillDetails = async (data: CandidateData): Promise<CandidateData> => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
data[key] = DOMPurify.sanitize(data[key]);
|
||||
});
|
||||
try {
|
||||
const res = await axios.post(API_URL + '/candidate/details', data, { withCredentials: true });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Failed to fill details');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiUploadCoverLetter = async (
|
||||
letter: File,
|
||||
progressReporter: (progress: AxiosProgressEvent) => void
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const res = await axios.post(API_URL + '/candidate/add/cover_letter', letter, {
|
||||
withCredentials: true,
|
||||
data: letter,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf'
|
||||
},
|
||||
onUploadProgress: progressReporter
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Failed to upload cover letter');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiUploadPortfolioLetter = async (
|
||||
letter: File,
|
||||
progressReporter: (progress: AxiosProgressEvent) => void
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const res = await axios.post(API_URL + '/candidate/add/portfolio_letter', letter, {
|
||||
withCredentials: true,
|
||||
data: letter,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf'
|
||||
},
|
||||
onUploadProgress: progressReporter
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Failed to upload cover letter');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiUploadPortfolioZip = async (
|
||||
portfolio: File,
|
||||
progressReporter: (progress: AxiosProgressEvent) => void
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const res = await axios.post(API_URL + '/candidate/add/portfolio_zip', portfolio, {
|
||||
withCredentials: true,
|
||||
data: portfolio,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip'
|
||||
},
|
||||
onUploadProgress: progressReporter
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Failed to upload cover letter');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiSubmitPortfolio = async (): Promise<boolean> => {
|
||||
try {
|
||||
await axios.post(API_URL + '/candidate/portfolio/submit', {}, { withCredentials: true });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Failed to submit portfolio');
|
||||
}
|
||||
};
|
||||
|
||||
export const apiDeltePortfolio = async (): Promise<boolean> => {
|
||||
try {
|
||||
await axios.post(API_URL + '/candidate/portfolio/delete', {}, { withCredentials: true });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw errorHandler(e, 'Failed to delete portfolio');
|
||||
}
|
||||
};
|
||||
14
frontend/src/lib/@api/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { AxiosError } from 'axios';
|
||||
|
||||
export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export const API_URL = 'http://localhost:8000';
|
||||
|
||||
export interface ApiError {
|
||||
error: AxiosError | unknown;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export const errorHandler = (error: AxiosError | unknown, msg: string): ApiError => {
|
||||
return { error, msg };
|
||||
}
|
||||
BIN
frontend/src/lib/assets/archive.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/src/lib/assets/background.jpg
Normal file
|
After Width: | Height: | Size: 674 KiB |
BIN
frontend/src/lib/assets/background2.jpg
Normal file
|
After Width: | Height: | Size: 767 KiB |
BIN
frontend/src/lib/assets/document.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/src/lib/assets/logo/lion.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
1
frontend/src/lib/assets/logo/ssps.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/src/lib/assets/woman.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
38
frontend/src/lib/components/DarkModeToggle.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
export let backgroundColor: 'light' | 'dark' = 'light';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class:blue={backgroundColor === 'light'}
|
||||
class:white={backgroundColor === 'dark'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<g>
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
@apply h-12 w-12;
|
||||
}
|
||||
.blue {
|
||||
@apply stroke-sspsBlue fill-sspsBlue;
|
||||
@apply hover:fill-sspsBlueDark hover:stroke-sspsBlueDark transition-colors duration-300;
|
||||
}
|
||||
.white {
|
||||
@apply fill-white stroke-white;
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/lib/components/Modal.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const close = () => dispatch('close');
|
||||
|
||||
let modal: HTMLElement;
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<div class="modalBackground" on:keydown on:click={close} />
|
||||
|
||||
<div class="modal" role="dialog" aria-modal="true" bind:this={modal}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modalBackground {
|
||||
@apply fixed;
|
||||
@apply top-0 left-0;
|
||||
@apply h-full w-full;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
|
||||
@apply z-20;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply absolute;
|
||||
@apply p-4;
|
||||
@apply rounded-xl;
|
||||
@apply transform:
|
||||
@apply bg-white;
|
||||
|
||||
@apply z-50;
|
||||
|
||||
@apply top-1/2 left-1/2;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
14
frontend/src/lib/components/PageTransition.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script>
|
||||
import { fly } from 'svelte/transition';
|
||||
export let url = '';
|
||||
</script>
|
||||
|
||||
{#key url}
|
||||
<div
|
||||
in:fly={{ x: -5, duration: 500, delay: 500 }}
|
||||
out:fly={{ x: 5, duration: 500 }}
|
||||
class="absolute h-full w-full bg-inherit"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
||||
45
frontend/src/lib/components/SelectField.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
|
||||
export let error: string = '';
|
||||
|
||||
export let options: Array<string> = [];
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<select class:error bind:value on:click on:keydown on:keyup on:change {placeholder} class:placeholder={!value}>
|
||||
{#if placeholder}
|
||||
<option value="" disabled selected>{placeholder}</option>
|
||||
{/if}
|
||||
{#each options as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div,
|
||||
input {
|
||||
@apply w-full;
|
||||
}
|
||||
div {
|
||||
@apply relative flex items-center justify-center;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply hover:border-sspsBlue w-full rounded-lg border border-2 bg-[#f8fafb] p-3 text-xl shadow-lg outline-none transition-colors duration-300;
|
||||
@apply min-w-40;
|
||||
text-align-last: center;
|
||||
}
|
||||
option {
|
||||
@apply w-full;
|
||||
@apply text-center;
|
||||
}
|
||||
.placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
.error {
|
||||
@apply border-red-700;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { apiCreateCandidate } from '$lib/@api/admin';
|
||||
import type { CreateCandidate, CreateCandidateLogin } from '$lib/stores/candidate';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Modal from '../Modal.svelte';
|
||||
import IdField from '../textfield/IdField.svelte';
|
||||
import NumberField from '../textfield/NumberField.svelte';
|
||||
|
||||
let isOpened = true;
|
||||
|
||||
let applicationId: string = '';
|
||||
let personalId: string = '';
|
||||
|
||||
let login: CreateCandidateLogin;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const createCandidate = async () => {
|
||||
const data: CreateCandidate = {
|
||||
applicationId: Number(applicationId),
|
||||
personalIdNumber: personalId
|
||||
};
|
||||
try {
|
||||
login = await apiCreateCandidate(data);
|
||||
dispatch('created');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpened = false;
|
||||
dispatch('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOpened}
|
||||
<Modal on:close={close}>
|
||||
<div class="p-20">
|
||||
{#if login}
|
||||
<h1 class="text-sspsBlue text-3xl font-semibold">{applicationId}</h1>
|
||||
<h1 class="text-sspsBlue text-3xl font-semibold">{login.password}</h1>
|
||||
{:else}
|
||||
<h1 class="text-sspsBlue text-3xl font-semibold">Registrace nového uchazeče</h1>
|
||||
<h3 class="my-4">Evidenčni číslo přihlášky</h3>
|
||||
<NumberField bind:value={applicationId} />
|
||||
<h3 class="my-4">Rodné číslo</h3>
|
||||
<IdField bind:value={personalId} />
|
||||
<input
|
||||
on:click={createCandidate}
|
||||
class="bg-sspsBlue hover:bg-sspsBlueDark mt-6 w-full rounded-lg p-3 text-xl font-semibold text-white transition-colors duration-300"
|
||||
type="submit"
|
||||
value="Vytvořit"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
</style>
|
||||
107
frontend/src/lib/components/admin/list/CandidateDetails.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script lang="ts">
|
||||
import { apiGetCandidatePortfolio, apiResetCandidatePassword } from '$lib/@api/admin';
|
||||
import type { CandidateData } from '$lib/stores/candidate';
|
||||
import ListElement from './ListElement.svelte';
|
||||
|
||||
export let id: number;
|
||||
export let candidate: CandidateData;
|
||||
|
||||
async function resetCandidatePassword() {
|
||||
try {
|
||||
const res = await apiResetCandidatePassword(id);
|
||||
alert('Nove heslo: ' + res.password);
|
||||
} catch {
|
||||
console.log('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPortfolio() {
|
||||
try {
|
||||
const portfolioBlob = await apiGetCandidatePortfolio(id);
|
||||
const url = window.URL.createObjectURL(new Blob([portfolioBlob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'PORTFOLIO' + '_' + id + '.zip');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen w-full items-center justify-center">
|
||||
<div class="mr-8 max-w-sm">
|
||||
<div class="rounded-lg bg-white p-10 shadow-xl">
|
||||
<div class="p-2">
|
||||
<h3 class="text-sspsBlue text-center text-2xl font-medium font-semibold leading-8">
|
||||
{candidate.name + ' ' + candidate.surname}
|
||||
</h3>
|
||||
|
||||
<table class="my-3 text-xs">
|
||||
<tbody
|
||||
><tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">Místo narození</td>
|
||||
<td class="px-2 py-2">{candidate.birthplace}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">Datum narození</td>
|
||||
<td class="px-2 py-2">{candidate.birthdate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">Adresa</td>
|
||||
<td class="px-2 py-2">{candidate.address}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">Telefon</td>
|
||||
<td class="px-2 py-2">{candidate.telephone}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">E-mail</td>
|
||||
<td class="px-2 py-2">{candidate.email}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">Obor</td>
|
||||
<td class="px-2 py-2">{candidate.study}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-sm">
|
||||
<div class="rounded-lg bg-white p-10 shadow-xl">
|
||||
<div class="p-2">
|
||||
<h3 class="text-sspsBlue text-center text-2xl font-medium font-semibold leading-8">
|
||||
{candidate.parentName + ' ' + candidate.parentSurname}
|
||||
</h3>
|
||||
<table class="my-3 text-xs">
|
||||
<tbody
|
||||
><tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">Telefon</td>
|
||||
<td class="px-2 py-2">{candidate.parentTelephone}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-2 py-2 font-semibold text-gray-500">E-mail</td>
|
||||
<td class="px-2 py-2">{candidate.parentEmail}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-8">
|
||||
<div class="flex flex-col">
|
||||
<button on:click={(e) => resetCandidatePassword()}>Resetovat heslo</button>
|
||||
<button on:click={(e) => downloadPortfolio()} class="my-8">Stáhnout portfolio</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button {
|
||||
@apply bg-sspsBlue hover:bg-sspsBlueDark rounded-lg transition duration-300;
|
||||
@apply px-10 py-4 text-2xl font-bold text-white;
|
||||
}
|
||||
</style>
|
||||
14
frontend/src/lib/components/button/Submit.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
export let value: string;
|
||||
</script>
|
||||
|
||||
<input on:click type="submit" {value} />
|
||||
|
||||
<style>
|
||||
input {
|
||||
@apply bg-sspsBlue hover:bg-sspsBlueDark
|
||||
@apply rounded-lg p-3 text-xl font-semibold text-white
|
||||
@apply transition-colors duration-300;
|
||||
@apply w-full;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { fetchSubmProgress } from '$lib/stores/portfolio';
|
||||
import { apiUploadCoverLetter } from '$lib/@api/candidate';
|
||||
import DashboardUploadCard from './DashboardUploadCard.svelte';
|
||||
|
||||
const onFileDrop = async (detail: any) => {
|
||||
const file = detail.file;
|
||||
const callback = detail.callback;
|
||||
await apiUploadCoverLetter(file, callback);
|
||||
await fetchSubmProgress();
|
||||
};
|
||||
</script>
|
||||
|
||||
<DashboardUploadCard
|
||||
on:filedrop={(e) => onFileDrop(e.detail)}
|
||||
title="Motivační dopis"
|
||||
filetype="PDF"
|
||||
filesize={10}
|
||||
fileType={1}
|
||||
placeholder="svůj motivanční dopis"
|
||||
/>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
import { apiDeltePortfolio, apiSubmitPortfolio } from '$lib/@api/candidate';
|
||||
import Circles from '$lib/components/icons/Circles.svelte';
|
||||
import { fetchSubmProgress, type Status } from '$lib/stores/portfolio';
|
||||
import StatusNotificationBig from './StatusNotificationBig.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let status: Status;
|
||||
|
||||
let loading = false;
|
||||
|
||||
const submitPortfolio = async () => {
|
||||
loading = true;
|
||||
await apiSubmitPortfolio();
|
||||
await fetchSubmProgress();
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const deletePortfolio = async () => {
|
||||
loading = true;
|
||||
await apiDeltePortfolio();
|
||||
await fetchSubmProgress();
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const handleNotificationClick = async () => {
|
||||
if (status === "uploaded") {
|
||||
await submitPortfolio();
|
||||
} else if (status === "submitted") {
|
||||
await deletePortfolio();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col">
|
||||
<div class="infoBar flex flex-row-reverse">
|
||||
<StatusNotificationBig {loading} {status} on:click={handleNotificationClick} />
|
||||
</div>
|
||||
<div class="relative flex flex-row justify-between">
|
||||
<div>
|
||||
<span class="absolute -left-16 -top-36">
|
||||
<Circles />
|
||||
</span>
|
||||
<div class="mt-8 flex flex-col lg:mt-12">
|
||||
<h3>{title}</h3>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
@apply m-3;
|
||||
@apply h-full;
|
||||
|
||||
@apply bg-[#f8fbfc];
|
||||
@apply rounded-3xl;
|
||||
@apply px-7 py-10;
|
||||
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
.card:hover {
|
||||
@apply shadow-2xl;
|
||||
@apply m-0;
|
||||
}
|
||||
.card h3 {
|
||||
@apply text-sspsBlue text-4xl font-semibold md:text-2xl xl:text-4xl;
|
||||
}
|
||||
</style>
|
||||
227
frontend/src/lib/components/dashboard/DashboardUploadCard.svelte
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<script lang="ts">
|
||||
import FileType from './FileType.svelte';
|
||||
import { filedrop, type FileDropOptions } from 'filedrop-svelte';
|
||||
import { submissionProgress, UploadStatus, type Status } from '$lib/stores/portfolio';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
import type { AxiosProgressEvent } from 'axios';
|
||||
import StatusNotificationDot from './StatusNotificationDot.svelte';
|
||||
|
||||
import documentIcon from '$lib/assets/document.png';
|
||||
import archiveIcon from '$lib/assets/archive.png';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let title: string;
|
||||
export let filetype: 'PDF' | 'ZIP';
|
||||
export let filesize: number;
|
||||
export let fileType: number;
|
||||
export let placeholder: string = '';
|
||||
|
||||
let fileDropped: boolean = false;
|
||||
let progress: number = 1;
|
||||
let bytesTotal: number = 0;
|
||||
|
||||
let status: Status;
|
||||
|
||||
$: if ($submissionProgress) {
|
||||
status = getStatus();
|
||||
// console.log('type' + fileType + ' status: ' + status);
|
||||
fileDropped = status === 'uploaded' || status === 'submitted';
|
||||
}
|
||||
|
||||
const getStatus = (): Status => {
|
||||
console.log($submissionProgress);
|
||||
switch ($submissionProgress.status) {
|
||||
case UploadStatus.None:
|
||||
return 'missing';
|
||||
case UploadStatus.Some:
|
||||
if ($submissionProgress.files!.some((code) => code === fileType)) {
|
||||
return 'uploaded';
|
||||
}
|
||||
return 'missing';
|
||||
case UploadStatus.All:
|
||||
return 'uploaded';
|
||||
case UploadStatus.Submitted:
|
||||
return 'submitted';
|
||||
default:
|
||||
return 'missing';
|
||||
}
|
||||
};
|
||||
|
||||
let dashAnimationProgress = 0;
|
||||
let dashAnimationInterval: NodeJS.Timer;
|
||||
|
||||
const dashAnimationStart = () => {
|
||||
dashAnimationInterval = setInterval(() => {
|
||||
dashAnimationProgress += 1;
|
||||
if (dashAnimationProgress == 20) {
|
||||
dashAnimationProgress = 0;
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const dashAnimationStop = () => {
|
||||
clearInterval(dashAnimationInterval);
|
||||
};
|
||||
|
||||
type Dropped = {
|
||||
accepted: Array<File>;
|
||||
rejected: Array<File>;
|
||||
};
|
||||
|
||||
const onFileDrop = (dropped: Dropped) => {
|
||||
console.log(dropped);
|
||||
if (dropped.accepted.length > 0) {
|
||||
fileDropped = true;
|
||||
const file = dropped.accepted[0];
|
||||
// send the request in outer component
|
||||
dispatch('filedrop', {
|
||||
file: file,
|
||||
callback: (progressEvent: AxiosProgressEvent) => {
|
||||
console.log(progressEvent.bytes);
|
||||
progress = progressEvent.progress!;
|
||||
bytesTotal = progressEvent.total ?? 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const FileDropOptions: FileDropOptions = {
|
||||
accept: filetype === 'PDF' ? 'application/pdf' : 'application/zip',
|
||||
maxSize: filesize * 1_000_000,
|
||||
multiple: false,
|
||||
windowDrop: false
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="card uploadCard relative">
|
||||
<div class="header">
|
||||
<h3 class="mb-4 sm:mb-0">{title}</h3>
|
||||
<div class="mb-4 mt-1 sm:mb-0 sm:mt-0">
|
||||
<FileType {filetype} filesize={filesize + ' MB'} />
|
||||
</div>
|
||||
<div class="absolute right-0 top-4 px-7">
|
||||
<StatusNotificationDot {status} />
|
||||
</div>
|
||||
</div>
|
||||
{#if fileDropped}
|
||||
<div class="body uploaded flex content-around items-center justify-between">
|
||||
<div class="w-24">
|
||||
<img
|
||||
class="w-full object-scale-down"
|
||||
src={filetype == 'PDF' ? documentIcon : archiveIcon}
|
||||
alt="Icon"
|
||||
/>
|
||||
</div>
|
||||
<svg class="h-25 hidden xl:block" viewBox="0 0 2 40" xmlns="http://www.w3.org/2000/svg"
|
||||
><line
|
||||
x1="0"
|
||||
y="0"
|
||||
x2="0"
|
||||
y2="40"
|
||||
stroke="#406280ff"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="3"
|
||||
/></svg
|
||||
>
|
||||
<div class="hidden items-center xl:block">
|
||||
{#if bytesTotal === 0 || Math.round(progress * 100) === 100}
|
||||
<h2 class="text-xl font-bold">{status === 'submitted' ? 'Odesláno' : 'Nahráno'}</h2>
|
||||
{:else}
|
||||
<h2 class="text-xl">Nahráno {((bytesTotal / 1_000_000) * progress).toFixed(1)} MB</h2>
|
||||
<h2 class="self-center text-xl">z {(bytesTotal / 1_000_000).toFixed(1)} MB</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<svg class="h-25" viewBox="0 0 2 40" xmlns="http://www.w3.org/2000/svg"
|
||||
><line
|
||||
x1="0"
|
||||
y="0"
|
||||
x2="0"
|
||||
y2="40"
|
||||
stroke="#406280ff"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="3"
|
||||
/></svg
|
||||
>
|
||||
<div class="items-center text-center">
|
||||
<h2 class="text-sspsBlueDark mb-2 text-2xl font-bold">{Math.round(progress * 100)} %</h2>
|
||||
<ProgressBar {progress} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="body">
|
||||
<div
|
||||
use:filedrop={FileDropOptions}
|
||||
on:filedrop={(e) => onFileDrop(e.detail.files)}
|
||||
on:filedragenter={dashAnimationStart}
|
||||
on:filedragleave={dashAnimationStop}
|
||||
class="drag group"
|
||||
on:mouseenter={dashAnimationStart}
|
||||
on:mouseleave={dashAnimationStop}
|
||||
style={`background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='9' ry='9' stroke-opacity='50%' stroke='%23406280' stroke-width='4' stroke-dasharray='10' stroke-dashoffset='${dashAnimationProgress}' stroke-linecap='square'/%3e%3c/svg%3e");`}
|
||||
>
|
||||
<span class="text-[#406280]">Sem přetáhněte,</span>
|
||||
<span class="text-sspsGray">nebo nahrajte {placeholder}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global([type='file']) {
|
||||
@apply hidden;
|
||||
}
|
||||
.card {
|
||||
@apply m-3 bg-transparent;
|
||||
@apply h-full;
|
||||
@apply flex flex-col justify-between;
|
||||
|
||||
@apply rounded-3xl;
|
||||
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
.card:hover {
|
||||
@apply shadow-2xl;
|
||||
@apply m-0;
|
||||
}
|
||||
.header {
|
||||
@apply rounded-t-3xl;
|
||||
@apply px-7 pb-7 pt-14;
|
||||
background-color: rgb(220, 238, 253);
|
||||
backdrop-filter: blur(15px) saturate(0.86);
|
||||
-webkit-backdrop-filter: blur(15px) saturate(0.86);
|
||||
|
||||
@apply flex flex-col justify-between sm:flex-row sm:items-center;
|
||||
}
|
||||
.body {
|
||||
@apply bg-[#f8fbfc];
|
||||
@apply rounded-b-3xl;
|
||||
@apply flex-1;
|
||||
@apply p-7;
|
||||
}
|
||||
.uploaded {
|
||||
@apply 2xl:px-14;
|
||||
}
|
||||
.card h3 {
|
||||
@apply text-sspsBlue text-2xl font-semibold xl:text-4xl;
|
||||
}
|
||||
.card span {
|
||||
@apply text-sm opacity-60;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
.card .drag {
|
||||
@apply transition duration-200;
|
||||
@apply min-h-full;
|
||||
@apply flex flex-col items-center justify-center;
|
||||
border-radius: 9px;
|
||||
|
||||
@apply hover:cursor-pointer;
|
||||
|
||||
/* TODO: Fix this hack */
|
||||
@apply p-10 sm:p-20 md:p-0;
|
||||
}
|
||||
.card .drag:hover span {
|
||||
@apply opacity-100;
|
||||
}
|
||||
</style>
|
||||
30
frontend/src/lib/components/dashboard/FileType.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
export let filetype: 'PDF' | 'ZIP';
|
||||
export let filesize: string;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<span class="text-sspsBlue text-lg italic">{filetype}</span>
|
||||
<span class="text-sspsGray mx-2">/</span>
|
||||
<span class="text-sspsBlue text-lg italic">Max {filesize}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
@apply flex items-center justify-between;
|
||||
@apply py-1 px-4;
|
||||
@apply rounded-xl bg-white shadow-md;
|
||||
|
||||
@apply hover:bg-sspsBlue;
|
||||
@apply hover:cursor-pointer;
|
||||
}
|
||||
|
||||
div,
|
||||
span {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
div:hover span {
|
||||
@apply text-white;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { fetchSubmProgress } from '$lib/stores/portfolio';
|
||||
import { apiUploadPortfolioLetter } from '../../@api/candidate';
|
||||
import DashboardUploadCard from './DashboardUploadCard.svelte';
|
||||
|
||||
const onFileDrop = async (detail: any) => {
|
||||
const file = detail.file;
|
||||
const callback = detail.callback;
|
||||
await apiUploadPortfolioLetter(file, callback);
|
||||
await fetchSubmProgress();
|
||||
};
|
||||
</script>
|
||||
|
||||
<DashboardUploadCard
|
||||
on:filedrop={(e) => onFileDrop(e.detail)}
|
||||
title="Portfolio"
|
||||
filetype="PDF"
|
||||
filesize={10}
|
||||
fileType={2}
|
||||
placeholder="svoje portfolio"
|
||||
/>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { fetchSubmProgress } from '$lib/stores/portfolio';
|
||||
import { apiUploadPortfolioZip } from '$lib/@api/candidate';
|
||||
import DashboardUploadCard from './DashboardUploadCard.svelte';
|
||||
|
||||
const onFileDrop = async (detail: any) => {
|
||||
const file = detail.file;
|
||||
const callback = detail.callback;
|
||||
await apiUploadPortfolioZip(file, callback);
|
||||
await fetchSubmProgress();
|
||||
};
|
||||
</script>
|
||||
|
||||
<DashboardUploadCard
|
||||
on:filedrop={(e) => onFileDrop(e.detail)}
|
||||
title="Další data"
|
||||
filetype="ZIP"
|
||||
filesize={100}
|
||||
fileType={3}
|
||||
placeholder="vaše další soubory ve formátu ZIP"
|
||||
/>
|
||||
39
frontend/src/lib/components/dashboard/ProgressBar.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
export let progress: number;
|
||||
</script>
|
||||
|
||||
<div class="progress-bar">
|
||||
<svg
|
||||
class="animated animate-ease-linear"
|
||||
width="40mm"
|
||||
height="8mm"
|
||||
viewBox="0 0 50 6"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="5" y1="3" x2="45" y2="3" stroke="#e6e6e6" stroke-width="6" stroke-linecap="round" />
|
||||
|
||||
{#if progress === 1}
|
||||
<line
|
||||
x1="5"
|
||||
y1="3"
|
||||
x2={progress * 45}
|
||||
y2="3"
|
||||
stroke="#35e000ff"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
{:else}
|
||||
<line
|
||||
x1="5"
|
||||
y1="3"
|
||||
x2={progress * 45}
|
||||
y2="3"
|
||||
stroke="#75bff8ff"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
{/if}
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import type { Status } from '$lib/stores/portfolio';
|
||||
|
||||
export let loading: boolean = false;
|
||||
export let status: Status;
|
||||
|
||||
let title: string;
|
||||
let description: string;
|
||||
$: switch (status) {
|
||||
case 'submitted':
|
||||
title = 'Soubory odevzdány!';
|
||||
description = 'Vaše soubory smažete kliknutím zde';
|
||||
break;
|
||||
case 'uploaded':
|
||||
title = 'Soubory nebyly odevzdány!';
|
||||
description = 'Odevzdejte soubory kliknutím zde';
|
||||
break;
|
||||
case 'missing':
|
||||
title = 'Soubory nebyly nahrány!';
|
||||
description = 'Nahrajte včechny soubory prosím';
|
||||
break;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div on:click on:keydown class="flex flex-col">
|
||||
<div class="info flex flex-col {status}">
|
||||
<span class="text-xl font-bold text-white">{title}</span>
|
||||
{#if loading}
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="my-2 h-6 w-6 animate-spin !fill-sspsBlue text-gray-200"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-md italic text-white">{description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.info {
|
||||
@apply flex items-center justify-between;
|
||||
@apply py-3 px-6;
|
||||
@apply rounded-xl border-red-700 bg-red-700 shadow-md;
|
||||
|
||||
@apply hover:cursor-help;
|
||||
}
|
||||
|
||||
.submitted {
|
||||
@apply bg-green-600;
|
||||
@apply !cursor-pointer;
|
||||
}
|
||||
|
||||
.uploaded {
|
||||
@apply bg-yellow-600;
|
||||
@apply !cursor-pointer;
|
||||
}
|
||||
|
||||
.missing {
|
||||
@apply bg-orange-700;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import type { Status } from '$lib/stores/portfolio';
|
||||
|
||||
export let status: Status;
|
||||
let title: string;
|
||||
|
||||
$: switch (status) {
|
||||
case 'submitted':
|
||||
title = 'Odeslané';
|
||||
break;
|
||||
case 'uploaded':
|
||||
title = 'Nahráno';
|
||||
break;
|
||||
case 'missing':
|
||||
title = 'Chybí';
|
||||
break;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- make red dot -->
|
||||
<div class="flex animate-pulse flex-row justify-between div-{status}">
|
||||
<span class="mt-1 h-6 w-6 rounded-full {status}" />
|
||||
<!-- <h3 class="ml-8 font-bold text-xl">{title}</h3> -->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
span {
|
||||
@apply rounded-full p-1;
|
||||
}
|
||||
|
||||
.div-submitted {
|
||||
@apply animate-none;
|
||||
}
|
||||
|
||||
.submitted {
|
||||
@apply bg-[#35e000ff];
|
||||
}
|
||||
|
||||
.uploaded {
|
||||
@apply bg-[#ff8530ff];
|
||||
@apply animate-none;
|
||||
}
|
||||
|
||||
.missing {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
</style>
|
||||
14
frontend/src/lib/components/icons/Circles.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<div>
|
||||
<span class="left-20 top-10 h-20 w-20 rounded-full bg-black bg-[#f8fbfc] opacity-100" />
|
||||
<span class="left-14 -top-6 h-24 w-24 rounded-full bg-black bg-[#558cbd] opacity-50" />
|
||||
<span class="h-28 w-28 bg-black bg-[#55b1bd] opacity-60" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
@apply relative;
|
||||
}
|
||||
span {
|
||||
@apply absolute rounded-full p-1 shadow-md;
|
||||
}
|
||||
</style>
|
||||
14
frontend/src/lib/components/icons/Email.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<span>@</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
@apply flex items-center justify-center;
|
||||
@apply text-sspsBlue text-center text-2xl;
|
||||
@apply pb-1;
|
||||
|
||||
@apply select-none;
|
||||
}
|
||||
span:hover {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/lib/components/icons/Home.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M4.586 20.414l-.707.707zm14.828 0l-.707-.707zM19 10v7h2v-7zm-3 10H8v2h8zM5 17v-7H3v7zm3 3c-.971 0-1.599-.002-2.061-.064-.434-.059-.57-.153-.646-.229l-1.414 1.414c.51.51 1.138.709 1.793.797C6.3 22.002 7.085 22 8 22zm-5-3c0 .915-.002 1.701.082 2.328.088.655.287 1.284.797 1.793l1.414-1.414c-.076-.076-.17-.212-.229-.646C5.002 18.6 5 17.971 5 17zm16 0c0 .971-.002 1.599-.064 2.061-.059.434-.153.57-.229.646l1.414 1.414c.51-.51.709-1.138.797-1.793C21.002 18.7 21 17.915 21 17zm-3 5c.915 0 1.701.002 2.328-.082.655-.088 1.284-.287 1.793-.797l-1.414-1.414c-.076.076-.212.17-.646.229-.462.062-1.09.064-2.061.064z"
|
||||
/><path
|
||||
d="M3 11l6.172-6.172c1.333-1.333 2-2 2.828-2s1.495.667 2.828 2L21 11"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
/><path
|
||||
d="M9 17c0-.932 0-1.398.152-1.765a2 2 0 0 1 1.083-1.083C10.602 14 11.068 14 12 14s1.398 0 1.765.152a2 2 0 0 1 1.083 1.083C15 15.602 15 16.068 15 17v4H9zm7-12.5c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C16.801 3 17.034 3 17.5 3s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883V10l-3-3.5z"
|
||||
/></svg
|
||||
>
|
||||
|
||||
<style>
|
||||
path {
|
||||
@apply fill-current;
|
||||
}
|
||||
path:nth-child(2) {
|
||||
@apply stroke-current;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
frontend/src/lib/components/icons/Lock.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"
|
||||
><g
|
||||
><path
|
||||
d="M19.414 20.414l.707.707zm-14.828 0l-.707.707zm14.828-9.828l.707-.707zM8 11h8V9H8zm-3 6v-3H3v3zm11 3H8v2h8zm3-6v3h2v-3zm-3 8c.915 0 1.701.002 2.328-.082.655-.088 1.284-.287 1.793-.797l-1.414-1.414c-.076.076-.212.17-.646.229-.462.062-1.09.064-2.061.064zm3-5c0 .971-.002 1.599-.064 2.061-.059.434-.153.57-.229.646l1.414 1.414c.51-.51.709-1.138.797-1.793C21.002 18.7 21 17.915 21 17zM3 17c0 .915-.002 1.701.082 2.328.088.655.287 1.284.797 1.793l1.414-1.414c-.076-.076-.17-.212-.229-.646C5.002 18.6 5 17.971 5 17zm5 3c-.971 0-1.599-.002-2.061-.064-.434-.059-.57-.153-.646-.229l-1.414 1.414c.51.51 1.138.709 1.793.797C6.3 22.002 7.085 22 8 22zm8-9c.971 0 1.599.002 2.061.064.434.059.57.153.646.229l1.414-1.414c-.51-.51-1.138-.709-1.793-.797C17.7 8.998 16.915 9 16 9zm5 3c0-.915.002-1.701-.082-2.328-.088-.655-.287-1.284-.797-1.793l-1.414 1.414c.076.076.17.212.229.646.062.462.064 1.09.064 2.061zM8 9c-.915 0-1.701-.002-2.328.082-.655.088-1.284.287-1.793.797l1.414 1.414c.076-.076.212-.17.646-.229C6.4 11.002 7.029 11 8 11zm-3 5c0-.971.002-1.599.064-2.061.059-.434.153-.57.229-.646L3.879 9.879c-.51.51-.709 1.138-.797 1.793C2.998 12.3 3 13.085 3 14z"
|
||||
/><path
|
||||
d="M16 6h-1zm-8 4h1V6.5H7V10zm1-3.5c0-1.174.447-1.718.942-2.026C10.514 4.12 11.293 4 12 4V2c-.856 0-2.077.131-3.114.776C7.772 3.468 7 4.674 7 6.5zM12 4c.706 0 1.514.118 2.108.441.524.285.892.72.892 1.559h2c0-1.66-.85-2.726-1.937-3.316C14.05 2.132 12.856 2 12 2zm3 2v4h2V6z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
@apply stroke-current fill-current;
|
||||
}
|
||||
g,
|
||||
path {
|
||||
@apply fill-current stroke-current;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
15
frontend/src/lib/components/icons/Person.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M19 21v-1.45c0-.977 0-1.465-.113-1.864a3 3 0 0 0-2.073-2.073c-.399-.113-.887-.113-1.864-.113h-6.9c-.977 0-1.465 0-1.864.113a3 3 0 0 0-2.073 2.073C4 18.085 4 18.573 4 19.55V21M16.2 7.06c0 2.245-1.88 4.065-4.2 4.065S7.8 9.305 7.8 7.06 9.68 2.996 12 2.996s4.2 1.82 4.2 4.064z"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/></svg
|
||||
>
|
||||
|
||||
<style>
|
||||
path {
|
||||
@apply fill-current stroke-current;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 522 B |
12
frontend/src/lib/components/icons/SchoolBadge.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import lion from '$lib/assets/logo/lion.png';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center rounded-[999px] py-3 px-6 shadow-2xl transition-all duration-700 hover:shadow-md md:py-4 md:px-8"
|
||||
>
|
||||
<img class="object-cover" src={lion} alt="" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
18
frontend/src/lib/components/icons/Telephone.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg
|
||||
class="stroke-sspsBlue fill-transparent"
|
||||
height="24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><g stroke-linecap="round" stroke-linejoin="round"
|
||||
><path
|
||||
d="M17.935 19.5C13.535 23.9.1 10.465 4.5 6.065l1.232-1.232a2 2 0 0 1 3.14.405l.535.914a2 2 0 0 1-.071 2.131l-.363.535a1.5 1.5 0 0 0 .182 1.902l2.062 2.063 2.063 2.062a1.5 1.5 0 0 0 1.902.181l.535-.362a2 2 0 0 1 2.131-.07l.914.534a2 2 0 0 1 .404 3.14z"
|
||||
stroke-width="2"
|
||||
/><path d="M13 6.5l4-4m0 0h-2.5m2.5 0V5m4 1.5l-4 4m0 0h2.5m-2.5 0V8" stroke-width="1.5" /></g
|
||||
></svg
|
||||
>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
@apply stroke-sspsBlue fill-transparent;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 621 B |
60
frontend/src/lib/components/layout/FullLayout.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import backgroundImage from '$lib/assets/background.jpg';
|
||||
import logo from '$lib/assets/logo/ssps.svg';
|
||||
import DarkModeToggle from '../DarkModeToggle.svelte';
|
||||
|
||||
export let hideHeader: boolean = false;
|
||||
</script>
|
||||
|
||||
<div class="bg">
|
||||
<div class="bgOverlay">
|
||||
{#if !hideHeader}
|
||||
<img class="logo" src={logo} alt="SSPŠ logo" />
|
||||
<div class="darkModeToggle">
|
||||
<DarkModeToggle backgroundColor="dark" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div style={`background-image: url(${backgroundImage});`} class="bgImage" />
|
||||
</div>
|
||||
<div class="view">
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
@apply h-[200px] w-[200px];
|
||||
}
|
||||
.bgImage {
|
||||
@apply -z-20;
|
||||
@apply min-w-screen absolute min-h-screen min-w-full;
|
||||
@apply bg-cover bg-no-repeat;
|
||||
background-position: 55%;
|
||||
}
|
||||
.bgOverlay {
|
||||
@apply min-w-screen absolute -z-10 min-h-screen;
|
||||
background: linear-gradient(45deg, rgba(18, 48, 75, 1), rgba(119, 173, 224, 0.443));
|
||||
@apply bg-cover;
|
||||
}
|
||||
.bgOverlay .logo {
|
||||
@apply absolute top-0 left-0;
|
||||
@apply hidden md:inline-block;
|
||||
@apply max-w-72 h-auto;
|
||||
@apply p-7;
|
||||
}
|
||||
.bgOverlay .darkModeToggle {
|
||||
@apply absolute top-0 right-0;
|
||||
@apply hidden md:inline-block;
|
||||
@apply p-7;
|
||||
}
|
||||
.view {
|
||||
@apply z-10 overflow-scroll;
|
||||
@apply top-0 right-0 bottom-0 left-0 m-auto h-screen w-screen;
|
||||
}
|
||||
.content {
|
||||
@apply h-full w-full;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
</style>
|
||||
61
frontend/src/lib/components/layout/SplitLayout.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import defaultBg from '$lib/assets/background.jpg';
|
||||
import logo from '$lib/assets/logo/ssps.svg';
|
||||
import DarkModeToggle from '../DarkModeToggle.svelte';
|
||||
|
||||
export let backgroundImage: string = defaultBg;
|
||||
export let backgroundPosition: string = '55%';
|
||||
</script>
|
||||
|
||||
<div class="bg">
|
||||
<div class="bgOverlay">
|
||||
<img class="logo" src={logo} alt="SSPŠ logo" />
|
||||
</div>
|
||||
<div
|
||||
style={`background-image: url(${backgroundImage}); background-position: ${backgroundPosition}`}
|
||||
class="bgImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="view">
|
||||
<div class="darkModeToggle">
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bgImage {
|
||||
@apply -z-20;
|
||||
@apply min-w-screen absolute min-h-screen md:min-w-[50vw];
|
||||
@apply bg-no-repeat bg-cover;
|
||||
}
|
||||
.bgOverlay {
|
||||
@apply min-w-screen absolute -z-10 min-h-screen md:min-w-[50vw];
|
||||
background: linear-gradient(45deg, rgba(18, 48, 75, 1), rgba(119, 173, 224, 0.443));
|
||||
@apply bg-cover;
|
||||
}
|
||||
.bgOverlay .logo {
|
||||
@apply absolute top-0 left-0;
|
||||
@apply hidden md:inline-block;
|
||||
@apply max-w-56 h-auto;
|
||||
@apply p-7;
|
||||
}
|
||||
.darkModeToggle {
|
||||
@apply absolute right-0;
|
||||
@apply hidden md:inline-block;
|
||||
@apply p-7;
|
||||
}
|
||||
.view {
|
||||
@apply z-10 overflow-scroll;
|
||||
@apply rounded-3xl md:rounded-none;
|
||||
@apply absolute top-0 right-0 bottom-0 left-0 m-auto h-[90vh] w-[90vw] md:top-auto md:bottom-auto md:left-auto md:m-0;
|
||||
@apply md:h-screen md:w-[50vw];
|
||||
@apply md:my-auto;
|
||||
@apply bg-white;
|
||||
}
|
||||
.content {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
27
frontend/src/lib/components/textfield/EmailField.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import Email from '../icons/Email.svelte';
|
||||
import TextField from './TextField.svelte';
|
||||
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
export let error: string = '';
|
||||
</script>
|
||||
|
||||
<TextField
|
||||
bind:error
|
||||
bind:value
|
||||
on:click
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:change
|
||||
type="email"
|
||||
{placeholder}
|
||||
icon
|
||||
>
|
||||
<div slot="icon" class="flex items-center justify-center">
|
||||
<Email />
|
||||
</div>
|
||||
</TextField>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
26
frontend/src/lib/components/textfield/IdField.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import Person from '../icons/Person.svelte';
|
||||
import TextField from './TextField.svelte';
|
||||
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
export let error: string = '';
|
||||
|
||||
// Personal Id formatting
|
||||
$: {
|
||||
let x = value.replace(/\D/g, '').match(/(\d{0,6})(\d{0,4})/)!;
|
||||
value = x[1] + (x[2] ? '/' + x[2] : '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<TextField bind:error bind:value on:keydown on:keyup on:change type="text" {placeholder} icon>
|
||||
<div slot="icon" class="flex items-center justify-center">
|
||||
<Person />
|
||||
</div>
|
||||
</TextField>
|
||||
|
||||
<style>
|
||||
div {
|
||||
@apply text-sspsBlue;
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/lib/components/textfield/NameField.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import TextField from './TextField.svelte';
|
||||
|
||||
export let placeholder: string = '';
|
||||
|
||||
export let valueName: string = '';
|
||||
export let valueSurname: string = '';
|
||||
|
||||
let value: string = "";
|
||||
|
||||
$: {
|
||||
const parsed = value.trim().split(' ');
|
||||
if (parsed.length > 1) {
|
||||
valueName = parsed[0];
|
||||
valueSurname = parsed[1];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (valueName && valueSurname) {
|
||||
value = `${valueName} ${valueSurname}`;
|
||||
}
|
||||
});
|
||||
|
||||
export let error: string = '';
|
||||
</script>
|
||||
|
||||
<TextField bind:error bind:value on:click on:keydown on:keyup on:change type="text" {placeholder} />
|
||||
|
||||
<style>
|
||||
</style>
|
||||
14
frontend/src/lib/components/textfield/NumberField.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import TextField from './TextField.svelte';
|
||||
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
export let error: string = '';
|
||||
|
||||
// Number formatting
|
||||
$: {
|
||||
value = value.replace(/[^0-9]/g, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<TextField bind:error bind:value on:keydown on:keyup on:change type="number" {placeholder} />
|
||||
30
frontend/src/lib/components/textfield/PasswordField.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import Lock from '../icons/Lock.svelte';
|
||||
import TextField from './TextField.svelte';
|
||||
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
export let error: string = '';
|
||||
</script>
|
||||
|
||||
<TextField
|
||||
bind:error
|
||||
bind:value
|
||||
on:click
|
||||
on:keydown
|
||||
on:keyup
|
||||
{placeholder}
|
||||
on:change
|
||||
type="password"
|
||||
icon
|
||||
>
|
||||
<div slot="icon" class="flex items-center justify-center">
|
||||
<Lock />
|
||||
</div>
|
||||
</TextField>
|
||||
|
||||
<style>
|
||||
div {
|
||||
@apply text-sspsBlue;
|
||||
}
|
||||
</style>
|
||||
27
frontend/src/lib/components/textfield/TelephoneField.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import Telephone from '../icons/Telephone.svelte';
|
||||
import TextField from './TextField.svelte';
|
||||
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
export let error: string = '';
|
||||
|
||||
// Phone Number formatting
|
||||
$: {
|
||||
let x = value.replace(/\D/g, '').match(/(\d{0,3})(\d{0,3})(\d{0,3})(\d{0,3})/)!;
|
||||
value =
|
||||
(x[1] ? '+' + x[1] : '') +
|
||||
(x[2] ? ' ' + x[2] : '') +
|
||||
(x[3] ? ' ' + x[3] : '') +
|
||||
(x[4] ? ' ' + x[4] : '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<TextField bind:error bind:value on:keydown on:keyup on:change type="tel" {placeholder} icon>
|
||||
<div slot="icon" class="flex items-center justify-center">
|
||||
<Telephone />
|
||||
</div>
|
||||
</TextField>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
52
frontend/src/lib/components/textfield/TextField.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
export let type: 'text' | 'number' | 'tel' | 'email' | 'password' = 'text';
|
||||
const typeAction = (node: HTMLInputElement) => {
|
||||
node.type = type;
|
||||
};
|
||||
export let placeholder: string = '';
|
||||
export let value: string = '';
|
||||
|
||||
export let icon: boolean = false;
|
||||
export let error: string = '';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<input
|
||||
class:error
|
||||
bind:value
|
||||
on:click
|
||||
on:keydown
|
||||
on:keyup
|
||||
on:change
|
||||
class:withIcon={icon}
|
||||
use:typeAction
|
||||
{placeholder}
|
||||
/>
|
||||
{#if icon}
|
||||
<span>
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div,
|
||||
input {
|
||||
@apply w-full;
|
||||
}
|
||||
div {
|
||||
@apply relative flex items-center justify-center;
|
||||
}
|
||||
input {
|
||||
@apply hover:border-sspsBlue w-full rounded-lg border border-2 bg-[#f8fafb] p-3 text-xl shadow-lg outline-none transition-colors duration-300;
|
||||
}
|
||||
div span {
|
||||
@apply absolute right-0 top-0 bottom-0 my-auto flex bg-transparent p-3;
|
||||
}
|
||||
.withIcon {
|
||||
@apply pr-14;
|
||||
}
|
||||
.error {
|
||||
@apply border-red-700;
|
||||
}
|
||||
</style>
|
||||
4
frontend/src/lib/stores/admin.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface AdminLogin {
|
||||
adminId: number;
|
||||
password: string;
|
||||
}
|
||||
42
frontend/src/lib/stores/candidate.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface CandidateData {
|
||||
name?: string;
|
||||
surname?: string;
|
||||
birthplace?: string;
|
||||
birthdate?: string;
|
||||
address?: string;
|
||||
telephone?: string;
|
||||
citizenship?: string;
|
||||
email?: string;
|
||||
sex?: string;
|
||||
study?: string;
|
||||
personalIdNumber?: string;
|
||||
parentName?: string;
|
||||
parentSurname?: string;
|
||||
parentTelephone?: string;
|
||||
parentEmail?: string;
|
||||
}
|
||||
|
||||
export interface CandidatePreview {
|
||||
applicationId?: number;
|
||||
name?: string;
|
||||
surname?: string;
|
||||
study?: string;
|
||||
}
|
||||
|
||||
export interface CandidateLogin {
|
||||
applicationId: number;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface CreateCandidate {
|
||||
applicationId: number;
|
||||
personalIdNumber: string;
|
||||
}
|
||||
|
||||
export interface CreateCandidateLogin extends CreateCandidate {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const candidateData = writable<CandidateData>({});
|
||||
26
frontend/src/lib/stores/portfolio.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { apiFetchSubmissionProgress } from '../@api/candidate';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type Status = 'submitted' | 'uploaded' | 'missing';
|
||||
|
||||
export enum UploadStatus {
|
||||
None = 1,
|
||||
Some = 2,
|
||||
All = 3,
|
||||
Submitted = 4
|
||||
}
|
||||
|
||||
export interface SubmissionProgress {
|
||||
status?: UploadStatus;
|
||||
files?: [number];
|
||||
}
|
||||
export const submissionProgress = writable<SubmissionProgress>({});
|
||||
|
||||
export const fetchSubmProgress = async () => {
|
||||
try {
|
||||
const prog = await apiFetchSubmissionProgress();
|
||||
submissionProgress.set(prog);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
8
frontend/src/params/application.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
const isNumber = /^\d{6}(?:\d{1})?$/.test(param);
|
||||
const isValid = param.startsWith('101') || param.startsWith('102') || param.startsWith('103');
|
||||
|
||||
return isNumber && isValid;
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: LayoutServerLoad = ({ cookies }) => {
|
||||
const isAuthenticated = cookies.get('id');
|
||||
if (!isAuthenticated) {
|
||||
throw redirect(302, '/admin/auth/login');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<h1 class="text-6xl">Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { apiFetchCandidate } from '$lib/@api/admin';
|
||||
import type { CandidateData } from '$lib/stores/candidate';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const { code } = params;
|
||||
const codeNumber = Number(code);
|
||||
|
||||
let candidateData: CandidateData = {};
|
||||
try {
|
||||
candidateData = await apiFetchCandidate(codeNumber, fetch);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return {
|
||||
id: codeNumber,
|
||||
candidate: candidateData
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import CandidateDetails from '$lib/components/admin/list/CandidateDetails.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<CandidateDetails id={data.id} candidate={data.candidate} />
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { apiListCandidates } from '$lib/@api/admin';
|
||||
import type { CandidatePreview } from '$lib/stores/candidate';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
let candidatePreview: Array<CandidatePreview> = [{}];
|
||||
|
||||
candidatePreview =
|
||||
(await apiListCandidates(fetch).catch((e) => {
|
||||
console.error(e);
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
preview: candidatePreview
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
<script lang="ts">
|
||||
import { apiListCandidates } from '$lib/@api/admin';
|
||||
import Home from '$lib/components/icons/Home.svelte';
|
||||
import TextField from '$lib/components/textfield/TextField.svelte';
|
||||
import type { CandidatePreview } from '$lib/stores/candidate';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from '../$types';
|
||||
import CreateCandidateModal from '$lib/components/admin/CreateCandidateModal.svelte';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let candidates: Array<CandidatePreview> = data.preview;
|
||||
|
||||
|
||||
const getCandidates = async (field?: string) => {
|
||||
try {
|
||||
candidates = await apiListCandidates(undefined, field);
|
||||
} catch {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
type Filter = 'Vše' | 'KBB' | 'IT' | 'GYM';
|
||||
|
||||
let filters: Array<Filter> = ['Vše', 'KBB', 'IT', 'GYM'];
|
||||
|
||||
let activeFilter: Filter = 'Vše';
|
||||
|
||||
const changeFilter = (filter: Filter) => {
|
||||
activeFilter = filter;
|
||||
switch (activeFilter) {
|
||||
case 'Vše':
|
||||
getCandidates();
|
||||
break;
|
||||
case 'KBB':
|
||||
getCandidates('KB');
|
||||
break;
|
||||
case 'IT':
|
||||
getCandidates('IT');
|
||||
break;
|
||||
case 'GYM':
|
||||
getCandidates('G');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let scrollTop = 0;
|
||||
|
||||
let createCandidateModal: boolean = false;
|
||||
|
||||
const openCreateCandidateModal = () => {
|
||||
createCandidateModal = true;
|
||||
};
|
||||
|
||||
$: candidatesTable = candidates;
|
||||
let searchValue: string = '';
|
||||
$: fuse = new Fuse(candidates, {
|
||||
keys: ['applicationId', 'name', 'surname', 'study']
|
||||
});
|
||||
|
||||
const search = () => {
|
||||
if (searchValue === '' || !searchValue) {
|
||||
candidatesTable = candidates;
|
||||
} else {
|
||||
candidatesTable = fuse.search(searchValue).map((result) => result.item);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if createCandidateModal}
|
||||
<CreateCandidateModal
|
||||
on:created={() => getCandidates()}
|
||||
on:close={() => (createCandidateModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="flex flex-row">
|
||||
<div class="list fixed">
|
||||
{#each filters as filter}
|
||||
<div class:selected={filter === activeFilter}>
|
||||
<Home />
|
||||
<button on:click={() => changeFilter(filter)}>{filter}</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="body relative overflow-scroll">
|
||||
<h1 class="text-3xl font-semibold">Uchazeči</h1>
|
||||
<div class="controls my-8">
|
||||
<TextField on:keyup={search} bind:value={searchValue} placeholder="Hledat" />
|
||||
<button
|
||||
on:click={openCreateCandidateModal}
|
||||
class="bg-sspsBlue hover:bg-sspsBlueDark ml-3 w-2/5 rounded-lg p-3 py-4 text-xl font-semibold text-white transition-colors duration-300"
|
||||
>Nový uchazeč</button
|
||||
>
|
||||
</div>
|
||||
{#if scrollTop > 200}
|
||||
<div class="fixed bottom-8 right-8">
|
||||
<button
|
||||
on:click={openCreateCandidateModal}
|
||||
class="bg-sspsBlue flex h-16 w-16 items-center justify-center rounded-full p-6 text-lg font-semibold text-white"
|
||||
>+</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-4 sm:px-6 lg:px-8">
|
||||
<div class="overflow-hidden rounded-md border-2 border-[#dfe0e9] ">
|
||||
<table class="min-w-full text-center ">
|
||||
<thead class="bg-[#f6f4f4] ">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
Ev. č. přihlásky
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-4 text-sm font-medium text-gray-900"> Jméno </th>
|
||||
<th scope="col" class="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
Příjmení
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-4 text-sm font-medium text-gray-900"> Obor </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each candidatesTable as candidate}
|
||||
<tr class="border-b bg-white hover:cursor-pointer">
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900"
|
||||
><a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="/admin/candidate/{candidate.applicationId}"
|
||||
>{candidate.applicationId}</a
|
||||
></td
|
||||
>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{candidate.name}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{candidate.surname}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{candidate.study}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:window on:scroll={() => (scrollTop = window.scrollY)} />
|
||||
|
||||
<style>
|
||||
.list {
|
||||
@apply h-full w-96;
|
||||
@apply float-left overflow-scroll;
|
||||
|
||||
@apply border-r border-gray-400;
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.list div {
|
||||
@apply p-3;
|
||||
@apply mx-3 my-6;
|
||||
@apply flex items-center;
|
||||
@apply rounded-xl;
|
||||
|
||||
@apply transition-all duration-300;
|
||||
|
||||
@apply hover:bg-sspsBlue focus:bg-sspsBlue;
|
||||
@apply hover:text-white focus:text-white;
|
||||
}
|
||||
|
||||
.list div :global(path) {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.list div:hover :global(path) {
|
||||
@apply fill-white fill-white;
|
||||
}
|
||||
.list div:hover :global(path:nth-child(2)) {
|
||||
@apply stroke-white stroke-white;
|
||||
}
|
||||
|
||||
.list .selected :global(path) {
|
||||
@apply fill-white fill-white;
|
||||
}
|
||||
.list .selected :global(path:nth-child(2)) {
|
||||
@apply stroke-white stroke-white;
|
||||
}
|
||||
|
||||
.list .selected {
|
||||
@apply bg-sspsBlue;
|
||||
@apply text-white;
|
||||
}
|
||||
.list div button {
|
||||
@apply p-1;
|
||||
@apply flex-1;
|
||||
@apply text-left;
|
||||
}
|
||||
|
||||
.body {
|
||||
@apply h-full w-full;
|
||||
@apply float-left overflow-hidden;
|
||||
@apply my-6 mx-12 ml-[27rem];
|
||||
}
|
||||
|
||||
.body .controls {
|
||||
@apply flex flex-row items-center justify-between;
|
||||
}
|
||||
|
||||
.candidatePreview {
|
||||
@apply mt-5 h-20 w-full rounded-xl bg-gray-200;
|
||||
@apply hover:cursor-pointer;
|
||||
}
|
||||
</style>
|
||||
6
frontend/src/routes/(admin)/admin/[...path]/+page.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = () => {
|
||||
throw redirect(302, "/admin/auth/login")
|
||||
}
|
||||
57
frontend/src/routes/(admin)/admin/auth/login/+page.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import lion from '$lib/assets/logo/lion.png';
|
||||
import SplitLayout from '$lib/components/layout/SplitLayout.svelte';
|
||||
import TextField from '$lib/components/textfield/TextField.svelte';
|
||||
|
||||
import background from '$lib/assets/background2.jpg';
|
||||
import Lock from '$lib/components/icons/Lock.svelte';
|
||||
import { apiLogin } from '$lib/@api/admin';
|
||||
import { goto } from '$app/navigation';
|
||||
import Submit from '$lib/components/button/Submit.svelte';
|
||||
import PasswordField from '$lib/components/textfield/PasswordField.svelte';
|
||||
|
||||
let adminIdValue = '';
|
||||
let adminPasswordValue = '';
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
await apiLogin({ adminId: Number(adminIdValue), password: adminPasswordValue });
|
||||
goto('/admin/dashboard');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<SplitLayout backgroundImage={background} backgroundPosition="30%">
|
||||
<div class="form">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-[999px] py-3 px-6 shadow-2xl md:py-4 md:px-8"
|
||||
>
|
||||
<img class="object-cover" src={lion} alt="" />
|
||||
</div>
|
||||
<h1 class="text-sspsBlue mt-8 text-4xl font-semibold">Přihlášení</h1>
|
||||
<p class="text-sspsGray mt-8 text-center font-light">
|
||||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.<br /> Fusce suscipit libero eget elit.
|
||||
</p>
|
||||
<div class="mt-8 flex w-3/5 flex-col">
|
||||
<span>
|
||||
<TextField bind:value={adminIdValue} placeholder="Admin id" type="number" />
|
||||
</span>
|
||||
<span class="mt-8">
|
||||
<PasswordField bind:value={adminPasswordValue} placeholder="Heslo" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-8 w-3/5">
|
||||
<Submit value="Odeslat" on:click={login} />
|
||||
</div>
|
||||
</div>
|
||||
</SplitLayout>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
@apply flex flex-col;
|
||||
@apply mx-auto h-full w-[90%];
|
||||
@apply items-center justify-center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { apiLogout } from '$lib/@api/admin';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, cookies }) => {
|
||||
const a = await apiLogout(fetch);
|
||||
console.log(a);
|
||||
|
||||
cookies.delete('id', { path: '/' });
|
||||
cookies.delete('key', { path: '/' });
|
||||
|
||||
throw redirect(302, '/admin/auth/login');
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { apiWhoami } from '$lib/@api/candidate';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
|
||||
const isAuthenticated = cookies.get('id');
|
||||
|
||||
if (isAuthenticated) {
|
||||
await apiWhoami(fetch).catch((e) => {
|
||||
throw redirect(302, '/auth/logout');
|
||||
});
|
||||
} else {
|
||||
throw redirect(302, '/auth/logout');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { apiFetchDetails, apiFetchSubmissionProgress } from '$lib/@api/candidate';
|
||||
import type { CandidateData } from '$lib/stores/candidate';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const details: CandidateData = await apiFetchDetails(fetch).catch((e) => {
|
||||
console.error(e);
|
||||
throw redirect(302, '/register');
|
||||
});
|
||||
|
||||
const submissionProgress = await apiFetchSubmissionProgress(fetch).catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
|
||||
return {
|
||||
candidate: details,
|
||||
submission: {
|
||||
...submissionProgress
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
import FullLayout from '$lib/components/layout/FullLayout.svelte';
|
||||
|
||||
import { Swiper, SwiperSlide } from 'swiper/svelte';
|
||||
import 'swiper/css';
|
||||
|
||||
import DashboardInfoCard from '$lib/components/dashboard/DashboardInfoCard.svelte';
|
||||
import CoverLetterUploadCard from '$lib/components/dashboard/CoverLetterUploadCard.svelte';
|
||||
import PortfolioLetterUploadCard from '$lib/components/dashboard/PortfolioLetterUploadCard.svelte';
|
||||
import PortfolioZipUploadCard from '$lib/components/dashboard/PortfolioZipUploadCard.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { fetchSubmProgress, submissionProgress, UploadStatus, type Status } from '$lib/stores/portfolio';
|
||||
import { candidateData } from '$lib/stores/candidate';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// @ts-ignore
|
||||
$: candidateData.set(data.candidate);
|
||||
// @ts-ignore
|
||||
$: submissionProgress.set(data.submission);
|
||||
|
||||
const getUploadStatus = (progressStatus: UploadStatus | undefined): Status => {
|
||||
switch (progressStatus) {
|
||||
case 3:
|
||||
return 'uploaded';
|
||||
case 4:
|
||||
return 'submitted';
|
||||
default:
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullLayout>
|
||||
<div class="dashboard dashboardDesktop">
|
||||
<div class="name col-span-3 <2xl:col-span-4">
|
||||
<DashboardInfoCard status={getUploadStatus($submissionProgress.status)} title={$candidateData.name + ' ' + $candidateData.surname ?? ''}>
|
||||
<span class="text-sspsBlue mt-3 truncate">{$candidateData.email}</span>
|
||||
<span class="text-sspsGray mt-3 text-xs">Uchazeč na SSPŠ</span>
|
||||
</DashboardInfoCard>
|
||||
</div>
|
||||
<div class="coverletter col-span-5 <2xl:col-span-4">
|
||||
<CoverLetterUploadCard />
|
||||
</div>
|
||||
<div class="portfolio col-span-4">
|
||||
<PortfolioLetterUploadCard />
|
||||
</div>
|
||||
<div class="moreData col-span-4">
|
||||
<PortfolioZipUploadCard />
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard dashboardMobile">
|
||||
<div class="name my-10 mx-auto w-[90%]">
|
||||
<DashboardInfoCard status={getUploadStatus($submissionProgress.status)} title={$candidateData.name + ' ' + $candidateData.surname ?? ''}>
|
||||
<span class="text-sspsBlue mt-3 truncate">{$candidateData.email}</span>
|
||||
<span class="text-sspsGray mt-3 text-xs">Uchazeč na SSPŠ</span>
|
||||
</DashboardInfoCard>
|
||||
</div>
|
||||
<Swiper slidesPerView={1} spaceBetween={20}>
|
||||
<SwiperSlide>
|
||||
<div class="mx-auto w-[90%]">
|
||||
<CoverLetterUploadCard />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
<SwiperSlide>
|
||||
<div class="mx-auto w-[90%]">
|
||||
<PortfolioLetterUploadCard />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
<SwiperSlide>
|
||||
<div class="mx-auto w-[90%]">
|
||||
<PortfolioZipUploadCard />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</div>
|
||||
</FullLayout>
|
||||
|
||||
<style>
|
||||
.dashboardDesktop {
|
||||
@apply h-[85vh] w-[85vw];
|
||||
@apply hidden grid-cols-8 grid-rows-2 gap-10 md:grid;
|
||||
}
|
||||
|
||||
.dashboardMobile {
|
||||
@apply h-[90vh] w-[100vw];
|
||||
@apply md:hidden;
|
||||
}
|
||||
|
||||
.dashboardMobile :global(.uploadCard) {
|
||||
@apply min-h-72;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { apiFillDetails } from '$lib/@api/candidate';
|
||||
import Submit from '$lib/components/button/Submit.svelte';
|
||||
|
||||
import Home from '$lib/components/icons/Home.svelte';
|
||||
import SchoolBadge from '$lib/components/icons/SchoolBadge.svelte';
|
||||
import SplitLayout from '$lib/components/layout/SplitLayout.svelte';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import EmailField from '$lib/components/textfield/EmailField.svelte';
|
||||
import IdField from '$lib/components/textfield/IdField.svelte';
|
||||
import NameField from '$lib/components/textfield/NameField.svelte';
|
||||
import TelephoneField from '$lib/components/textfield/TelephoneField.svelte';
|
||||
import TextField from '$lib/components/textfield/TextField.svelte';
|
||||
|
||||
import { createForm } from 'svelte-forms-lib';
|
||||
import * as yup from 'yup';
|
||||
|
||||
const pageCount = 3;
|
||||
let pageIndex = 0;
|
||||
let pagesFilled = 0;
|
||||
|
||||
const formInitialValues = {
|
||||
name: '',
|
||||
surname: '',
|
||||
email: '',
|
||||
telephone: '',
|
||||
birthplace: '',
|
||||
birthdate: '',
|
||||
sex: '',
|
||||
address: '',
|
||||
citizenship: '',
|
||||
personalIdNumber: '',
|
||||
study: '',
|
||||
parentName: '',
|
||||
parentSurname: '',
|
||||
parentTelephone: '',
|
||||
parentEmail: ''
|
||||
};
|
||||
|
||||
const { form, errors, handleSubmit, handleChange } = createForm({
|
||||
initialValues: formInitialValues,
|
||||
validationSchema: yup.object().shape({
|
||||
name: yup.string().required(),
|
||||
surname: yup.string(),
|
||||
email: yup.string().email().required(),
|
||||
telephone: yup
|
||||
.string()
|
||||
.required()
|
||||
.matches(/^\+\d{1,3} \d{3} \d{3} \d{3}$/),
|
||||
birthplace: yup.string().required(),
|
||||
birthdate: yup.string().required(),
|
||||
sex: yup.string(),
|
||||
address: yup.string().required(),
|
||||
citizenship: yup.string().required(),
|
||||
personalIdNumber: yup.string().required(),
|
||||
study: yup.string().required(),
|
||||
parentName: yup.string(),
|
||||
parentSurname: yup.string(),
|
||||
parentTelephone: yup
|
||||
.string()
|
||||
.required()
|
||||
.matches(/^\+\d{1,3} \d{3} \d{3} \d{3}$/),
|
||||
parentEmail: yup.string().email().required()
|
||||
}),
|
||||
|
||||
onSubmit: async (values) => {
|
||||
if (pageIndex === pageCount) {
|
||||
try {
|
||||
console.log('submit');
|
||||
// @ts-ignore // love javascript
|
||||
delete values.undefined;
|
||||
values.birthdate = '2000-01-01'; // TODO: reformat user typed date
|
||||
await apiFillDetails(values);
|
||||
goto('/dashboard');
|
||||
} catch (e) {
|
||||
console.error('error while submitting data: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$: console.log($errors);
|
||||
|
||||
const isPageInvalid = (): boolean => {
|
||||
switch (pageIndex) {
|
||||
case 0:
|
||||
if ($errors.name || $errors.email || $errors.telephone) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 1:
|
||||
if (
|
||||
/* $errors.birthdurname || */ $errors.birthplace ||
|
||||
$errors.birthdate /* || $errors.sex */
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if ($errors.address || $errors.parentEmail || $errors.parentTelephone) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
if (
|
||||
$errors.citizenship ||
|
||||
$errors.personalIdNumber ||
|
||||
$errors.study //||
|
||||
// $errors.applicationId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<SplitLayout>
|
||||
<div class="form">
|
||||
<div class="h-24 w-24 md:h-auto md:w-auto">
|
||||
<SchoolBadge />
|
||||
</div>
|
||||
{#if pageIndex === 0}
|
||||
<form on:submit={handleSubmit}>
|
||||
<h1 class="text-sspsBlue mt-8 text-4xl font-semibold">Registrace</h1>
|
||||
<p class="text-sspsGray mt-8 block text-center font-light">
|
||||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.<br /> Fusce suscipit libero eget
|
||||
elit.
|
||||
</p>
|
||||
<div class="flex w-full items-center justify-center md:flex-col">
|
||||
<span class="mt-8 w-full">
|
||||
<NameField
|
||||
error={$errors.name}
|
||||
on:change={handleChange}
|
||||
bind:valueName={$form.name}
|
||||
bind:valueSurname={$form.surname}
|
||||
placeholder="Jméno a příjmení"
|
||||
/>
|
||||
</span>
|
||||
<span class="mt-8 ml-2 w-full md:ml-0">
|
||||
<EmailField
|
||||
error={$errors.email}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.email}
|
||||
placeholder="E-mail"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-8 w-full">
|
||||
<TelephoneField
|
||||
error={$errors.telephone}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.telephone}
|
||||
placeholder="Telefon"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{#if pageIndex === 1}
|
||||
<h1 class="text-sspsBlue mt-8 text-4xl font-semibold">Něco o tobě</h1>
|
||||
<p class="text-sspsGray mt-8 block text-center font-light">
|
||||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.<br /> Fusce suscipit libero eget elit.
|
||||
</p>
|
||||
<div class="flex w-full flex-row md:flex-col">
|
||||
<span class="mt-8 w-full">
|
||||
<NameField
|
||||
error={$errors.name}
|
||||
on:change={handleChange}
|
||||
bind:valueName={$form.parentName}
|
||||
bind:valueSurname={$form.parentSurname}
|
||||
placeholder="Jméno a příjmení zákonného zástupce"
|
||||
/>
|
||||
</span>
|
||||
<span class="mt-8 ml-2 w-full md:ml-0">
|
||||
<TextField
|
||||
error={$errors.birthplace}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.birthplace}
|
||||
type="text"
|
||||
placeholder="Místo narození"
|
||||
icon
|
||||
>
|
||||
<div slot="icon" class="flex items-center justify-center text-sspsBlue">
|
||||
<Home />
|
||||
</div>
|
||||
</TextField>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex w-full items-center">
|
||||
<TextField
|
||||
error={$errors.birthdate}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.birthdate}
|
||||
type="text"
|
||||
placeholder="Datum narození"
|
||||
/>
|
||||
<div class="ml-2">
|
||||
<SelectField
|
||||
error={$errors.sex}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.sex}
|
||||
options={['Žena', 'Muž']}
|
||||
placeholder="Pohlaví"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pageIndex === 2}
|
||||
<h1 class="text-sspsBlue mt-8 text-4xl font-semibold">Už jen kousek!</h1>
|
||||
<p class="text-sspsGray mt-8 block text-center font-light">
|
||||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.<br /> Fusce suscipit libero eget elit.
|
||||
</p>
|
||||
<div class="flex w-full flex-col">
|
||||
<span class="mt-8 w-full">
|
||||
<TextField
|
||||
error={$errors.address}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.address}
|
||||
type="text"
|
||||
placeholder="Adresa trvalého bydliště"
|
||||
/>
|
||||
</span>
|
||||
<div class="mt-8 flex flex-row items-center md:flex-col">
|
||||
<span class="w-full">
|
||||
<EmailField
|
||||
error={$errors.parentEmail}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.parentEmail}
|
||||
placeholder="E-mail zákonného zástupce"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-2 w-full md:ml-0 md:mt-8">
|
||||
<TelephoneField
|
||||
error={$errors.parentTelephone}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.parentTelephone}
|
||||
placeholder="Telefon zákonného zástupce"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pageIndex === 3}
|
||||
<h1 class="text-sspsBlue mt-8 text-4xl font-semibold">Poslední krok</h1>
|
||||
<p class="text-sspsGray mt-8 block text-center font-light">
|
||||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.<br /> Fusce suscipit libero eget elit.
|
||||
</p>
|
||||
<div class="flex w-full flex-row md:flex-col">
|
||||
<span class="mt-8 w-full">
|
||||
<TextField
|
||||
error={$errors.citizenship}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.citizenship}
|
||||
type="text"
|
||||
placeholder="Občanství"
|
||||
/>
|
||||
</span>
|
||||
<span class="mt-8 ml-2 w-full md:ml-0">
|
||||
<TextField on:change={handleChange} type="text" placeholder="Evidenční číslo přihlášky" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-8 flex w-full items-center justify-center">
|
||||
<IdField
|
||||
error={$errors.personalIdNumber}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.personalIdNumber}
|
||||
placeholder="Rodné číslo"
|
||||
/>
|
||||
<span class="ml-2">
|
||||
<SelectField
|
||||
error={$errors.study}
|
||||
on:change={handleChange}
|
||||
bind:value={$form.study}
|
||||
placeholder="Obor"
|
||||
options={['KB', 'IT', 'G']}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 w-full">
|
||||
<Submit
|
||||
on:click={async (e) => {
|
||||
await handleSubmit(e);
|
||||
console.log('clicked ' + isPageInvalid());
|
||||
if (isPageInvalid()) return;
|
||||
if (pageIndex === pageCount) {
|
||||
} else {
|
||||
pagesFilled++;
|
||||
pageIndex++;
|
||||
}
|
||||
errors.set(formInitialValues);
|
||||
}}
|
||||
value={pageIndex === pageCount ? 'Odeslat' : 'Pokračovat'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-row justify-center">
|
||||
{#each Array(pageCount + 1) as _, i}
|
||||
<button
|
||||
class:dotActive={i === pageIndex}
|
||||
on:click={async (e) => {
|
||||
if (i <= pagesFilled) {
|
||||
// never skip unfilled or invalid pages
|
||||
pageIndex = i;
|
||||
} else if (i == pagesFilled + 1) {
|
||||
// if next page is clicked, validate current page
|
||||
await handleSubmit(e);
|
||||
if (isPageInvalid()) return;
|
||||
pagesFilled++;
|
||||
pageIndex++;
|
||||
errors.set(formInitialValues);
|
||||
}
|
||||
}}
|
||||
class="dot"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</SplitLayout>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
@apply flex flex-col;
|
||||
@apply mx-auto h-full w-[90%];
|
||||
@apply items-center justify-center;
|
||||
}
|
||||
.form > form {
|
||||
@apply flex flex-col;
|
||||
@apply w-full;
|
||||
@apply items-center justify-center;
|
||||
}
|
||||
.dot {
|
||||
@apply @apply hover:bg-sspsBlue @apply
|
||||
bg-sspsGray ml-2 h-4
|
||||
w-4 rounded-full hover:cursor-pointer;
|
||||
}
|
||||
.dotActive {
|
||||
@apply bg-sspsBlue;
|
||||
}
|
||||
</style>
|
||||
6
frontend/src/routes/(candidate)/[...path]/+page.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = () => {
|
||||
throw redirect(302, "/auth/login")
|
||||
}
|
||||
11
frontend/src/routes/(candidate)/auth/login/+layout.server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: LayoutServerLoad = ({ cookies }) => {
|
||||
const isAuthenticated = cookies.get('id');
|
||||
|
||||
if (isAuthenticated) {
|
||||
throw redirect(302, '/dashboard');
|
||||
}
|
||||
};
|
||||
41
frontend/src/routes/(candidate)/auth/login/+page.svelte
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Submit from '$lib/components/button/Submit.svelte';
|
||||
|
||||
import SchoolBadge from '$lib/components/icons/SchoolBadge.svelte';
|
||||
import SplitLayout from '$lib/components/layout/SplitLayout.svelte';
|
||||
import TextField from '$lib/components/textfield/TextField.svelte';
|
||||
|
||||
let applicationValue = '';
|
||||
|
||||
const redirectToCode = () => {
|
||||
// TODO: Validation
|
||||
if (applicationValue) {
|
||||
goto(`login/${applicationValue}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<SplitLayout>
|
||||
<div class="form">
|
||||
<SchoolBadge />
|
||||
<h1 class="text-sspsBlue mt-8 text-4xl font-semibold">Přihlášení</h1>
|
||||
<p class="text-sspsGray my-8 text-center font-light">
|
||||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit.<br /> Fusce suscipit libero eget elit.
|
||||
</p>
|
||||
<div class="w-3/5">
|
||||
<TextField bind:value={applicationValue} placeholder="Ev. číslo" type="number" />
|
||||
</div>
|
||||
<div class="mt-8 w-3/5">
|
||||
<Submit on:click={redirectToCode} value="Odeslat" />
|
||||
</div>
|
||||
</div>
|
||||
</SplitLayout>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
@apply flex flex-col;
|
||||
@apply mx-auto h-full w-[90%];
|
||||
@apply items-center justify-center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import FullLayout from '$lib/components/layout/FullLayout.svelte';
|
||||
|
||||
import woman from '$lib/assets/woman.png';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { apiLogin } from '$lib/@api/candidate';
|
||||
|
||||
let applicationId = Number($page.params.code);
|
||||
let codeValueMobile: string = '';
|
||||
let codeValueArray: Array<string> = [];
|
||||
let codeElementArray: Array<HTMLInputElement> = [];
|
||||
|
||||
$: {
|
||||
codeValueMobile = codeValueMobile.toUpperCase();
|
||||
codeValueArray = codeValueMobile.split('');
|
||||
console.log(codeValueArray);
|
||||
}
|
||||
|
||||
const inputDesktopOnKeyDown = (index: number, e: KeyboardEvent) => {
|
||||
if (e.key === 'Backspace') {
|
||||
codeValueArray[index] = '';
|
||||
if (codeElementArray[index - 1]) {
|
||||
codeElementArray[index - 1].focus();
|
||||
}
|
||||
} else {
|
||||
if (e.key.length > 1) {
|
||||
return;
|
||||
}
|
||||
codeValueArray[index] = e.key.toUpperCase();
|
||||
if (codeElementArray[index + 1]) {
|
||||
codeElementArray[index + 1].focus();
|
||||
}
|
||||
}
|
||||
codeValueMobile = codeValueArray.join('');
|
||||
};
|
||||
|
||||
$: if (codeValueArray.length === 8) {
|
||||
submit();
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await apiLogin({ applicationId, password: codeValueMobile });
|
||||
goto('/dashboard');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// alert('ApplicationId: ' + applicationId + '; Password: ' + codeValueMobile);
|
||||
};
|
||||
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text) {
|
||||
codeValueMobile = text;
|
||||
}
|
||||
for (const el of codeElementArray) {
|
||||
el.blur();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
codeElementArray[0].focus();
|
||||
|
||||
// Document on:paste
|
||||
document.addEventListener('paste', onPaste);
|
||||
|
||||
return () => {
|
||||
// this function is called when the component is destroyed
|
||||
document.removeEventListener('paste', onPaste);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<FullLayout>
|
||||
<div class="modal">
|
||||
<img class="mx-auto" src={woman} alt="" />
|
||||
<div class="flex items-center justify-center">
|
||||
<input bind:value={codeValueMobile} type="text" class="codeInputMobile" />
|
||||
{#each [1, 2, 3, 4] as value}
|
||||
<input
|
||||
class="codeInputDesktop"
|
||||
bind:this={codeElementArray[value - 1]}
|
||||
bind:value={codeValueArray[value - 1]}
|
||||
on:keydown={(e) => inputDesktopOnKeyDown(value - 1, e)}
|
||||
on:paste|preventDefault={(e) => onPaste(e)}
|
||||
type="text"
|
||||
/>
|
||||
{/each}
|
||||
<span class="bg-sspsBlue mr-2 hidden h-2 w-8 sm:block" />
|
||||
{#each [5, 6, 7, 8] as value}
|
||||
<input
|
||||
class="codeInputDesktop"
|
||||
bind:this={codeElementArray[value - 1]}
|
||||
bind:value={codeValueArray[value - 1]}
|
||||
on:keydown={(e) => inputDesktopOnKeyDown(value - 1, e)}
|
||||
on:paste|preventDefault={(e) => onPaste(e)}
|
||||
type="text"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<h3 class="text-sspsBlue mx-8 mt-8 text-center text-xl font-semibold">
|
||||
Zadejte 8místný kód pro aktivaci účtu
|
||||
</h3>
|
||||
<p class="text-sspsGray mx-8 mt-8 text-center">Nevíte si rady? Klikněte <u>zde</u></p>
|
||||
</div>
|
||||
</FullLayout>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
@apply flex flex-col items-center justify-center;
|
||||
@apply mx-auto my-auto;
|
||||
@apply h-[90vh] w-[90vw] md:h-4/5 md:w-4/5;
|
||||
@apply rounded-3xl;
|
||||
@apply bg-white;
|
||||
}
|
||||
input {
|
||||
@apply text-sspsBlue text-center font-semibold;
|
||||
@apply focus:border-sspsBlue hover:border-sspsBlue rounded-xl border border-2 bg-[#f8fafb] p-3 caret-transparent shadow-lg outline-none transition-colors duration-300;
|
||||
}
|
||||
.codeInputMobile {
|
||||
@apply sm:hidden;
|
||||
@apply mx-5 w-full;
|
||||
}
|
||||
.codeInputDesktop {
|
||||
@apply hidden;
|
||||
@apply mr-1 md:mr-2;
|
||||
@apply sm:h-15 xl:w-18 xl:h-22 sm:block sm:w-12 sm:text-xl md:h-20 md:w-16 md:text-4xl xl:p-0 xl:text-4xl;
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/routes/(candidate)/auth/logout/+page.server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { apiLogout } from '$lib/@api/candidate';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, cookies }) => {
|
||||
await apiLogout(fetch).catch(() => {
|
||||
// TODO: Handle error
|
||||
});
|
||||
|
||||
cookies.delete('id', { path: '/' });
|
||||
cookies.delete('key', { path: '/' });
|
||||
|
||||
throw redirect(302, '/auth/login');
|
||||
};
|
||||
22
frontend/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import PageTransition from '$lib/components/PageTransition.svelte';
|
||||
|
||||
export let data: { url: string };
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<PageTransition url={data.url}>
|
||||
<slot />
|
||||
</PageTransition>
|
||||
|
||||
<style windi:preflights:global windi:safelist:global>
|
||||
:global(html) {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
</style>
|
||||
7
frontend/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async ({ url }) => {
|
||||
return {
|
||||
url: url.pathname
|
||||
};
|
||||
};
|
||||
2
frontend/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<h1 class="text-6xl">Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
BIN
frontend/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
15
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import adapter from '@sveltejs/adapter-auto';
|
||||
import preprocess from 'svelte-preprocess';
|
||||
import { windi } from 'svelte-windicss-preprocess';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: [preprocess(), windi({})],
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
frontend/tests/test.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/*test('index page has expected h1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
expect(await page.textContent('h1')).toBe('Welcome to SvelteKit');
|
||||
});*/
|
||||
14
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
8
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import type { UserConfig } from 'vite';
|
||||
|
||||
const config: UserConfig = {
|
||||
plugins: [sveltekit()]
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
frontend/windi.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'windicss/helpers';
|
||||
|
||||
export default defineConfig({
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
sspsBlue: '#406280',
|
||||
sspsBlueDark: '#243a55',
|
||||
sspsGray: '#e6e6e6'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||