client initial commit
This commit is contained in:
25
client/.gitignore
vendored
Normal file
25
client/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env.*
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
3
client/.vscode/extensions.json
vendored
Normal file
3
client/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
client/README.md
Normal file
5
client/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1851
client/package-lock.json
generated
Normal file
1851
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
client/package.json
Normal file
27
client/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "ijy-client",
|
||||
"private": false,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-toast-notification": "^3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.10",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"axios": "^1.6.5",
|
||||
"bootstrap": "^5.3.3",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
61
client/src/App.vue
Normal file
61
client/src/App.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import {RouterView} from 'vue-router'
|
||||
import NavBar from '@components/Navbar.vue'
|
||||
import {onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {updateIsMobile, routerTransitioning} from "@/globals.ts";
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const isFooterFixed = ref(document.documentElement.scrollHeight <= window.innerHeight);
|
||||
|
||||
watch(() => document.documentElement.scrollHeight, () => {
|
||||
console.debug('Document height changed, updating footer position');
|
||||
updateFooterPosition();
|
||||
});
|
||||
|
||||
const updateFooterPosition = () => {
|
||||
isFooterFixed.value = document.documentElement.scrollHeight <= window.innerHeight;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(() => updateFooterPosition());
|
||||
resizeObserver.observe(document.documentElement)
|
||||
window.addEventListener("resize", updateIsMobile)
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", updateIsMobile)
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBar/>
|
||||
<div class="router-view">
|
||||
<RouterView v-slot="{Component}">
|
||||
<template v-if="Component">
|
||||
<transition name="fade-tabs" mode="out-in" appear @before-enter="routerTransitioning=true"
|
||||
@after-enter="routerTransitioning = false">
|
||||
<component :is="Component"/>
|
||||
</transition>
|
||||
</template>
|
||||
<template v-else>
|
||||
Loading...
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
<div class="footer" :class="{ 'footer-fixed': isFooterFixed }">
|
||||
<div class="footer-content">
|
||||
<p style="text-align: center; margin-top: 1rem">©2025 <a href="https://github.com/L10nhunter">L10nhunter</a>.
|
||||
All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.footer-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
187
client/src/assets/css/base.css
Normal file
187
client/src/assets/css/base.css
Normal file
@ -0,0 +1,187 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #E7E7E7;
|
||||
--vt-c-black: #181818;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-black);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
|
||||
--color-nav-text-light: #178c85ff;
|
||||
--color-nav-text-light-hover: #12706aff;
|
||||
--color-nav-text-light-active: #093835ff;
|
||||
|
||||
--color-nav-text-dark: #a8a8a8ff;
|
||||
--color-nav-text-dark-hover: #dbdbdbff;
|
||||
--color-nav-text-dark-active: #ffffff;
|
||||
|
||||
--color-background-light: #99edcdff;
|
||||
--color-background-soft-light: #57e1acff;
|
||||
--color-background-mute-light: #23c788ff;
|
||||
|
||||
--color-background-dark: #23c788;
|
||||
--color-background-soft-dark: #57e1acff;
|
||||
--color-background-mute-dark: #99edcdff;
|
||||
|
||||
--primary-light: #99edcdff;
|
||||
--color-primary-hover-light: #57e1acff;
|
||||
--color-primary-active-light: #23c788ff;
|
||||
--color-primary-disabled-light: #909090ff;
|
||||
|
||||
--primary-dark: #23c788ff;
|
||||
--color-primary-hover-dark: #57e1acff;
|
||||
--color-primary-active-dark: #99edcdff;
|
||||
--color-primary-disabled-dark: #909090ff;
|
||||
|
||||
--secondary-light: #7fd9bfff;
|
||||
--color-secondary-hover-light: #178c8524;
|
||||
--color-secondary-active-light: #c9c8fbff;
|
||||
--color-secondary-disabled-light: #88d3d3ff;
|
||||
|
||||
--secondary-dark: #60AEAEff;
|
||||
--color-secondary-hover-dark: #4f9b9bff;
|
||||
--color-secondary-active-dark: #3e7776ff;
|
||||
--color-secondary-disabled-dark: #88d3d3ff;
|
||||
|
||||
--user-font: 'Public Sans', sans-serif;
|
||||
--user-font-reversion: 'Public Sans', sans-serif;
|
||||
|
||||
--color-modal-background-light: #b9b9b9ff;
|
||||
--color-modal-background-light-inverted: #464646ff;
|
||||
|
||||
--color-modal-background-dark: #464646ff;
|
||||
--color-modal-background-dark-inverted: #b9b9b9ff;
|
||||
|
||||
--color-form-background-light: #cdcdcdff;
|
||||
|
||||
--color-form-background-dark: #323232ff;
|
||||
|
||||
--color-border-light-1: #333333ff;
|
||||
--color-border-light-2: #6D6D6Dff;
|
||||
|
||||
--color-border-dark-1: #ccccccff;
|
||||
--color-border-dark-2: #929292ff;
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--color-background-dark);
|
||||
--color-background-reversion: var(--color-background-dark);
|
||||
--color-background-soft: var(--color-background-soft-dark);
|
||||
--color-background-mute: var(--color-background-mute-dark);
|
||||
|
||||
--color-border: var(--color-border-dark-1);
|
||||
--color-border-invert: var(--color-border-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-primary: var(--primary-dark);
|
||||
--color-primary-reversion: var(--primary-dark);
|
||||
--color-secondary: var(--secondary-dark);
|
||||
--color-secondary-reversion: var(--secondary-dark);
|
||||
--color-primary-hover: var(--color-primary-hover-dark);
|
||||
--color-secondary-hover: var(--color-secondary-hover-dark);
|
||||
--color-primary-active: var(--color-primary-active-dark);
|
||||
--color-secondary-active: var(--color-secondary-active-dark);
|
||||
|
||||
--color-primary-disabled: var(--color-primary-disabled-dark);
|
||||
--color-secondary-disabled: var(--color-secondary-disabled-dark);
|
||||
--color-modal-background: var(--color-modal-background-dark);
|
||||
--color-modal-background-inverted: var(--color-modal-background-dark-inverted);
|
||||
--color-form-background: var(--color-form-background-dark);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-1);
|
||||
--color-primary-font: var(--color-text);
|
||||
--color-secondary-font: var(--color-text);
|
||||
--color-background-font: var(--color-text);
|
||||
--color-background-mute-font: var(--color-text);
|
||||
--color-background-soft-font: var(--color-text);
|
||||
--color-modal-background-font: var(--color-text);
|
||||
--color-form-background-font: var(--color-text);
|
||||
--color-modal-background-inverted-font: var(--color-text);
|
||||
--color-nav-text: var(--color-nav-text-dark);
|
||||
--color-nav-text-hover: var(--color-nav-text-dark-hover);
|
||||
--color-nav-text-active: var(--color-nav-text-dark-active);
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference), (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-nav-text: var(--color-nav-text-light);
|
||||
--color-text: var(--color-nav-text);
|
||||
--color-background: var(--color-background-light);
|
||||
--color-background-reversion: var(--color-background-light);
|
||||
--color-background-soft: var(--color-background-soft-light);
|
||||
--color-background-mute: var(--color-background-mute-light);
|
||||
|
||||
--color-border: var(--color-border-light-1);
|
||||
--color-border-invert: var(--color-border-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
--color-primary: var(--primary-light);
|
||||
--color-primary-reversion: var(--primary-light);
|
||||
--color-secondary: var(--secondary-light);
|
||||
--color-secondary-reversion: var(--secondary-light);
|
||||
--color-primary-hover: var(--color-primary-hover-light);
|
||||
--color-secondary-hover: var(--color-secondary-hover-light);
|
||||
--color-primary-active: var(--color-primary-active-light);
|
||||
--color-secondary-active: var(--color-secondary-active-light);
|
||||
--color-primary-disabled: var(--color-primary-disabled-light);
|
||||
--color-secondary-disabled: var(--color-secondary-disabled-light);
|
||||
|
||||
--color-modal-background: var(--color-modal-background-light);
|
||||
--color-modal-background-inverted: var(--color-modal-background-light-inverted);
|
||||
--color-form-background: var(--color-form-background-light);
|
||||
|
||||
--color-primary-font: var(--color-text);
|
||||
--color-secondary-font: var(--color-text);
|
||||
--color-background-font: var(--color-text);
|
||||
--color-background-mute-font: var(--color-text);
|
||||
--color-background-soft-font: var(--color-text);
|
||||
--color-modal-background-font: var(--color-text);
|
||||
--color-form-background-font: var(--color-text);
|
||||
--color-modal-background-inverted-font: var(--color-text);
|
||||
--color-nav-text-hover: var(--color-nav-text-light-hover);
|
||||
--color-nav-text-active: var(--color-nav-text-light-active);
|
||||
--color-heading: var(--color-nav-text);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-background-font);
|
||||
background: var(--color-background);
|
||||
transition: color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family: Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
520
client/src/assets/css/style.css
Normal file
520
client/src/assets/css/style.css
Normal file
@ -0,0 +1,520 @@
|
||||
@import url('../../../node_modules/bootstrap/dist/css/bootstrap.min.css');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
@import url('https://db.onlinewebfonts.com/c/89d64a5584094a2f17662a5765bbf93b?family=CCWhatchamacallit+W03+Regular');
|
||||
@import url('https://db.onlinewebfonts.com/c/d1c74f711e58ad0844c33d6af9c6e31a?family=CCWhatchamacallit+W01+Bold');
|
||||
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-background-font);
|
||||
margin: 0;
|
||||
font-family: 'CCWhatchamacallit W03 Regular',sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: 100vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
b, strong, .bold {
|
||||
font-family: 'CCWhatchamacallit W01 Bold', sans-serif;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
left: 175px;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.title1{
|
||||
font-variation-settings: 'wght' 400, 'wdth' 50, 'ital' 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.border-top {
|
||||
color: var(--color-background-font);
|
||||
background-color: #86dfc3ff !important;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background-color: #86dfc3ff;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: var(--color-primary-disabled) !important;
|
||||
color: #f0f0f0!important; /* Bootstrap's default disabled text color */
|
||||
}
|
||||
|
||||
table {
|
||||
color: var(--color-background-font);
|
||||
background-color: var(--color-background-mute);
|
||||
border: 1px solid var(--color-border-invert);
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
tr {
|
||||
background-color: var(--color-background) ;
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-background-font);
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
border-top: 1px solid var(--color-border-invert);
|
||||
border-bottom: 1px solid var(--color-border-invert) !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
text-align: center !important;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
tr {
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-background-font);
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.accordion-button,
|
||||
.accordion-button:not(.collapsed) {
|
||||
color: var(--color-background-mute-font);
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
border: 1px solid var(--color-modal-background-inverted);
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.dropdown-menu.show,
|
||||
.dropdown-menu .dropdown-menu {
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-modal-background-inverted);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--color-nav-text);
|
||||
}
|
||||
|
||||
.dropdown-menu li a:hover,
|
||||
.dropdown-item:hover,
|
||||
.dropdown-submenu .dropdown-item:hover {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-background-font);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
color: var(--color-background-font);
|
||||
background-color: var(--color-background) !important;
|
||||
border: 1px solid var(--color-modal-background-inverted);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
color: var(--color-background-font);
|
||||
background-color: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-modal-background-inverted);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
color: var(--color-background-font);
|
||||
background-color: var(--color-background-soft);
|
||||
border-top: 1px solid var(--color-modal-background-inverted);
|
||||
}
|
||||
|
||||
.modal-body .card-body, .card-body {
|
||||
color: var(--color-background-font);
|
||||
background-color: var(--color-background) !important;
|
||||
}
|
||||
|
||||
.modal-body .card, .card {
|
||||
background-color: var(--color-background) !important;
|
||||
color: var(--color-background-font);
|
||||
border: 2px solid var(--color-modal-background-inverted);
|
||||
}
|
||||
|
||||
.tooltip-modal {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
margin-left: 3rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tooltip-modal .tooltiptext {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s linear,
|
||||
visibility 0.2s linear 0.2s;
|
||||
width: 200px;
|
||||
background-color: var(--color-modal-background-inverted);
|
||||
color: var(--color-background);
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
margin-left: -100px;
|
||||
}
|
||||
|
||||
.tooltip-modal .tooltiptext::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-modal-background-inverted) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-modal:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
.tooltip-modal .tooltiptext:hover {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dp__input,
|
||||
.dp__input:focus,
|
||||
.dp__input:hover {
|
||||
background-color: var(--color-background) !important;
|
||||
color: var(--color-background-font) !important;
|
||||
border-color: var(--color-modal-background-inverted) !important;
|
||||
}
|
||||
|
||||
.dp__theme_light {
|
||||
background-color: transparent !important;
|
||||
border-color: var(--color-modal-background-inverted) !important;
|
||||
--dp-range-between-dates-background-color: var(--color-background) !important;
|
||||
--dp-range-between-border-color: var(--color-background) !important;
|
||||
--dp-hover-color: var(--color-form-background) !important;
|
||||
--dp-background-color: var(--color-background-mute) !important;
|
||||
}
|
||||
|
||||
.dp__arrow_bottom {
|
||||
background-color: var(--color-background-mute) !important;
|
||||
color: var(--color-background-mute-font) !important;
|
||||
border-color: var(--color-modal-background-inverted) !important;
|
||||
--dp-range-between-dates-background-color: var(--color-background) !important;
|
||||
--dp-range-between-border-color: var(--color-background) !important;
|
||||
--dp-hover-color: var(--color-form-background) !important;
|
||||
--dp-background-color: var(--color-background-mute) !important;
|
||||
}
|
||||
|
||||
.dp__input_icon,
|
||||
.dp__clear_icon {
|
||||
color: var(--color-background-font) !important;
|
||||
}
|
||||
|
||||
.page-item.disabled {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scrollable-filter {
|
||||
height: 550px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.page-of-pages {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-group:focus-within .input-group-text:not(:focus),
|
||||
.form-check-input:focus,
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border-color: var(--color-secondary-active);
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.input-group .input-group-text,
|
||||
.input-group-text:focus {
|
||||
border-color: var(--color-modal-background-inverted);
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
}
|
||||
.form-control {
|
||||
color: var(--color-background-font);
|
||||
border-color: var(--color-border)
|
||||
}
|
||||
.form-control::placeholder {
|
||||
color: #808080D0;
|
||||
}
|
||||
|
||||
select.form-control,
|
||||
select#location {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,<svg width='16' height='16' fill='gray' xmlns='http://www.w3.org/2000/svg'><path d='M4 6l4 4 4-4'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.25rem center;
|
||||
background-size: 1.25em;
|
||||
padding-right: 2em;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
select.form-control:hover,
|
||||
select#location:hover,
|
||||
select.form-control:focus,
|
||||
select#location:focus {
|
||||
border-color: var(--color-secondary-active)
|
||||
}
|
||||
|
||||
select.form-control:focus,
|
||||
select#location:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(134, 223, 195, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
--bs-btn-color: var(--color-primary-font);
|
||||
--bs-btn-bg: var(--color-primary);
|
||||
--bs-btn-border-color: var(--color-primary);
|
||||
--bs-btn-hover-color: var(--color-primary-font);
|
||||
--bs-btn-hover-bg: var(--color-primary-hover);
|
||||
--bs-btn-hover-border-color: var(--color-primary-hover);
|
||||
--bs-btn-focus-shadow-rgb: 49, 132, 253;
|
||||
--bs-btn-active-color: var(--color-primary-font);
|
||||
--bs-btn-active-bg: var(--color-primary-active);
|
||||
--bs-btn-active-border-color: var(--color-primary-active);
|
||||
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
||||
--bs-btn-disabled-color: var(--color-primary-font);
|
||||
--bs-btn-disabled-bg: var(--color-primary-disabled);
|
||||
--bs-btn-disabled-border-color: var(--color-primary-disabled);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--bs-btn-color: var(--color-secondary-font);
|
||||
--bs-btn-bg: var(--color-secondary);
|
||||
--bs-btn-border-color: var(--color-secondary);
|
||||
--bs-btn-hover-color: var(--color-secondary-font);
|
||||
--bs-btn-hover-bg: var(--color-secondary-hover);
|
||||
--bs-btn-hover-border-color: var(--color-secondary-hover);
|
||||
--bs-btn-focus-shadow-rgb: 49, 132, 253;
|
||||
--bs-btn-active-color: var(--color-secondary-font);
|
||||
--bs-btn-active-bg: var(--color-secondary-active);
|
||||
--bs-btn-active-border-color: var(--color-secondary-active);
|
||||
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
||||
--bs-btn-disabled-color: var(--color-secondary-font);
|
||||
--bs-btn-disabled-bg: var(--color-secondary-disabled);
|
||||
--bs-btn-disabled-border-color: var(--color-secondary-disabled);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
--bs-btn-color: #fff;
|
||||
--bs-btn-bg: #ad6060;
|
||||
--bs-btn-border-color: #ad6060;
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-hover-bg: #935252;
|
||||
--bs-btn-hover-border-color: #935252;
|
||||
--bs-btn-focus-shadow-rgb: 225, 83, 97;
|
||||
--bs-btn-active-color: #fff;
|
||||
--bs-btn-active-bg: #794343;
|
||||
--bs-btn-active-border-color: #794343;
|
||||
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
||||
--bs-btn-disabled-color: #fff;
|
||||
--bs-btn-disabled-bg: #ad6060;
|
||||
--bs-btn-disabled-border-color: #ad6060;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.btn-link, .routerLink {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-link:hover, .routerLink:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-link:active, .routerLink:active {
|
||||
color: var(--color-primary-active);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
color: var(--color-primary-font);
|
||||
border-color: var(--color-modal-background-inverted);
|
||||
background-color: #86dfc3ff;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
border-color: var(--color-modal-background-inverted);
|
||||
background-color: #6ed1b0ff;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
--bs-pagination-padding-x: 0.75rem;
|
||||
--bs-pagination-padding-y: 0.375rem;
|
||||
--bs-pagination-font-size: 1rem;
|
||||
--bs-pagination-color: var(--color-primary-font);
|
||||
--bs-pagination-bg: var(--color-primary);
|
||||
--bs-pagination-border-width: var(--bs-border-width);
|
||||
--bs-pagination-border-color: var(--color-border);
|
||||
--bs-pagination-border-radius: var(--bs-border-radius);
|
||||
--bs-pagination-hover-color: var(--color-primary-font);
|
||||
--bs-pagination-hover-bg: var(--color-primary-hover);
|
||||
--bs-pagination-hover-border-color: var(--color-border);
|
||||
--bs-pagination-focus-color: var(--color-primary-font);
|
||||
--bs-pagination-focus-bg: var(--color-primary-hover);
|
||||
--bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(49, 132, 253, 0.25);
|
||||
--bs-pagination-active-color: var(--color-primary-font);
|
||||
--bs-pagination-active-bg: var(--color-primary-active);
|
||||
--bs-pagination-active-border-color: var(--color-border);
|
||||
--bs-pagination-disabled-color: var(--color-background-font);
|
||||
--bs-pagination-disabled-bg: var(--color-background-mute);
|
||||
--bs-pagination-disabled-border-color: var(--color-border);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.progress {
|
||||
--bs-progress-bar-bg: var(--color-primary, #7561a9);
|
||||
--bs-progress-bg: var(--color-background-soft, #d8d8d8);
|
||||
border: 1px solid var(--color-background-mute, #b9b9b9);
|
||||
}
|
||||
|
||||
.alert {
|
||||
--bs-alert-border: 1px solid #ad6060;
|
||||
--bs-alert-bg: #e4b6b6;
|
||||
}
|
||||
|
||||
.offcanvas-header {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-background-font);
|
||||
box-shadow: var(--vt-c-black) 0, 0, 10px;
|
||||
}
|
||||
|
||||
.offcanvas-body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-background-font);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button:not(.btn-primary):not(.btn-secondary) i {
|
||||
color: var(--color-nav-text-hover);
|
||||
}
|
||||
|
||||
button:not(.btn-primary):not(.btn-secondary):hover i {
|
||||
color: var(--color-background-font);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
color: var(--color-background-font);
|
||||
}
|
||||
|
||||
.fade-tabs-enter-active, .fade-tabs-leave-active {
|
||||
transition: opacity .5s;
|
||||
}
|
||||
|
||||
.fade-tabs-enter-from, .fade-tabs-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-tabs-enter-to, .fade-tabs-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.modal-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 1s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
BIN
client/src/assets/img/scales-ph.jpg
Normal file
BIN
client/src/assets/img/scales-ph.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
186
client/src/components/Navbar.vue
Normal file
186
client/src/components/Navbar.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import {RouterLink} from 'vue-router'
|
||||
import {ref} from 'vue'
|
||||
import {session} from '@models/session.ts'
|
||||
import {useLogin} from '@models/session.ts'
|
||||
|
||||
const {logout} = useLogin()
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{name: 'My Work', path: '/'},
|
||||
{name: 'About/Resume', path: '/about'},
|
||||
{name: 'Contact', path: '/contact'}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Desktop Navbar -->
|
||||
<div style="position: sticky; top: 0; z-index: 1000" class="desktop-navbar">
|
||||
<nav class="navbar navbar-expand thick">
|
||||
<div class="navbar-brand">
|
||||
<RouterLink to="/" class="navbar-brand">
|
||||
<img src="@img/scales-ph.jpg" alt="icon" class="d-inline-block align-text-top logo">
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="justify-content-start">
|
||||
<ul class="navbar-nav">
|
||||
<li v-for="link in navItems" :key="link.name" class="nav-item">
|
||||
<RouterLink :to="link.path" class="nav-link" active-class="active-tab">
|
||||
{{ link.name }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="!session.user" class="ms-auto d-flex align-items-center">
|
||||
<RouterLink to="/login" class="nav-link" active-class="active-tab" style="margin-right: 1.5rem;">
|
||||
Login
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else class="ms-auto d-flex align-items-center">
|
||||
<RouterLink v-if="session.user?.role === 'admin'" to="/admin" class="nav-link" active-class="active-tab">
|
||||
Admin
|
||||
</RouterLink>
|
||||
<button @click="logout" class="nav-link" style="margin-right: 1.5rem;">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navbar -->
|
||||
<div class="mobile-navbar">
|
||||
<nav class="navbar thick">
|
||||
<button class="hamburger" @click="mobileMenuOpen = !mobileMenuOpen">
|
||||
<span :class="['hamburger-icon', { open: mobileMenuOpen }]"></span>
|
||||
</button>
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<RouterLink v-for="link in navItems" :key="link.name" :to="link.path" @click="mobileMenuOpen = false"
|
||||
class="nav-link" active-class="active-tab">
|
||||
{{ link.name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.desktop-navbar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-navbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show mobile navbar, hide desktop on small screens */
|
||||
@media (max-width: 767px) {
|
||||
.desktop-navbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-navbar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
background: var(--color-background);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 3.5rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
font-size: 2rem;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger-icon {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
position: fixed;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger-icon,
|
||||
.hamburger-icon::before,
|
||||
.hamburger-icon::after {
|
||||
background: var(--color-nav-text);
|
||||
border-radius: 2px;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 4px;
|
||||
position: absolute;
|
||||
width: 2rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hamburger-icon {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.hamburger-icon::before {
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.hamburger-icon::after {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.hamburger-icon.open {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hamburger-icon.open::before {
|
||||
transform: translateY(8px) rotate(45deg);
|
||||
}
|
||||
|
||||
.hamburger-icon.open::after {
|
||||
transform: translateY(-8px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
margin-top: -5px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: var(--color-nav-text);
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-nav-text-hover);
|
||||
}
|
||||
|
||||
.thick {
|
||||
border-bottom: 2px solid var(--color-background-soft);
|
||||
box-shadow: 0 2px 6px -2px var(--vt-c-black);
|
||||
}
|
||||
|
||||
.active-tab {
|
||||
color: var(--color-nav-text-active) !important;
|
||||
font-weight: bolder;
|
||||
}
|
||||
</style>
|
||||
19
client/src/globals.ts
Normal file
19
client/src/globals.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ref } from "vue";
|
||||
import { useToast } from "vue-toast-notification";
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 768);
|
||||
|
||||
function updateIsMobile() {isMobile.value = window.innerWidth <= 768;}
|
||||
|
||||
const routerTransitioning = ref(false);
|
||||
const subTabTransitioning = ref(false);
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
duration: 5000,
|
||||
dismissible: true,
|
||||
pauseOnHover: true,
|
||||
type: 'default',
|
||||
})
|
||||
|
||||
export { isMobile, routerTransitioning, subTabTransitioning, updateIsMobile, toast };
|
||||
28
client/src/main.ts
Normal file
28
client/src/main.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import '@css/base.css'
|
||||
import '@css/style.css'
|
||||
import App from '@/App.vue'
|
||||
import router from "@/router";
|
||||
import { useToast } from "vue-toast-notification";
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css';
|
||||
import { session } from "@models/session.ts";
|
||||
import {jwtDecode} from 'jwt-decode';
|
||||
import type {SecureUser} from "@models/User.ts";
|
||||
|
||||
if (localStorage.getItem("token") && localStorage.getItem("username")) {
|
||||
const decode: SecureUser = jwtDecode(localStorage.getItem("token") || "");
|
||||
session.user = {
|
||||
username: localStorage.getItem("username") || "",
|
||||
token: localStorage.getItem("token") || "",
|
||||
role: decode?.role || "user"
|
||||
};
|
||||
session.token = localStorage.getItem("token");
|
||||
|
||||
}
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(useToast)
|
||||
.mount('#app')
|
||||
|
||||
|
||||
12
client/src/models/TransferTypes.ts
Normal file
12
client/src/models/TransferTypes.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface DataEnvelope<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: Error;
|
||||
}
|
||||
export interface DataListEnvelope<T> extends DataEnvelope<T[]>{
|
||||
data: T[];
|
||||
totalItems?: number;
|
||||
pageLimit?: number;
|
||||
}
|
||||
|
||||
export type DynamicDataEnvelope<T> = T extends (infer U)[] ? DataListEnvelope<U> : DataEnvelope<T>;
|
||||
6
client/src/models/User.ts
Normal file
6
client/src/models/User.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface SecureUser {
|
||||
username: string;
|
||||
role?: string;
|
||||
id?: number;
|
||||
token?: string;
|
||||
}
|
||||
30
client/src/models/rest.ts
Normal file
30
client/src/models/rest.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type {DynamicDataEnvelope} from "@models/TransferTypes.ts";
|
||||
|
||||
export const API_ROOT = import.meta.env.VITE_API_ROOT;
|
||||
|
||||
async function rest(url: string, body?: unknown, method?: string, headers?: HeadersInit) {
|
||||
const isFormData = body instanceof FormData;
|
||||
const options: RequestInit = {
|
||||
method: method ?? (body ? "POST" : "GET"),
|
||||
headers: {
|
||||
...headers
|
||||
},
|
||||
body: isFormData ? body : JSON.stringify(body)
|
||||
};
|
||||
|
||||
if (!isFormData) {
|
||||
options.headers = options.headers || {};
|
||||
(options.headers as Record<string, string>)['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return await fetch(url, options)
|
||||
.then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err)))
|
||||
.catch(err => Promise.reject(err));
|
||||
}
|
||||
|
||||
export async function api<T>(action: string, body?: unknown, method?: string, headers?: HeadersInit) {
|
||||
return rest(`${API_ROOT}${action}`, body, method, headers).then(data => {
|
||||
if (data && typeof data === 'object' && 'data' in data) return data as DynamicDataEnvelope<T>;
|
||||
else throw new Error("Invalid response format");
|
||||
}) as Promise<DynamicDataEnvelope<T>>;
|
||||
}
|
||||
65
client/src/models/session.ts
Normal file
65
client/src/models/session.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {reactive} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {toast} from "@/globals.ts";
|
||||
import { type DataEnvelope } from "./TransferTypes";
|
||||
import {api} from "@models/rest.ts";
|
||||
import type { SecureUser } from "@models/User.ts";
|
||||
|
||||
export const session = reactive({
|
||||
user: null as SecureUser | null,
|
||||
token: null as string | null,
|
||||
redirectURL: null as string | null,
|
||||
messages: [] as {
|
||||
type: string,
|
||||
message: string
|
||||
}[],
|
||||
});
|
||||
|
||||
export function useLogin() {
|
||||
const router = useRouter();
|
||||
return {
|
||||
async login(username: string, password: string): Promise<SecureUser> {
|
||||
return await api<SecureUser>("/api/auth", {username, password}, "POST")
|
||||
.then((response: DataEnvelope<SecureUser> ) => {
|
||||
session.user = response.data;
|
||||
if (!session.user) throw new Error("Invalid login credentials. Please try again.");
|
||||
session.token = response.data.token || null;
|
||||
router.push(session.redirectURL ?? "/").then((r) => r);
|
||||
session.redirectURL = null;
|
||||
toast.success("Welcome " + session.user.username + "!\nYou are now logged in.");
|
||||
localStorage.setItem("username", session.user.username);
|
||||
localStorage.setItem("token", session.token ?? "");
|
||||
return session.user;
|
||||
})
|
||||
.catch((err)=>{throw err}) as SecureUser;
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
session.user = null;
|
||||
session.token = null;
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("username");
|
||||
for(let i = 0; i < session.messages.length; i++) {
|
||||
console.debug("Messages: ");
|
||||
if(session.messages[i].type === "error") {
|
||||
console.error(session.messages[i].message);
|
||||
} else {
|
||||
console.debug(session.messages[i].message);
|
||||
}
|
||||
}
|
||||
router.push('/').then((r) => r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleExpiredToken(err: Error): Promise<never> {
|
||||
if (err.message === 'jwt expired') {
|
||||
session.token = null;
|
||||
session.user = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('username');
|
||||
toast.error("Your session has expired. Please log in again.");
|
||||
const router = useRouter();
|
||||
router.push('/login').then((r) => r);
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
69
client/src/router/index.ts
Normal file
69
client/src/router/index.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import {createRouter, createWebHashHistory} from 'vue-router'
|
||||
import MainPage from '@views/MainPage.vue'
|
||||
import NotFoundPage from "@views/NotFoundPage.vue";
|
||||
|
||||
import {session} from '@models/session.ts';
|
||||
import { toast } from '@/globals.ts';
|
||||
import {jwtDecode} from 'jwt-decode';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'MainPage',
|
||||
component: MainPage,
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFoundPage
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
const token = session.token ?? localStorage.getItem('token');
|
||||
if (!token) {
|
||||
session.redirectURL = to.fullPath; // Save the intended route
|
||||
return next("Login");
|
||||
}
|
||||
try {
|
||||
const decoded: any = jwtDecode(token);
|
||||
if( !decoded ) {
|
||||
toast.error("Invalid token. Please log in again.");
|
||||
session.token = null;
|
||||
localStorage.removeItem('token');
|
||||
session.user = null;
|
||||
localStorage.removeItem('username');
|
||||
return next("Login");
|
||||
}
|
||||
if (decoded.exp * 1000 < Date.now()) {
|
||||
toast.error("Token expired. Please log in again.");
|
||||
session.token = null;
|
||||
localStorage.removeItem('token');
|
||||
session.user = null;
|
||||
localStorage.removeItem('username');
|
||||
return next("Login");
|
||||
}
|
||||
if (decoded.role === 'admin') {
|
||||
return next();
|
||||
} else if (decoded.role === 'user') {
|
||||
toast.error("You do not have permission to access this page.");
|
||||
return from.fullPath;
|
||||
}
|
||||
else {
|
||||
return next({name: 'NotFound'});
|
||||
}
|
||||
} catch (e) {
|
||||
return next({name: 'NotFound'});
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
export default router
|
||||
9
client/src/views/MainPage.vue
Normal file
9
client/src/views/MainPage.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
<template>
|
||||
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
11
client/src/views/NotFoundPage.vue
Normal file
11
client/src/views/NotFoundPage.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
33
client/tsconfig.app.json
Normal file
33
client/tsconfig.app.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@assets/*": ["src/assets/*"],
|
||||
"@css/*": ["src/assets/css/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@router/*": ["src/router/*"],
|
||||
"@img/*": ["src/assets/img/*"],
|
||||
"@models/*": ["src/models/*"],
|
||||
"@stores/*": ["src/stores/*"],
|
||||
"@views/*": ["src/views/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
7
client/tsconfig.json
Normal file
7
client/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
27
client/tsconfig.node.json
Normal file
27
client/tsconfig.node.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
22
client/vite.config.ts
Normal file
22
client/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
'@assets': '/src/assets',
|
||||
'@css': '/src/assets/css',
|
||||
'@components': '/src/components',
|
||||
'@globals': '/src/globals',
|
||||
'@img': '/src/assets/img',
|
||||
'@layouts': '/src/layouts',
|
||||
'@models': '/src/models',
|
||||
'@router': '/src/router',
|
||||
'@stores': '/src/stores',
|
||||
'@views': '/src/views',
|
||||
},
|
||||
},
|
||||
plugins: [vue()],
|
||||
})
|
||||
Reference in New Issue
Block a user