Compare commits

..

4 Commits

Author SHA1 Message Date
4ca3c9d9f4 Update .gitignore to include additional datasource and deployment files 2025-07-15 13:44:29 -04:00
ee3abe358b idea files initial commit 2025-07-15 13:39:01 -04:00
b01e1cd586 client initial commit 2025-07-15 13:35:27 -04:00
5fc2a1eb24 server initial commit 2025-07-15 13:34:30 -04:00
42 changed files with 4988 additions and 0 deletions

11
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
client/package.json Normal file
View 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
View 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">&copy;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>

View 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;
}

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View 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
View 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
View 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')

View 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>;

View File

@ -0,0 +1,6 @@
export interface SecureUser {
username: string;
role?: string;
id?: number;
token?: string;
}

30
client/src/models/rest.ts Normal file
View 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>>;
}

View 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)
}

View 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

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

33
client/tsconfig.app.json Normal file
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

27
client/tsconfig.node.json Normal file
View 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
View 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
View 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}`);
});

View File

View 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 };

View 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});
}
};

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

21
server/package.json Normal file
View 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
View 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
View 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;