Compare commits
4 Commits
cf3f4270f9
...
4ca3c9d9f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ca3c9d9f4 | |||
| ee3abe358b | |||
| b01e1cd586 | |||
| 5fc2a1eb24 |
11
.idea/.gitignore
generated
vendored
Normal file
11
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
/dataSources.xml
|
||||||
|
/deployment.xml
|
||||||
|
|
||||||
6
.idea/GitLink.xml
generated
Normal file
6
.idea/GitLink.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="uk.co.ben_gibson.git.link.SettingsState">
|
||||||
|
<option name="host" value="e0f86390-1091-4871-8aeb-f534fbc99cf0" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/IYLaw-Site.iml
generated
Normal file
12
.idea/IYLaw-Site.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
31
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
31
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyPep8Inspection" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="E275" />
|
||||||
|
<option value="E302" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N802" />
|
||||||
|
<option value="N803" />
|
||||||
|
<option value="N806" />
|
||||||
|
<option value="N801" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredIdentifiers">
|
||||||
|
<list>
|
||||||
|
<option value="server.models.printers.*" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/IYLaw-Site.iml" filepath="$PROJECT_DIR$/.idea/IYLaw-Site.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/sqldialects.xml
generated
Normal file
6
.idea/sqldialects.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="PROJECT" dialect="PostgreSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
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?
|
||||||
|
|
||||||
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()],
|
||||||
|
})
|
||||||
40
server/app.js
Normal file
40
server/app.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
console.log("starting server...");
|
||||||
|
import path from "path";
|
||||||
|
import {fileURLToPath} from "url";
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import userRouter from "./routes/users.js";
|
||||||
|
import errorHandler from "./middlewares/ErrorHandler.js";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import db from "./models/db.js";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.pool.query('SELECT 1');
|
||||||
|
console.log('Database connection successful.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Database connection failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cors({
|
||||||
|
origin: ['https://ijylaw.li0nhunter.com', 'https://ijylaw.com', "https://www.ijylaw.com", "http://localhost:5173"],
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send("Hello! You've reached ijy law backend. Unless you are a developer, you probably shouldn't be here.");
|
||||||
|
});
|
||||||
|
app.use("/api/users", userRouter);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server is running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
0
server/controllers/index.js
Normal file
0
server/controllers/index.js
Normal file
16
server/controllers/users.js
Normal file
16
server/controllers/users.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import db from "../models/db.js";
|
||||||
|
import users from "../models/users.js";
|
||||||
|
|
||||||
|
/** * Fetches all users from the database.
|
||||||
|
* @return {Promise<User[]>}
|
||||||
|
*/
|
||||||
|
const getAllUsers = async () => {
|
||||||
|
try {
|
||||||
|
return await users.getAllUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getAllUsers };
|
||||||
44
server/middlewares/AuthHandler.js
Normal file
44
server/middlewares/AuthHandler.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
/** @typedef {import("express").Request} Request */
|
||||||
|
/** @typedef {import("express").Response} Response */
|
||||||
|
/** @typedef {import("express").NextFunction} NextFunction */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware to verify JWT and check for the admin role.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export default async (req, res, next) => {
|
||||||
|
const token = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).send({data: null, error: "Token missing"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await new Promise((resolve, reject) => {
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET || "super-secret-key", (err, decoded) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
return resolve(decoded);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Check if payload has id and role and if the role is admin
|
||||||
|
if (!payload || !payload.id || !payload.role) {
|
||||||
|
res.status(401).json({data: null, error: "Invalid token"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.role !== "admin") {
|
||||||
|
res.status(403).json({data: null, error: "admins only"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === 'jwt expired') {
|
||||||
|
res.status(401).json({data: null, message: "Token expired", error: err});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(401).json({data: null, message: "Invalid token", error: err});
|
||||||
|
}
|
||||||
|
};
|
||||||
17
server/middlewares/ErrorHandler.js
Normal file
17
server/middlewares/ErrorHandler.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/** @typedef {import("express").Request} Request */
|
||||||
|
/** @typedef {import("express").Response} Response */
|
||||||
|
/** @typedef {import("express").NextFunction} NextFunction */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express error handler middleware.
|
||||||
|
* @param {Error & {status?: number}} err
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
export default (err, req, res, next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
message: err.message || 'Internal Server Error',
|
||||||
|
});
|
||||||
|
};
|
||||||
31
server/models/db.js
Normal file
31
server/models/db.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import {Pool} from 'pg';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = new Pool(process.env.DATABASE_URL ? {connectionString: process.env.DATABASE_URL} : {
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic query function for PostgreSQL database.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {any[]} [params]
|
||||||
|
* @return {Promise<any[]>}
|
||||||
|
*/
|
||||||
|
async function query(text, params= []) {
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await pool.query(text, params).then(qr => qr);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (params) for (let i = 0; i < params.length; i++) text = text.replace("$".concat(String(i + 1)), params[i]);
|
||||||
|
console.log('executed query', {text, duration, rows: res.rowCount});
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
export default {
|
||||||
|
pool,
|
||||||
|
query
|
||||||
|
};
|
||||||
23
server/models/users.js
Normal file
23
server/models/users.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import db from "./db.js";
|
||||||
|
/** @typedef User
|
||||||
|
* @property {number} id - The unique identifier for the user.
|
||||||
|
* @property {string} username - The username of the user.
|
||||||
|
* @property {string} password - The hashed password of the user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all users from the database.
|
||||||
|
* @return {Promise<User[]>}
|
||||||
|
*/
|
||||||
|
const getAllUsers = async () => {
|
||||||
|
try {
|
||||||
|
return await db.query('SELECT * FROM users').then((users) => users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getAllUsers
|
||||||
|
}
|
||||||
1485
server/package-lock.json
generated
Normal file
1485
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/package.json
Normal file
21
server/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdoc": "^4.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/routes/index.js
Normal file
9
server/routes/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import express from 'express';
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/* GET home page. */
|
||||||
|
router.get('/', function(req, res, next) {
|
||||||
|
res.render('index', { title: 'Express' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
8
server/routes/users.js
Normal file
8
server/routes/users.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import express from 'express';
|
||||||
|
const router = express.Router();
|
||||||
|
import userController from '../controllers/users.js';
|
||||||
|
import AuthHandler from "../middlewares/AuthHandler.js";
|
||||||
|
/* GET users listing. */
|
||||||
|
router.get('/', AuthHandler, userController.getAllUsers);
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user