initial commit

This commit is contained in:
Ari Yeger
2025-07-23 11:29:35 -04:00
parent d104d37a27
commit 5e9654728e
67 changed files with 7956 additions and 1 deletions

View File

@ -0,0 +1,23 @@
name: 'Deploy To Dokku'
on:
push:
branches:
- master
- main
jobs:
deploy:
runs-on: self-hosted
container: node:24
steps:
- name: Cloning repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to dokku
uses: dokku/github-action@master
with:
# TODO: set the dokku app name
git_remote_url: 'ssh://dokku@192.168.1.2:22/website-template'
ssh_private_key: ${{ secrets.DOKKU_DEPLOY_KEY }}

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
/GitLink.xml

12
.idea/Website-template.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>

6
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="TypeScriptCompiler">
<option name="useTypesFromServer" value="true" />
</component>
</project>

View File

@ -0,0 +1,32 @@
<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>
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

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/Website-template.iml" filepath="$PROJECT_DIR$/.idea/Website-template.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
.idea/watcherTasks.xml generated Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="run build:sass" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="scss" />
<option name="immediateSync" value="true" />
<option name="name" value="SCSS" />
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="npm" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$PROJECT_DIR$/client" />
<envs />
</TaskOptions>
</component>
</project>

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# Use official Node.js image
FROM node:24
### Client Stage
# Set working directory
WORKDIR /app
# create client directory
RUN mkdir client
# Set the working directory for the client
WORKDIR /app/client
# Copy package files and install dependencies
COPY /client/package*.json ./
RUN npm install
# Copy server source code
COPY /client .
# Build the server (if using TypeScript or build step)
RUN npm run build
### Server Stage
# Set working directory
WORKDIR /app
# create server directory
RUN mkdir server
# Set the working directory for the server
WORKDIR /app/server
# Copy package files and install dependencies
COPY /server/package*.json ./
RUN npm install
# Copy server source code
COPY /server .
# no build step for server, its already JavaScript
# Production stage
FROM node:24-slim
# Expose the port your server runs on (change if needed)
EXPOSE 8000
# Start the server
CMD ["npm", "start"]

View File

@ -1,3 +1,51 @@
# Website-template
vue ts + vite frontend, express backend, with a dockerfile and a deploy script to my dokku
### steps to get started on the dokku side
1. create a dokku app
```bash
dokku apps:create <app-name>
```
2. create postgres database
```bash
dokku postgres:create <db-name>
```
3. link the database to the app
```bash
dokku postgres:link <db-name> <app-name>
```
4. setup db if applicable
- dump local db
```bash
pg_dump -Fc --no-acl --no-owner -h localhost -U <db-user> <db-name> > db.dump
```
- restore to dokku db
```bash
dokku postgres:import <db-name> < db.dump
```
5. set app to use nginx
- set proxy to nginx
```bash
dokku proxy:set <app-name> nginx
```
- map nginx port to internal docker port
```bash
dokku ports:add <app-name> <scheme>:<nginx-port>:<internal-docker-port>
```
6. set repo dokku deploy key
- copy the private key from dokku into the repo secrets under the name `DOKKU_DEPLOY_KEY`
7. add the public key to known hosts on dokku
```bash
dokku ssh-keys:add <app-name> <path-to-public-key>
```
8. set environment variables
- create a `.env` file on the dokku server
- add the variables to the `.env` file
- add the variables to the app
```bash
cat .env | xargs dokku config:set --no-restart <app-name>
```
it should be ready to go now, you can deploy with the deployment script

24
client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# 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/png" href="/src/assets/img/L10n.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Website Template</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2215
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
client/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "npm-run-all --parallel dev:client dev:server",
"dev:server": "cd ../server && npm run dev",
"dev:client": "vite",
"prod": "cd ../server && npm run start",
"build": "run-p build:vue build:sass",
"build:vue": "vue-tsc -b && vite build",
"build:sass": "sass src/assets/css/base.scss src/assets/css/base.css && sass src/assets/css/main.scss src/assets/css/main.css",
"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",
"bootstrap": "^5.3.3",
"npm-run-all2": "^7.0.2",
"sass": "^1.89.2",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vue-tsc": "^2.2.12"
}
}

70
client/src/App.vue Normal file
View File

@ -0,0 +1,70 @@
<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 "@models/globals.ts";
let resizeObserver: ResizeObserver | null = null;
const isFooterFixed = ref(false);
watch(() => document.documentElement.scrollHeight, () => {
console.debug('Document height changed, updating footer position');
updateFooterPosition();
});
const updateFooterPosition = () => {
isFooterFixed.value = document.documentElement.scrollHeight <= window.innerHeight;
};
watch(() => routerTransitioning.value, () => updateFooterPosition());
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
updateFooterPosition()
updateIsMobile()
});
resizeObserver.observe(document.documentElement)
});
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect();
});
</script>
<template>
<NavBar/>
<div class="router-view">
<RouterView v-slot="{Component}">
<template v-if="Component">
<transition name="fade" mode="out-in" appear @before-enter="routerTransitioning=true"
@after-enter="routerTransitioning = false">
<component :is="Component"/>
</transition>
</template>
<template v-else>
<div class="container">
<div class="row justify-content-center">
<div class="col">
<h1 class="display-6">Loading...</h1>
</div>
</div>
</div>
</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://git.li0nhunter.com/li0nhunter">Li0nhunter</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,86 @@
/* semantic color variables for this project */
@media (prefers-color-scheme: dark), (prefers-color-scheme: no-preference) {
:root {
--color-divider: rgba(84, 84, 84, 0.65);
--color-divider-alt: rgba(84, 84, 84, 0.48);
--color-text: #E7E7E7;
--color-text-alt: #e7e7e7;
--color-modal-background: #464646;
--color-modal-background-inverted: #b9b9b9;
--color-form-background: #222222;
--color-form-background-focus: rgb(56.1, 56.1, 56.1);
--color-border-1: #cccccc;
--color-border-2: rgb(163.2, 163.2, 163.2);
--color-nav-text: #a8a8a8;
--color-nav-text-hover: rgb(185.4, 185.4, 185.4);
--color-nav-text-active: rgb(202.8, 202.8, 202.8);
--color-nav-text-disabled: #909090ff;
--color-background: #181818;
--color-background-soft: rgb(70.2, 70.2, 70.2);
--color-background-mute: rgb(116.4, 116.4, 116.4);
--color-primary: #035768;
--color-primary-hover: rgb(5.2598130841, 152.5345794393, 182.3401869159);
--color-primary-active: rgb(19.9794392523, 209.8037383178, 248.2205607477);
--color-primary-disabled: #909090ff;
--color-secondary: #60aeae;
--color-secondary-hover: rgb(127.8, 190.2, 190.2);
--color-secondary-active: rgb(159.6, 206.4, 206.4);
--color-secondary-disabled: #909090ff;
--color-danger: #fd0000;
--color-danger-hover: rgb(255, 49.4, 49.4);
--color-danger-active: rgb(255, 100.8, 100.8);
--color-danger-disabled: #909090ff;
}
}
@media (prefers-color-scheme: light) {
:root {
--color-divider: rgba(60, 60, 60, 0.29);
--color-divider-alt: rgba(60, 60, 60, 0.12);
--color-text: #181818;
--color-text-alt: #181818;
--color-modal-background: #b9b9b9;
--color-modal-background-inverted: #464646;
--color-form-background: #cdcdcd;
--color-form-background-focus: #a4a4a4;
--color-border-1: #333333ff;
--color-border-2: #6D6D6Dff;
--color-nav-text: #178c85;
--color-nav-text-hover: rgb(18.4, 112, 106.4);
--color-nav-text-active: rgb(13.8, 84, 79.8);
--color-nav-text-disabled: #909090ff;
--color-primary: #99edcd;
--color-primary-hover: rgb(86.7, 225.3, 172.5);
--color-primary-active: rgb(35.1, 198.9, 136.5);
--color-primary-disabled: #909090ff;
--color-background: #f0f0f0;
--color-background-soft: silver;
--color-background-mute: #909090;
--color-secondary: #7fd9bf;
--color-secondary-hover: rgb(73.9493975904, 201.2506024096, 164.4746987952);
--color-secondary-active: rgb(47.2481927711, 159.1518072289, 126.8240963855);
--color-secondary-disabled: #909090ff;
--color-danger: #fd0000;
--color-danger-hover: rgb(202.4, 0, 0);
--color-danger-active: rgb(151.8, 0, 0);
--color-danger-disabled: #909090ff;
}
}
:root {
--color-background-reversion: var(--color-background);
--color-border: var(--color-border-1);
--color-border-invert: var(--color-border-2);
--color-border-hover: var(--color-divider);
--color-primary-reversion: var(--color-primary);
--color-secondary-reversion: var(--color-secondary);
--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-heading: var(--color-nav-text);
}
/*# sourceMappingURL=base.css.map */

View File

@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["base.scss"],"names":[],"mappings":"AACA;AACA;EACE;IACE;IACA;IAGA;IACA;IAGA;IACA;IAGA;IACA;IAGA;IACA;IAGA;IACA;IACA;IACA;IAGA;IACA;IACA;IAGA;IACA;IACA;IACA;IAIA;IACA;IACA;IACA;IAGA;IACA;IACA;IACA;;;AAIJ;EACE;IACE;IACA;IAEA;IACA;IAGA;IACA;IAGA;IACA;IACA;IACA;IAGA;IACA;IACA;IACA;IAGA;IACA;IACA;IACA;IAGA;IACA;IACA;IAGA;IACA;IACA;IACA;IAGA;IACA;IACA;IACA;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA","file":"base.css"}

View File

@ -0,0 +1,121 @@
@use "sass:color";
/* semantic color variables for this project */
@media (prefers-color-scheme: dark), (prefers-color-scheme: no-preference) {
:root {
--color-divider: rgba(84, 84, 84, 0.65);
--color-divider-alt: rgba(84, 84, 84, 0.48);
$color-text: #E7E7E7;
--color-text: #{$color-text};
--color-text-alt: #{color.scale($color-text, $alpha: 64%)};
$color-modal-background: #464646ff;
--color-modal-background: #{$color-modal-background};
--color-modal-background-inverted: #{color.invert($color-modal-background)};
$color-form-background: #222222ff;
--color-form-background: #{$color-form-background};
--color-form-background-focus: #{color.scale($color-form-background, $lightness: 10%)};
$color-border-1: #ccccccff;
--color-border-1: #{$color-border-1};
--color-border-2: #{color.scale($color-border-1, $lightness: -20%)};
$color-nav-text: #a8a8a8ff;
--color-nav-text: #{$color-nav-text};
--color-nav-text-hover: #{color.scale($color-nav-text, $lightness: 20%)};
--color-nav-text-active: #{color.scale($color-nav-text, $lightness: 40%)};
--color-nav-text-disabled: #909090ff;
$color-background: #181818ff;
--color-background: #{$color-background};
--color-background-soft: #{color.scale($color-background, $lightness: 20%)};
--color-background-mute: #{color.scale($color-background, $lightness: 40%)};
$color-primary: #035768ff;
--color-primary: #{$color-primary};
--color-primary-hover: #{color.scale($color-primary, $lightness: 20%)};
--color-primary-active: #{color.scale($color-primary, $lightness: 40%)};
--color-primary-disabled: #909090ff;
$color-secondary: #60AEAEff;
--color-secondary: #{$color-secondary};
--color-secondary-hover: #{color.scale($color-secondary, $lightness: 20%)};
--color-secondary-active: #{color.scale($color-secondary, $lightness: 40%)};
--color-secondary-disabled: #909090ff;
$color-danger: #fd0000ff;
--color-danger: #{$color-danger};
--color-danger-hover: #{color.scale($color-danger, $lightness: 20%)};
--color-danger-active: #{color.scale($color-danger, $lightness: 40%)};
--color-danger-disabled: #909090ff;
}
}
@media (prefers-color-scheme: light) {
:root {
--color-divider: rgba(60, 60, 60, 0.29);
--color-divider-alt: rgba(60, 60, 60, 0.12);
$color-text: #181818;
--color-text: #{$color-text};
--color-text-alt: #{color.scale($color-text, $alpha: 66%)};
$color-modal-background: #b9b9b9ff;
--color-modal-background: #{$color-modal-background};
--color-modal-background-inverted: #{color.invert($color-modal-background)};
$color-form-background: #cdcdcdff;
--color-form-background: #{$color-form-background};
--color-form-background-focus: #{color.scale($color-form-background, $lightness: -20%)};
--color-border-1: #333333ff;
--color-border-2: #6D6D6Dff;
$color-nav-text: #178c85ff;
--color-nav-text: #{$color-nav-text};
--color-nav-text-hover: #{color.scale($color-nav-text, $lightness: -20%)};
--color-nav-text-active: #{color.scale($color-nav-text, $lightness: -40%)};
--color-nav-text-disabled: #909090ff;
$color-primary: #99edcdff;
--color-primary: #{$color-primary};
--color-primary-hover: #{color.scale($color-primary, $lightness: -20%)};
--color-primary-active: #{color.scale($color-primary, $lightness: -40%)};
--color-primary-disabled: #909090ff;
$color-background: #f0f0f0ff;
--color-background: #{$color-background};
--color-background-soft: #{color.scale($color-background, $lightness: -20%)};
--color-background-mute: #{color.scale($color-background, $lightness: -40%)};
$color-secondary: #7fd9bfff;
--color-secondary: #{$color-secondary};
--color-secondary-hover: #{color.scale($color-secondary, $lightness: -20%)};
--color-secondary-active: #{color.scale($color-secondary, $lightness: -40%)};
--color-secondary-disabled: #909090ff;
$color-danger: #fd0000ff;
--color-danger: #{$color-danger};
--color-danger-hover: #{color.scale($color-danger, $lightness: -20%)};
--color-danger-active: #{color.scale($color-danger, $lightness: -40%)};
--color-danger-disabled: #909090ff;
}
}
:root {
--color-background-reversion: var(--color-background);
--color-border: var(--color-border-1);
--color-border-invert: var(--color-border-2);
--color-border-hover: var(--color-divider);
--color-primary-reversion: var(--color-primary);
--color-secondary-reversion: var(--color-secondary);
--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-heading: var(--color-nav-text);
}

View File

@ -0,0 +1,64 @@
.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;
}
.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;
}

View File

@ -0,0 +1,309 @@
@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");
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
body {
min-height: 100vh;
width: 100vw;
color: var(--color-background-font);
background: var(--color-background);
transition: color 0.2s ease, background-color 0.2s ease;
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: 1rem;
font-weight: 500;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
.media-content {
overflow: hidden;
position: absolute;
left: 175px;
margin-top: 1rem;
margin-bottom: 1rem;
}
input, select, textarea {
color: var(--color-background-font);
background-color: var(--color-form-background);
border-color: var(--color-border);
}
.input-group {
display: flex;
align-items: center;
}
.input-group-text {
background-color: var(--color-form-background);
padding: 0.5rem;
display: flex;
align-items: center;
}
input:disabled {
background-color: var(--color-primary-disabled);
}
.container {
flex-direction: column;
align-items: center;
justify-content: center;
}
.form-check-label {
user-select: none;
}
.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;
}
.btn {
user-select: none;
}
.btn:hover {
cursor: pointer;
}
.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);
color: var(--color-form-background-font);
background-color: var(--color-form-background-focus);
transition: border-color 0.2s ease-in-out;
}
.input-group .input-group-text,
.input-group-text:focus {
border-color: var(--color-border);
transition: border-color 0.2s ease-in-out;
}
.form-control {
color: var(--color-background-font);
background-color: var(--color-form-background);
border-color: var(--color-border);
}
.form-control::placeholder {
color: rgba(128, 128, 128, 0.8156862745);
}
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);
}
.is-primary {
color: var(--color-primary-font);
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.is-secondary {
color: var(--color-secondary-font);
background-color: var(--color-secondary);
border-color: var(--color-secondary);
}
.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-bg: var(--color-danger);
--bs-btn-border-color: var(--color-danger);
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: var(--color-danger-hover);
--bs-btn-hover-border-color: var(--color-danger-hover);
--bs-btn-focus-shadow-rgb: 225, 83, 97;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: var(--color-danger-active);
--bs-btn-active-border-color: var(--color-danger-active);
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #fff;
--bs-btn-disabled-bg: var(--color-danger-disabled);
--bs-btn-disabled-border-color: var(--color-danger-disabled);
}
.btn-submit {
--bs-btn-color: var(--color-primary-font);
--bs-btn-bg: var(--color-primary);
--bs-btn-border-color: var(--color-modal-background-inverted);
--bs-btn-hover-color: var(--color-primary-font);
--bs-btn-hover-bg: var(--color-primary-hover);
--bs-btn-hover-border-color: var(--color-modal-background-inverted);
--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-modal-background-inverted);
--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-modal-background-inverted);
}
.routerLink {
color: var(--color-primary);
}
.routerLink:hover {
color: var(--color-primary-hover);
}
.routerLink:active {
color: var(--color-primary-active);
}
.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(--color-text) 0, 0, 10px;
}
.offcanvas-body {
background-color: var(--color-background);
color: var(--color-background-font);
position: relative;
}
.ellipsis {
color: var(--color-background-font);
}
.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;
}
/*# sourceMappingURL=main.css.map */

View File

@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAQ;AACA;AAER;AAAA;AAAA;EAGE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EAYA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACI;EACA;;;AAGJ;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAIF;AAAA;AAAA;EAGE;EACA;EACA;EACA;EACA;EACA;;;AAGF;AAAA;EAEE;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;AAAA;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AAAA;AAAA;AAAA;EAIE;;;AAGF;AAAA;EAEE;;;AAGF;EACI;EACA;EACA;;;AAGJ;EACI;EACA;EACA;;;AAGJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"main.css"}

View File

@ -0,0 +1,322 @@
@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');
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
body {
min-height: 100vh;
width: 100vw;
color: var(--color-background-font);
background: var(--color-background);
transition: color 0.2s ease, background-color 0.2s ease;
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: 1rem;
font-weight: 500;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
.media-content {
overflow: hidden;
position: absolute;
left: 175px;
margin-top: 1rem;
margin-bottom: 1rem;
}
input, select, textarea {
color: var(--color-background-font);
background-color: var(--color-form-background);
border-color: var(--color-border);
}
.input-group {
display: flex;
align-items: center;
}
.input-group-text {
background-color: var(--color-form-background);
padding: 0.5rem;
display: flex;
align-items: center;
}
input:disabled {
background-color: var(--color-primary-disabled);
}
.container {
flex-direction: column;
align-items: center;
justify-content: center;
}
.form-check-label {
user-select: none;
}
.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;
}
.btn {
user-select: none;
}
.btn:hover {
cursor: pointer;
}
.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);
color: var(--color-form-background-font);
background-color: var(--color-form-background-focus);
transition: border-color 0.2s ease-in-out;
}
.input-group .input-group-text,
.input-group-text:focus {
border-color: var(--color-border);
transition: border-color 0.2s ease-in-out;
}
.form-control {
color: var(--color-background-font);
background-color: var(--color-form-background);
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);
}
.is-primary{
color: var(--color-primary-font);
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.is-secondary{
color: var(--color-secondary-font);
background-color: var(--color-secondary);
border-color: var(--color-secondary);
}
.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-bg: var(--color-danger);
--bs-btn-border-color: var(--color-danger);
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: var(--color-danger-hover);
--bs-btn-hover-border-color: var(--color-danger-hover);
--bs-btn-focus-shadow-rgb: 225, 83, 97;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: var(--color-danger-active);
--bs-btn-active-border-color: var(--color-danger-active);
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #fff;
--bs-btn-disabled-bg: var(--color-danger-disabled);
--bs-btn-disabled-border-color: var(--color-danger-disabled);
}
.btn-submit {
--bs-btn-color: var(--color-primary-font);
--bs-btn-bg: var(--color-primary);
--bs-btn-border-color: var(--color-modal-background-inverted);
--bs-btn-hover-color: var(--color-primary-font);
--bs-btn-hover-bg: var(--color-primary-hover);
--bs-btn-hover-border-color: var(--color-modal-background-inverted);
--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-modal-background-inverted);
--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-modal-background-inverted);
}
.routerLink {
color: var(--color-primary);
}
.routerLink:hover {
color: var(--color-primary-hover);
}
.routerLink:active {
color: var(--color-primary-active);
}
.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(--color-text) 0, 0, 10px;
}
.offcanvas-body {
background-color: var(--color-background);
color: var(--color-background-font);
position: relative;
}
.ellipsis {
color: var(--color-background-font);
}
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.fade-enter-to, .fade-leave-from {
opacity: 1;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.0671 2.27157C17.5 2.09228 17.9639 2 18.4324 2C18.9009 2 19.3648 2.09228 19.7977 2.27157C20.2305 2.45086 20.6238 2.71365 20.9551 3.04493C21.2864 3.37621 21.5492 3.7695 21.7285 4.20235C21.9077 4.63519 22 5.09911 22 5.56761C22 6.03611 21.9077 6.50003 21.7285 6.93288C21.5492 7.36572 21.2864 7.75901 20.9551 8.09029L20.4369 8.60845L15.3916 3.56308L15.9097 3.04493C16.241 2.71365 16.6343 2.45086 17.0671 2.27157Z"
fill="#000000"/>
<path d="M13.9774 4.9773L3.6546 15.3001C3.53154 15.4231 3.44273 15.5762 3.39694 15.7441L2.03526 20.7369C1.94084 21.0831 2.03917 21.4534 2.29292 21.7071C2.54667 21.9609 2.91693 22.0592 3.26314 21.9648L8.25597 20.6031C8.42387 20.5573 8.57691 20.4685 8.69996 20.3454L19.0227 10.0227L13.9774 4.9773Z"
fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 930 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" ?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd"
d="M22.6928 1.55018C22.3102 1.32626 21.8209 1.45915 21.6 1.84698L19.1533 6.14375C17.4864 5.36351 15.7609 4.96457 14.0142 4.96457C9.32104 4.96457 4.781 7.84644 1.11993 13.2641L1.10541 13.2854L1.09271 13.3038C0.970762 13.4784 0.967649 13.6837 1.0921 13.8563C3.79364 17.8691 6.97705 20.4972 10.3484 21.6018L8.39935 25.0222C8.1784 25.4101 8.30951 25.906 8.69214 26.1299L9.03857 26.3326C9.4212 26.5565 9.91046 26.4237 10.1314 26.0358L23.332 2.86058C23.553 2.47275 23.4219 1.97684 23.0392 1.75291L22.6928 1.55018ZM18.092 8.00705C16.7353 7.40974 15.3654 7.1186 14.0142 7.1186C10.6042 7.1186 7.07416 8.97311 3.93908 12.9239C3.63812 13.3032 3.63812 13.8561 3.93908 14.2354C6.28912 17.197 8.86102 18.9811 11.438 19.689L12.7855 17.3232C11.2462 16.8322 9.97333 15.4627 9.97333 13.5818C9.97333 11.2026 11.7969 9.27368 14.046 9.27368C15.0842 9.27368 16.0317 9.68468 16.7511 10.3612L18.092 8.00705ZM15.639 12.3137C15.2926 11.7767 14.7231 11.4277 14.046 11.4277C12.9205 11.4277 12 12.3906 12 13.5802C12 14.3664 12.8432 15.2851 13.9024 15.3624L15.639 12.3137Z"
fill="currentColor" fill-rule="evenodd"/>
<path d="M14.6873 22.1761C19.1311 21.9148 23.4056 19.0687 26.8864 13.931C26.9593 13.8234 27 13.7121 27 13.5797C27 13.4535 26.965 13.3481 26.8956 13.2455C25.5579 11.2677 24.1025 9.62885 22.5652 8.34557L21.506 10.2052C22.3887 10.9653 23.2531 11.87 24.0894 12.9239C24.3904 13.3032 24.3904 13.8561 24.0894 14.2354C21.5676 17.4135 18.7903 19.2357 16.0254 19.827L14.6873 22.1761Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" ?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd"
d="M17.7469 15.4149C17.9855 14.8742 18.1188 14.2724 18.1188 14.0016C18.1188 11.6544 16.2952 9.7513 14.046 9.7513C11.7969 9.7513 9.97332 11.6544 9.97332 14.0016C9.97332 16.3487 12.0097 17.8886 14.046 17.8886C15.3486 17.8886 16.508 17.2515 17.2517 16.2595C17.4466 16.0001 17.6137 15.7168 17.7469 15.4149ZM14.046 15.7635C14.5551 15.7635 15.0205 15.5684 15.3784 15.2457C15.81 14.8566 16 14.2807 16 14.0016C16 12.828 15.1716 11.8764 14.046 11.8764C12.9205 11.8764 12 12.8264 12 14C12 14.8104 12.9205 15.7635 14.046 15.7635Z"
fill="currentColor" fill-rule="evenodd"/>
<path clip-rule="evenodd"
d="M1.09212 14.2724C1.07621 14.2527 1.10803 14.2931 1.09212 14.2724C0.96764 14.1021 0.970773 13.8996 1.09268 13.7273C1.10161 13.7147 1.11071 13.7016 1.11993 13.6882C4.781 8.34319 9.32105 5.5 14.0142 5.5C18.7025 5.5 23.2385 8.33554 26.8956 13.6698C26.965 13.771 27 13.875 27 13.9995C27 14.1301 26.9593 14.2399 26.8863 14.3461C23.2302 19.6702 18.6982 22.5 14.0142 22.5C9.30912 22.5 4.75717 19.6433 1.09212 14.2724ZM3.93909 13.3525C3.6381 13.7267 3.6381 14.2722 3.93908 14.6465C7.07417 18.5443 10.6042 20.3749 14.0142 20.3749C17.4243 20.3749 20.9543 18.5443 24.0894 14.6465C24.3904 14.2722 24.3904 13.7267 24.0894 13.3525C20.9543 9.45475 17.4243 7.62513 14.0142 7.62513C10.6042 7.62513 7.07417 9.45475 3.93909 13.3525Z"
fill="currentColor" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-3 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>trash</title>
<desc>Created with Sketch Beta.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Icon-Set-Filled" transform="translate(-261.000000, -205.000000)" fill="#F0F0F0">
<path d="M268,220 C268,219.448 268.448,219 269,219 C269.552,219 270,219.448 270,220 L270,232 C270,232.553 269.552,233 269,233 C268.448,233 268,232.553 268,232 L268,220 L268,220 Z M273,220 C273,219.448 273.448,219 274,219 C274.552,219 275,219.448 275,220 L275,232 C275,232.553 274.552,233 274,233 C273.448,233 273,232.553 273,232 L273,220 L273,220 Z M278,220 C278,219.448 278.448,219 279,219 C279.552,219 280,219.448 280,220 L280,232 C280,232.553 279.552,233 279,233 C278.448,233 278,232.553 278,232 L278,220 L278,220 Z M263,233 C263,235.209 264.791,237 267,237 L281,237 C283.209,237 285,235.209 285,233 L285,217 L263,217 L263,233 L263,233 Z M277,209 L271,209 L271,208 C271,207.447 271.448,207 272,207 L276,207 C276.552,207 277,207.447 277,208 L277,209 L277,209 Z M285,209 L279,209 L279,207 C279,205.896 278.104,205 277,205 L271,205 C269.896,205 269,205.896 269,207 L269,209 L263,209 C261.896,209 261,209.896 261,211 L261,213 C261,214.104 261.895,214.999 262.999,215 L285.002,215 C286.105,214.999 287,214.104 287,213 L287,211 C287,209.896 286.104,209 285,209 L285,209 Z"
id="trash">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import BaseDiv from "@components/baseComponents/BaseDiv.vue";
const emit = defineEmits(["close"])
</script>
<template>
<div class="modal-fade" tabindex="-1" id="editModal" aria-labelledby="editModalLabel" @click="emit('close')">
<div class="modal" @click.stop>
<div class="modal-content">
<BaseDiv class="modal-header">
<slot name="modal-header">
Modal Header
</slot>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"/>
</BaseDiv>
<BaseDiv v-if="$slots.body" class="modal-body">
<slot name="body"></slot>
</BaseDiv>
<BaseDiv v-if="$slots.footer" class="modal-footer">
<slot name="footer">
<button type="button" class="btn btn-secondary" @click="emit('close')">Close</button>
</slot>
</BaseDiv>
</div>
</div>
</div>
</template>
<style scoped>
.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 {
color: var(--color-background-font);
background-color: var(--color-background) !important;
}
.modal-body {
background-color: var(--color-background) !important;
color: var(--color-background-font);
border: 2px solid var(--color-modal-background-inverted);
}
.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;
}
</style>

View File

@ -0,0 +1,182 @@
<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'
import {isMobile} from "@models/globals.ts";
const {logout} = useLogin()
const mobileMenuOpen = ref(false)
const navItems = [
{name: 'Main Page', path: '/'},
{name: 'About Me', path: '/about'},
{name: 'Contact Me', path: '/contact'}
]
</script>
<template>
<!-- Mobile Navbar -->
<div v-if="isMobile" 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>
<RouterLink v-if="session.user?.role === 'admin'" class="nav-link" to="/admin"
active-class="active-tab">
Admin
</RouterLink>
<RouterLink v-if="session.user" to="#" @click.prevent="logout" class="nav-link">
Logout
</RouterLink>
<RouterLink v-if="!session.user" to="/login" class="nav-link" active-class="active-tab">
Login
</RouterLink>
</div>
</nav>
</div>
<!-- Desktop Navbar -->
<div v-else 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/L10n.png" alt="Drawn Tal" 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>
</template>
<style scoped>
.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(--color-text);
}
.active-tab {
color: var(--color-nav-text-active) !important;
font-weight: bolder;
}
</style>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
.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;
}
.page-item.disabled {
user-select: none;
}
</style>

View File

@ -0,0 +1,276 @@
<script setup lang="ts">
import {nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
interface Column {
key: string
label: string
}
interface Props {
columns: Column[]
rows: Record<string, any>[]
hideAddNew?: boolean
addNewLabel?: string
}
const props = defineProps<Props>()
const emit = defineEmits(['addNew'])
const tableRef = ref<HTMLElement | null>(null)
const headerRef = ref<HTMLElement | null>(null)
const headerCells: HTMLElement[] = []
const bodyCells: HTMLElement[][] = []
const hideAddNewButton = ref(props.hideAddNew ?? false)
const addNewLabel = ref(props.addNewLabel ?? 'Add New')
let observer: ResizeObserver | null = null
// Called when any cell resizes
function recalculateWidths() {
if (!tableRef.value) return
const numCols = props.columns.length
const colWidths: number[] = new Array(numCols).fill(60)
if (observer) observer.disconnect()
tableRef.value.style.gridTemplateColumns = colWidths.map(w => `${w}px`).join(' ')
startObserving()
for (let col = 0; col < numCols; col++) {
let maxWidth = 0
// Header cell
const headerCell = headerCells[col]
if (headerCell) {
const style = window.getComputedStyle(headerCell)
if (style.display === 'none' || style.visibility === 'hidden') continue
maxWidth = headerCell.scrollWidth+1;
}
// Body cells
for (let row = 0; row < props.rows.length; row++) {
const cell = bodyCells[col]?.[row]
if (cell) {
const style = window.getComputedStyle(cell)
if (style.display === 'none' || style.visibility === 'hidden') continue
const cellWidth = cell.scrollWidth;
if (cellWidth > maxWidth) maxWidth = cellWidth+1
}
}
colWidths[col] = maxWidth
}
// Check if total width exceeds container width
if (tableRef.value && colWidths.reduce((a, b) => a + b, 0) > window.innerWidth) {
let start = 300;
while(tableRef.value && colWidths.reduce((a, b) => a + b, 0) > window.innerWidth) {
// Find the index of the 'link' column
const linkColIndex = props.columns.findIndex(col => col.key === 'link')
if (linkColIndex !== -1) {
colWidths[linkColIndex] = start
}
start -= 50;
}
tableRef.value.style.overflowX = 'auto'
} else if (tableRef.value) {
tableRef.value.style.overflowX = ''
}
tableRef.value.style.gridTemplateColumns = colWidths.map(w => `${w}px`).join(' ')
}
// Observe all cells for size changes
function startObserving() {
if (observer) observer.disconnect()
observer = new ResizeObserver(() => {
recalculateWidths()
})
headerCells.forEach(cell => cell && observer!.observe(cell))
bodyCells.forEach(col => col.forEach(cell => cell && observer!.observe(cell)))
}
onMounted(async () => {
await nextTick()
recalculateWidths()
window.addEventListener('resize', recalculateWidths)
})
onBeforeUnmount(() => {
if (observer) observer.disconnect()
window.removeEventListener('resize', recalculateWidths)
})
// Watch for changes to the data
watch(() => props.rows, async () => {
await nextTick()
recalculateWidths()
}, {deep: true})
</script>
<template>
<div class="custom-table" ref="tableRef">
<!-- Header -->
<div class="table-row table-header" ref="headerRef">
<div
v-for="(col, index) in columns"
:key="col.key"
class="table-cell"
:ref="el => {if (el) headerCells[index] = el as HTMLElement}"
>
{{ col.label }}
</div>
</div>
<!-- Body Rows -->
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="table-row"
:class="{ 'table-row-even': rowIndex % 2 === 0, 'table-row-odd': rowIndex % 2 !== 0 }"
ref="el => (bodyRows[rowIndex] = el)"
>
<div
v-for="(col, colIndex) in columns"
:key="col.key"
class="table-cell"
:ref="el => {
if (!bodyCells[colIndex]) bodyCells[colIndex] = []
if (el) bodyCells[colIndex][rowIndex] = el as HTMLElement;
}"
>
<template v-if="col.key === 'controls'">
<slot name="controls" :rowIndex="rowIndex"/>
</template>
<template v-else-if="col.key === 'link'">
<a :href="row[col.key]" target="_blank" rel="noopener noreferrer">
{{ row[col.key] }}
</a>
</template>
<template v-else>
{{ row[col.key] }}
</template>
</div>
</div>
<!-- Add new row button -->
<div v-if="!hideAddNewButton" style="grid-column: 1 / -1; width: 100%;">
<button type="button" class="btn btn-secondary" style="width: 100%;"
:class="{'table-row-even': rows.length % 2 === 0, 'table-row-odd': rows.length % 2 !== 0}"
@click="emit('addNew')">
{{ addNewLabel }}
</button>
</div>
</div>
</template>
<style scoped>
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;
border-right: none;
}
th {
background-color: var(--color-background-soft);
color: var(--color-background-font);
border-left: none;
border-right: none;
user-select: none;
}
td,
th {
border-top: 1px solid var(--color-border-invert);
border-bottom: 1px solid var(--color-border-invert);
border-left: none;
border-right: none;
text-align: center;
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);
}
.btn-secondary.table-row-even:hover {
background-color: var(--color-secondary-hover);
}
.btn-secondary.table-row-odd:hover {
background-color: rgba(0, 0, 0, 0.1);
}
.custom-table {
display: grid;
grid-auto-rows: auto;
align-items: start;
border: 2px solid #404040;
border-radius: 1rem;
overflow: hidden;
}
.table-row {
display: contents;
height: 45px;
overflow-y: hidden;
overflow-x: inherit;
}
.table-row-even {
background-color: var(--color-secondary);
}
.table-row-odd {
background-color: rgba(0, 0, 0, 0.05);
}
.table-cell {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid #444;
background-color: inherit;
vertical-align: center;
overflow-y: hidden;
overflow-x: inherit;
height: inherit;
white-space: nowrap;
}
.table-cell::-webkit-scrollbar {
height: 2px;
background: transparent;
}
.table-cell::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.table-cell::-webkit-scrollbar-thumb:hover {
background: #555;
}
.table-cell:not(:last-child) {
border-right: 1px solid #00000020;
}
.table-header .table-cell {
font-weight: bold;
background-color: var(--color-primary);
border-bottom: 2px solid #444;
}
.table-row:last-child .table-cell {
border-bottom: none;
}
</style>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import vRecolorText from '@/directives/vRecolorText.ts';
</script>
<template>
<button v-recolor-text>
<slot></slot>
</button>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import vRecolorText from '@/directives/vRecolorText.ts';
</script>
<template>
<div v-recolor-text>
<slot></slot>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts" xmlns="http://www.w3.org/1999/html">
import vRecolorText from '@/directives/vRecolorText.ts';
</script>
<template>
<input v-recolor-text>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref, watchEffect, computed} from 'vue';
import vRecolorSvg from '@/directives/vRecolorSvg.ts';
const props = defineProps<{
svgUrl: string;
width?: number;
height?: number;
viewBox?: string;
}>();
const paths = ref<Array<{ clip: string; d: string; fill: "nonzero" | "evenodd" | "inherit" | undefined}>>([]);
watchEffect(async () => {
if (!props.svgUrl) {
paths.value = [];
return;
}
try {
const response = await fetch(props.svgUrl);
const svgText = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, 'image/svg+xml');
paths.value = Array.from(doc.querySelectorAll('path')).map(path => ({
clip: path.getAttribute('clip-rule') || 'nonzero',
d: path.getAttribute('d') || '',
fill: (path.getAttribute('fill-rule') as "nonzero" | "evenodd" | "inherit" | undefined) || "inherit",
}));
} catch {
paths.value = [];
}
});
const svgStyle = computed(() => ({
width: props.width ? `${props.width}px` : '100%',
height: props.height ? `${props.height}px` : '100%',
viewBox: props.viewBox || '0 0 100 100',
}));
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" :viewBox="svgStyle.viewBox" :width="svgStyle.width"
:height="svgStyle.height">
<path v-for="(path, index) in paths" :key="index" :clip-rule="path.clip" :d="path.d" :fill-rule="path.fill" v-recolor-svg/>
</svg>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,56 @@
// client/src/directives/vUseReadableSvgColor.ts
import { useReadableTextColor } from './vRecolorText.ts'; // reuse your function
import { darkModeActive } from "@models/globals.ts";
import { watch } from 'vue';
function updateSvgColor(el: SVGElement) {
let bgColor: string | null = window.getComputedStyle(el).backgroundColor;
if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
let parent = el.parentElement;
bgColor = parent ? window.getComputedStyle(parent).backgroundColor : null;
while (parent && !(bgColor && bgColor !== 'transparent' && bgColor !== 'rgba(0, 0, 0, 0)')) {
parent = parent.parentElement;
if (parent) bgColor = window.getComputedStyle(parent).backgroundColor;
else bgColor = null;
}
}
if (bgColor) {
el.style.fill = useReadableTextColor(bgColor);
} else {
const stopWatch = watch(
() => el.parentElement,
(parent) => {
if (parent) {
el.style.fill = useReadableTextColor(window.getComputedStyle(parent).backgroundColor);
stopWatch();
}
}
);
}
}
export default {
mounted(el: SVGElement) {
const updateColor = () => updateSvgColor(el);
updateColor();
el.addEventListener('click', updateColor);
el.addEventListener('mouseenter', updateColor);
el.addEventListener('mouseleave', updateColor);
el.addEventListener('transitionend', updateColor);
el.addEventListener('change', updateColor);
watch(() => darkModeActive.value, () => updateSvgColor(el));
(el as any)._readableSvgListeners = [updateColor];
},
unmounted(el: SVGElement) {
const listeners = (el as any)._readableSvgListeners || [];
for (const listener of listeners) {
el.removeEventListener('click', listener);
el.removeEventListener('mouseenter', listener);
el.removeEventListener('mouseleave', listener);
el.removeEventListener('transitionend', listener);
el.removeEventListener('change', listener);
}
delete (el as any)._readableSvgListeners;
}
}

View File

@ -0,0 +1,136 @@
import {watch} from "vue";
import {darkModeActive} from "@models/globals.ts";
function getLuminance(color: string): number {
let r = 0, g = 0, b = 0;
//parse hex color to RGB
if(/^#.*$/.test(color)) {
if (/^#(|[0-9A-Fa-f]{6})$/.test(color)) {
// Parse hex color
r = parseInt(color.substring(1, 3), 16);
g = parseInt(color.substring(3, 5), 16);
b = parseInt(color.substring(5, 7), 16);
} else if (/^#([0-9A-Fa-f]{3})$/.test(color)) {
// Parse short hex color
r = parseInt(color[1] + color[1], 16);
g = parseInt(color[2] + color[2], 16);
b = parseInt(color[3] + color[3], 16);
} else if (/^#([0-9A-Fa-f]{8})$/.test(color)) {
// Parse hex with alpha
r = parseInt(color.substring(1, 3), 16);
g = parseInt(color.substring(3, 5), 16);
b = parseInt(color.substring(5, 7), 16);
} else {
throw new Error('Invalid color format: cause: ' + color);
}
} else if (/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+)\s*)?\)$/.test(color)) {
// Parse RGB or RGBA
const rgbaMatch = color.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+)\s*)?\)/);
if (rgbaMatch) {
r = parseInt(rgbaMatch[1], 10);
g = parseInt(rgbaMatch[2], 10);
b = parseInt(rgbaMatch[3], 10);
}
} else {
throw new Error('Invalid color format: cause: ' + color);
}
// RGB to sRGB conversion
r = r / 255;
g = g / 255;
b = b / 255;
// sRBG to linear RGB conversion
r = r <= 0.04045 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
g = g <= 0.04045 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
b = b <= 0.04045 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
// Calculate luminance
return (0.1726 * r + 0.7552 * g + 0.0722 * b);
}
const blackTextColor = '#181818';
const whiteTextColor = '#E7E7E7';
const blackLuminance = getLuminance(blackTextColor);
const whiteLuminance = getLuminance(whiteTextColor);
const blackTextColorDeep = '#0B0B0B';
const whiteTextColorDeep = '#F4F4F4';
const blackLuminanceDeep = getLuminance(blackTextColorDeep);
const whiteLuminanceDeep = getLuminance(whiteTextColorDeep);
const blackTextColorAbsolute = '#000000';
const whiteTextColorAbsolute = '#FFFFFF';
const blackLuminanceAbsolute = getLuminance(blackTextColorAbsolute);
const whiteLuminanceAbsolute = getLuminance(whiteTextColorAbsolute);
export function useReadableTextColor(bgColor: string): string {
const bgLuminance = getLuminance(bgColor);
let blackLighter = Math.max(bgLuminance, blackLuminance);
let blackDarker = Math.min(bgLuminance, blackLuminance);
let whiteLighter = Math.max(bgLuminance, whiteLuminance);
let whiteDarker = Math.min(bgLuminance, whiteLuminance);
// Calculate contrast ratio
let blackRatio = (blackLighter + 0.05) / (blackDarker + 0.05);
let whiteRatio = (whiteLighter + 0.05) / (whiteDarker + 0.05);
// Return black or white based on luminance
if (blackRatio >= 4.5 || whiteRatio >= 4.5) {
console.debug("bgL:", bgLuminance, "bL:", blackLuminance, "wL:", whiteLuminance, "bR:", blackRatio, "wR:", whiteRatio);
return blackRatio > whiteRatio ? '#181818' : '#E7E7E7';
}
// If contrast is not enough, use deep colors
blackLighter = Math.max(bgLuminance, blackLuminanceDeep);
blackDarker = Math.min(bgLuminance, blackLuminanceDeep);
whiteLighter = Math.max(bgLuminance, whiteLuminanceDeep);
whiteDarker = Math.min(bgLuminance, whiteLuminanceDeep);
blackRatio = (blackLighter + 0.05) / (blackDarker + 0.05);
whiteRatio = (whiteLighter + 0.05) / (whiteDarker + 0.05);
if (blackRatio >= 4.5 || whiteRatio >= 4.5) {
console.debug("bgL:", bgLuminance, "bL:", blackLuminanceDeep, "wL:", whiteLuminanceDeep, "bR:", blackRatio, "wR:", whiteRatio);
return blackRatio > whiteRatio ? '#0B0B0B' : '#F4F4F4';
}
// If still not enough, use absolute colors
blackLighter = Math.max(bgLuminance, blackLuminanceAbsolute);
blackDarker = Math.min(bgLuminance, blackLuminanceAbsolute);
whiteLighter = Math.max(bgLuminance, whiteLuminanceAbsolute);
whiteDarker = Math.min(bgLuminance, whiteLuminanceAbsolute);
blackRatio = (blackLighter + 0.05) / (blackDarker + 0.05);
whiteRatio = (whiteLighter + 0.05) / (whiteDarker + 0.05);
if (blackRatio >= 4.5 || whiteRatio >= 4.5) {
console.debug("bgL:", bgLuminance, "bL:", blackLuminanceAbsolute, "wL:", whiteLuminanceAbsolute, "bR:", blackRatio, "wR:", whiteRatio);
return blackRatio > whiteRatio ? '#000000' : '#FFFFFF';
}
console.warn(`Not enough contrast for background color: ${bgColor}. Using fallback colors.`);
return bgLuminance > 0.5 ? blackTextColor : whiteTextColor; // Fallback to default colors
}
function updateTextColor(el: HTMLElement) {
el.style.color = useReadableTextColor(window.getComputedStyle(el).backgroundColor);
}
export default {
mounted(el: HTMLElement) {
const updateColor = () => updateTextColor(el);
// Initial color update
updateColor();
// Listen for background color changes
el.addEventListener('click', updateColor);
el.addEventListener('mouseover', updateColor);
el.addEventListener('mouseout', updateColor);
el.addEventListener('transitionend', updateColor);
watch(() => darkModeActive.value, () => updateTextColor(el));
(el as any)._readableTextListeners = [updateColor];
},
unmounted(el: HTMLElement) {
// Remove event listeners
const listeners = (el as any)._readableTextListeners || [];
for (const listener of listeners) {
el.removeEventListener('click', listener);
el.removeEventListener('mouseover', listener);
el.removeEventListener('mouseout', listener);
el.removeEventListener('transitionend', listener);
}
delete (el as any)._readableTextListeners;
}
}

26
client/src/main.ts Normal file
View File

@ -0,0 +1,26 @@
import {createApp} from "vue";
import '@css/base.css'
import '@css/main.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/session.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,11 @@
import { ref } from "vue";
export const isMobile = ref(window.innerWidth <= 768);
export function updateIsMobile() {isMobile.value = window.innerWidth <= 768;}
export const routerTransitioning = ref(false);
export const subTabTransitioning = ref(false);
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)');
export const darkModeActive = ref(darkModePreference.matches);
darkModePreference.addEventListener('change', (e) => e.matches ? darkModeActive.value = true : darkModeActive.value = false);

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 ?? window.location.origin) + "/api";
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,58 @@
import {reactive} from "vue";
import {useRouter} from "vue-router";
import {toast} from "@models/toast.ts";
import { type DataEnvelope } from "./TransferTypes";
import {api} from "@models/rest.ts";
export interface SecureUser {
username: string;
role?: string;
id?: number;
token?: string;
}
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>("/users/login", {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);
}
}
}

View File

@ -0,0 +1,8 @@
import { useToast } from "vue-toast-notification";
export const toast = useToast({
position: 'top',
duration: 5000,
dismissible: true,
pauseOnHover: true,
type: 'default',
})

View File

@ -0,0 +1,78 @@
import {createRouter, createWebHashHistory} from 'vue-router'
import {session} from '@models/session.ts';
import { toast } from '@models/toast.ts';
import {jwtDecode} from 'jwt-decode';
const routes = [
{
path: '/',
name: 'Main Page',
component: () => import('@views/MainPage.vue')
},
{
path: '/about',
name: 'About',
component: () => import('@views/AboutPage.vue')
},
{
path: '/contact',
name: 'Contact',
component: () => import('@views/ContactPage.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('@views/LoginPage.vue')
}
]
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,117 @@
<script setup lang="ts">
import {isMobile} from "@models/globals.ts";
//TODO: Replace with actual data
const data = {
name: 'Full Name',
pronunciation: 'FUL NAYME',
pronouns: 'it/its',
bio: [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse vitae laoreet felis, ac fermentum " +
"lectus. Vivamus pulvinar velit id arcu facilisis lacinia. In hac habitasse platea dictumst. In at nisl " +
"quis orci pretium ultrices posuere tempor enim. Praesent blandit luctus porta. Proin semper ipsum non " +
"mollis feugiat. Sed porttitor leo quis ante pretium vestibulum. Nunc mattis diam a libero blandit accumsan. " +
"Cras nec cursus massa, vel aliquet lorem. Praesent porttitor vitae purus vitae blandit. Aliquam vestibulum " +
"fringilla vehicula. Donec maximus eros at augue venenatis maximus."]
}
//TODO: Replace with actual links
const iconToLinkMap = {
Instagram: {
link: 'https://www.instagram.com/?hl=en',
icon: new URL('@img/Instagram_icon.png', import.meta.url).href
},
LinkedIn: {
link: 'https://www.linkedin.com/',
icon: new URL('@img/Linkedin_icon.png', import.meta.url).href
}
}
</script>
<template>
<div>
<div class="container is-fluid text-center">
<div v-if="isMobile" style="background-color: transparent">
<div class="card-content">
<div class="about-center">
<div>
<figure class="image is-128x128" style="margin-top: 1rem; margin-bottom: .25rem;">
<img src="https://placehold.co/128x128" alt="headshot" style=border-radius:48px;
width=128
height=128>
</figure>
</div>
<div style="padding: 1rem;">
<h1 class="display-1 bold">{{ data.name }}</h1>
<p><small>Pronounced: {{ data.pronunciation }}</small></p>
<p><small>({{ data.pronouns }})</small></p>
</div>
</div>
<h2 class="display-5 about-center bold">About Me</h2>
<div class="content" style="text-align: left">
<div v-for="(paragraph, index) in data.bio" :key="index">
{{ paragraph }}
<br><br>
</div>
</div>
</div>
</div>
<!-- left side is the image, right side is the text -->
<div v-else class="row">
<div class="col">
<figure class="image" style="margin-top: 1rem; margin-bottom: .25rem;">
<img src="https://placehold.co/1848x2396" alt="headshot" style="border-radius:48px"
:style="{width: 1848 / 4 + 'px', height: 2396 / 4 + 'px'}">
</figure>
</div>
<div class="col">
<div style="padding: 1rem;">
<h1 class="display-1 bold">{{ data.name }}</h1>
<p><small>Pronounced: {{ data.pronunciation }}</small></p>
<p><small>({{ data.pronouns }})</small></p>
</div>
<h1 class="display-5 about-center bold">About Me</h1>
<div class="content" style="text-align: justify;">
<div v-for="(paragraph, index) in data.bio" :key="index">
{{ paragraph }}
<br><br>
</div>
<div class="row">
<div class="col" style="text-align: left">
<a v-for="(data, name) in iconToLinkMap" :key="name" :href="data.link"
target="_blank" style="margin-right: 0.75rem;">
<img :src="data.icon" :alt="name" width="20" height="20"/>
</a>
</div>
<div class="col" style="text-align: right">
<!-- TODO: Email link -->
<a href="mailto:example@gmail.com" style="color: inherit; text-decoration: none;">example@gmail.com</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.about-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
h1, h2, p {
margin-bottom: 0;
}
.content {
margin: 1rem 1rem 1rem 1rem;
font-size: 1.2rem;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,102 @@
<script lang="ts" setup>
import {ref} from 'vue'
import {toast} from "@models/toast.ts"
import {isMobile} from "@models/globals.ts";
interface Contact {
name: string;
email: string;
subject: string;
message: string;
}
import {api} from '@models/rest';
import BaseButton from "@components/baseComponents/BaseButton.vue";
async function emailAPI(action: string, body?: unknown, method?: string, headers?: HeadersInit) {
return await api(`/email${action}`, body, method, headers);
}
async function sendEmail(contact: Contact): Promise<boolean> {
return await emailAPI('/', contact, 'POST', {'Content-Type': 'application/json'})
.then((res) => res.message?.includes('success') || false);
}
const form = ref<Contact>({
name: '',
email: '',
subject: '',
message: '',
})
const sending = ref(false)
async function submitForm() {
sending.value = true
await sendEmail(form.value).then(response => {
if (response) {
toast.success('Message sent successfully!')
form.value = {name: '', email: '', subject: '', message: ''} // Reset form
}
}).catch(e => {
console.error('Error sending email:', e)
toast.error('An error occurred while sending your message. Please try again later.')
}).finally(() => sending.value = false)
}
</script>
<template>
<div class="container mb-0">
<div class="row text-center">
<div class="col">
<h1 class="display-1 bold">Contact Me</h1>
</div>
</div>
<div class="row text-center">
<div class="col">
<p>I look forward to hearing from you!</p>
</div>
</div>
<div class="row justify-content-center">
<div class="col" :style="isMobile ? 'max-width: 100%' : 'max-width: 450px'">
<form @submit.prevent="submitForm">
<div class="mb-2">
<label class="form-label">Name:</label>
<input v-model="form.name" class="form-control" type="text" placeholder="e.g., John Doe"
required/>
</div>
<div class="mb-2">
<label class="form-label">Email:</label>
<input v-model="form.email" class="form-control" type="text"
placeholder="e.g., email@example.com"
required/>
</div>
<div class="mb-2">
<label class="form-label">Subject:</label>
<input v-model="form.subject" class="form-control" type="text" placeholder="e.g., Support"
required/>
</div>
<div class="mb-4">
<label class="form-label">Message:</label>
<textarea v-model="form.message" class="form-control" placeholder="Enter text here" required/>
</div>
<div class="d-grid">
<button v-if="sending" type="button" class="btn btn-submit">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"/>
<span class="sr-only">Sending...</span>
</button>
<BaseButton v-else type="submit" class="btn btn-submit">Send</BaseButton>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
textarea {
color: var(--color-primary-font);
}
</style>

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import {ref} from 'vue';
import {useLogin} from "@models/session.ts";
import {isMobile} from "@models/globals.ts";
import vRecolorSvg from '@/directives/vRecolorSvg.ts';
import BaseButton from "@components/baseComponents/BaseButton.vue";
import BaseSVG from "@components/baseComponents/BaseSVG.vue";
import BaseInput from "@components/baseComponents/BaseInput.vue";
const username = ref('');
const password = ref('');
const {login} = useLogin();
const showPassword = ref(false);
</script>
<template>
<div class="container">
<div class="row justify-content-center">
<div class="col" :style="isMobile ? '' : 'max-width: 400px'">
<h1 class="text-center mt-3">Login</h1>
<form>
<div class="mb-2">
<label class="form-label" for="username">Username</label>
<input class="form-control" id="username" v-model="username" type="text" autocomplete="username"
required/>
</div>
<div class="mb-2">
<label class="form-label" for="password">Password</label>
<div class="input-group">
<BaseInput class="form-control" id="password" v-model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password" required style="border-right:0;"/>
<button type="button" class="input-group-text border-start-0"
style="cursor:pointer;"
@click="showPassword = !showPassword">
<BaseSVG svg-url="/src/assets/svg/eye.svg" :width="20" :height="20"
view-box="0 0 28 28"
v-recolor-svg v-if="!showPassword"/>
<BaseSVG svg-url="/src/assets/svg/eye-slash.svg" :width="20" :height="20"
view-box="0 0 28 28"
v-recolor-svg v-else/>
</button>
</div>
</div>
<div class="d-grid">
<BaseButton class="btn btn-submit mt-4" type="submit"
@click.prevent="login(username, password)">
Login
</BaseButton>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import BaseDiv from "@components/baseComponents/BaseDiv.vue";
import BaseButton from "@components/baseComponents/BaseButton.vue";
</script>
<template>
<div class="container text-center">
<h1 class="display-1 fw-normal">Main Page</h1>
<div class="row justify-content-center">
<div class="col">These are the palettes for this site:</div>
</div>
<div class="row justify-content-center align-items-center">
<div class="col">Primary:</div>
</div>
<div class="row justify-content-center">
<BaseDiv class="col-md-1 primary">Main</BaseDiv>
<BaseDiv class="col-md-1 primary hover">Hover</BaseDiv>
<BaseDiv class="col-md-1 primary active">Active</BaseDiv>
<BaseDiv class="col-md-1 primary disabled">Disabled</BaseDiv>
</div>
<div class="row justify-content-center">
<BaseDiv class="col">Secondary:</BaseDiv>
</div>
<div class="row justify-content-center">
<BaseDiv class="col-md-1 secondary">Main</BaseDiv>
<BaseDiv class="col-md-1 secondary hover">Hover</BaseDiv>
<BaseDiv class="col-md-1 secondary active">Active</BaseDiv>
<BaseDiv class="col-md-1 secondary disabled">Disabled</BaseDiv>
</div>
<div class="row justify-content-center">
<BaseDiv class="col">Buttons:</BaseDiv>
</div>
<div class="row justify-content-center">
<div class="col">
<div class="btn-group">
<BaseButton class="btn btn-primary">Primary Button</BaseButton>
<BaseButton class="btn btn-secondary">Secondary Button</BaseButton>
<BaseButton class="btn btn-submit">Submit Button</BaseButton>
<BaseButton class="btn btn-danger">Danger Button</BaseButton>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.row {
padding-top: 0.5rem;
}
.col-md-1 {
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
align-content: center;
padding: 0.5rem;
margin-right: 0.5rem;
height: 100px;
}
.col-md-1.primary {
background-color: var(--color-primary);
}
.col-md-1.primary.hover {
background-color: var(--color-primary-hover);
}
.col-md-1.primary.active {
background-color: var(--color-primary-active);
}
.col-md-1.primary.disabled {
background-color: var(--color-primary-disabled);
}
.col-md-1.secondary {
background-color: var(--color-secondary);
}
.col-md-1.secondary.hover {
background-color: var(--color-secondary-hover);
}
.col-md-1.secondary.active {
background-color: var(--color-secondary-active);
}
.col-md-1.secondary.disabled {
background-color: var(--color-secondary-disabled);
}
</style>

1
client/src/vite-env.d.ts vendored Normal file
View File

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

27
client/tsconfig.app.json Normal file
View File

@ -0,0 +1,27 @@
{
"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,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@views/*": ["src/views/*"],
"@assets/*": ["src/assets/*"],
"@router/*": ["src/router/*"],
"@css/*": ["src/assets/css/*"],
"@img/*": ["src/assets/img/*"],
"@svg/*": ["src/assets/svg/*"],
"@models/*": ["src/models/*"],
}
},
"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" }
]
}

25
client/tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"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"]
}

21
client/vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import * as path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
"@components": path.resolve(__dirname, 'src/components'),
"@views": path.resolve(__dirname, 'src/views'),
"@assets": path.resolve(__dirname, 'src/assets'),
"@router": path.resolve(__dirname, 'src/router'),
"@css": path.resolve(__dirname, 'src/assets/css'),
"@img": path.resolve(__dirname, 'src/assets/img'),
"@svg": path.resolve(__dirname, 'src/assets/svg'),
"@models": path.resolve(__dirname, 'src/models'),
}
}
})

75
server/app.js Normal file
View File

@ -0,0 +1,75 @@
// server/app.js
import express from 'express';
import cors from 'cors';
// import multer from 'multer';
// import path from 'path';
import db from './models/db.js';
import dotenv from 'dotenv';
import emailController from "./controllers/emailController.js";
dotenv.config();
/**@typedef {import("../client/src/DBRecord.ts").DBRecord} DBRecord */
console.log("Starting Server...");
// test db connection
try {
await db.query("SELECT 1")
console.log("DB connection successful")
await db.get('users', {id: 1});
console.log("Users table exists");
await emailController.withTimeout(emailController.transporter.verify(), 15000)
.then(() => {
console.log("Transporter is ready to send emails");
})
.catch((error) => {
console.error("Error verifying transporter:", error);
process.exit(1);
});
} catch (err) {
console.error(err);
process.exit(1);
}
const app = express();
app.use(cors());
app.use(express.json());
// Serve static files from the client build directory
if (process.env.NODE_ENV !== 'development') app.use("/", express.static('../client/dist'));
/*const UPLOAD_FOLDER = path.join(process.cwd(), 'Scans');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_FOLDER);
},
filename: (req, file, cb) => {
cb(null, file.originalname);
}
});
const upload = multer({storage});*/
// user routes
import userRouter from './controllers/userController.js';
app.use('/api/users', userRouter);
// email routes
app.use('/api/email', emailController.router);
// other routes
// Error handling middleware
import errorHandler from './middleware/ErrorHandler.js';
app.use(errorHandler);
// 404 handler
app.use((req, res) => {
res.status(404).json({message: 'Route not found'});
});
const PORT = process.env.PORT || 8000;
app.listen(PORT, () => {
console.log(`Express server running on port ${PORT}`);
});

View File

@ -0,0 +1,152 @@
import nodemailer from 'nodemailer';
import dotenv from 'dotenv';
import {Router} from 'express';
/** @typedef Email
* @property {string} name - name of sender.
* @property {string} email - email addr of sender.
* @property {string} subject - subject of email.
* @property {string} message - message body of email.
*/
/** @typedef {import("@types/express").Request} Request */
/** @typedef {import("@types/express").Response} Response */
/** @typedef {import("@types/express").NextFunction} NextFunction */
/** @typedef {import("@types/nodemailer").TransportOptions} TransportOptions */
dotenv.config();
const transporter = nodemailer.createTransport(
/** @type {TransportOptions}*/
{
service: 'gmail', // Use Gmail as the email service
target: process.env.EMAIL_TARGET, // The email address to send the email to
auth: {
user: process.env.MAILER_EMAIL_ADDR, // Your Gmail address
pass: process.env.MAILER_EMAIL_PASS, // Your Gmail password or App Password
},
});
await withTimeout(transporter.verify(), 15000)
.then(() => {
console.log("Transporter is ready to send emails");
})
.catch((error) => console.error("Error verifying transporter:", error));
const router = Router()
router.post('/',
/**
* Sends an email using the configured transporter.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<e.Response<any, Record<string, any>> | void>}
*/
async (req, res, next) => {
try {
console.debug("made it to sendEmail function");
let exitonError = false;
await withTimeout(transporter.verify(), 15000)
.then(() => {
console.debug("Transporter is ready to send emails");
})
.catch((error) => {
console.error("Error verifying transporter:", error);
res.status(500).json({message: 'Failed to verify email transporter'});
exitonError = true;
});
if (exitonError) return;
const emailWithZeroWidthSpace = req.body.email.replace(/@/g, '@\u200B').replace(/\./g, ".\u200B"); // Add zero-width space to prevent email scraping
/** @type {Email} */
const contact = {
name: req.body.name,
email: emailWithZeroWidthSpace, // Add zero-width space to prevent email scraping
subject: req.body.subject,
message: req.body.message,
};
if (!contact.name || !contact.email || !contact.subject || !contact.message) {
return res.status(400).json({message: 'To, subject, and text are required'});
}
const emailText = `
This email was sent from Li0nhunter's Bot.
Name: ${contact.name}
Email: ${contact.email}
Subject: ${contact.subject}
Message: ${contact.message}
Respond now: mailto:${contact.email}
`
const emailHtml = `
<div style="max-width:80%;margin:40px auto;background:var(--color-background);border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.14);padding:32px 24px;font-family:Arial,sans-serif;">
<h1 style="margin-bottom:24px;text-align:center;color:var(--color-text);">You got a message from your website's contact page!</h1>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Name:</b></label>
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);"><b>${contact.name}</b></div>
</div>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Email:</b></label>
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);"><b>${contact.email}</b></div>
</div>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Subject:</b></label>
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);"><b>${contact.subject}</b></div>
</div>
<div style="margin-bottom:24px;">
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Message:</b></label>
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);white-space:pre-line;"><b>${contact.message}</b></div>
</div>
<div style="text-align:center;">
<a href="mailto:${contact.email}" style="display:inline-block;font-weight:500;color:var(--color-text);background-color:var(--color-form-background);border:1px solid #333333;padding:10px 32px;text-align:center;border-radius:4px;text-decoration:none;font-size:1.1rem;transition:background 0.2s;"><b>Respond now</b></a>
</div>
<p style="text-align:center;color:var(--color-text);">This email was sent from Li0nhunter's Bot.</p>
</div>
`;
const mailOptions = {
from: `"Li0nhunter's Web Bot" <${process.env.EMAIL_ADDR}>`, // sender address
to: process.env.EMAIL_TARGET,
replyTo: contact.email,
subject: contact.subject, // Subject line
text: emailText, // plain text body
html: emailHtml, // html body
};
// Set a timeout of 15 seconds (15,000 ms) for sending the email
await withTimeout(transporter.sendMail(mailOptions), 15000)
.then((result) => res.status(200).json({data: result, message: 'Email sent successfully'}))
.catch((error) => {
console.error(error);
res.status(500).json({message: error.message || 'Failed to send email'});
});
} catch (error) {
next(error);
}
});
/**
* Executes a promise with a timeout.
* @param {Promise<any>} promise The promise to execute.
* @param {number} ms The timeout in milliseconds.
* @throws {Error} If the promise does not resolve within the specified time.
* @return {Promise<any>}
*/
async function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Operation timed out')), ms)
);
return Promise.race([promise, timeout])
}
export default {
withTimeout,
transporter,
router
};

View File

@ -0,0 +1,163 @@
import authHandler from "../middleware/AuthHandler.js";
import users from "../models/users.js";
import express from "express";
const router = express.Router();
/** @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.
*/
/** @typedef {import("@types/express").Request} Request */
/** @typedef {import("@types/express").Response} Response */
/** @typedef {import("@types/express").NextFunction} NextFunction */
router.get("/",
/** * Fetches all users from the database.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<void>}
*/
async (req, res, next) => {
await users.getAllUsers()
.then((users) => {
res.status(200).json({data: users});
})
.catch((err) => next(err));
}
)
router.get("/",
/**
* adds new user to db as non-admin. only direct db manipulation can make users admins
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<e.Response<any, Record<string, any>> | void>}
*/
async (req, res, next) => {
if (!req.body || !req.body.username || !req.body.password) {
return res.status(400).json({data: null, error: 'Username and password are required'});
}
await users.addNewUser(req.body.username, req.body.password)
.then((user) => {
if (!user) {
return res.status(500).json({data: null, error: 'User not found after insertion'});
}
res.status(200).json({data: user, message: 'User added successfully'});
})
.catch((err) => next(err));
});
router.get("/:identifier",
/**
* Fetches a user by ID or username from the database.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<e.Response<any, Record<string, any>> | void>}
*/
async (req, res, next) => {
const userIdentifier = req.params.identifier;
if (!userIdentifier) return res.status(400).json({data: null, error: 'User identifier is required'});
await users.getUser(userIdentifier)
.then(user => {
if (!user) return res.status(404).json({data: null, error: 'User not found'});
res.status(200).json({data: user});
})
.catch((err) => {
if (err instanceof Error) {
if (err.message === 'User not found') {
return res.status(404).json({data: null, error: 'User not found'});
}
if (err.message === 'Multiple users found with the same identifier, something has gone wrong') {
res.status(500).json({
data: null,
error: 'Multiple users found with the same identifier, something has gone wrong'
});
} else next(err);
} else {
next(new Error('An unhandled error occurred while fetching the user'));
}
})
}
)
router.post("/login",
/**
* Fetches a user by ID or username from the database.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<e.Response<any, Record<string, any>> | void>}
*/
async (req, res, next) => {
const {username, password} = req.body;
if (!username || !password) return res.status(400).json({
data: null,
error: 'Username and password are required'
});
await users.login(username, password).then(data => {
if (!data) return res.status(401).json({data: null, error: 'Invalid username or password'});
res.status(200).json({data: data, message: 'Login successful'});
})
.catch((err) => {
if (err instanceof Error) {
if (err.message === 'Invalid username or password') {
res.status(401).json({data: null, error: 'Invalid username or password'});
} else next(err);
} else {
next(new Error('An unhandled error occurred while login'));
}
})
})
router.patch("/:identifier", authHandler.authenticateUser,
/**
* Updates a user by ID or username in the database.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<e.Response<any, Record<string, any>> | void>}
*/
async (req, res, next) => {
const userIdentifier = req.params.id;
if (!userIdentifier) {
return res.status(400).json({data: null, error: 'User Identifier is required'});
}
await users.updateUser(req.body)
.then(user => {
if (!user) res.status(404).json({data: null, error: 'User not found'});
else res.status(200).json({data: user, message: 'User updated successfully'});
})
.catch((err) => next(err))
}
)
router.delete("/:identifier", authHandler.authenticateUser,
/**
* deletes a user by ID or username from the database.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @return {Promise<e.Response<any, Record<string, any>> | void>}
*/
async (req, res, next) => {
const userIdentifier = req.params.identifier;
if (!userIdentifier) return res.status(400).json({data: null, error: 'User identifier is required'});
await users.deleteUser(userIdentifier)
.then(user => {
if (!user) return res.status(404).json({data: null, error: 'User not found'});
res.status(200).json({data: user, message: 'User deleted successfully'});
})
.catch((err) => next(err));
}
)
export default router

View File

@ -0,0 +1,65 @@
import jwt from "jsonwebtoken";
/** @typedef {import("@types/express").Request} Request */
/** @typedef {import("@types/express").Response} Response */
/** @typedef {import("@types/express").NextFunction} NextFunction */
export default {
/**
* Express middleware to verify JWT and check for the admin role.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<e.Response<any, Record<string, any>> | void>}
*/
authenticateAdmin: async (req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).send({data: null, error: "Token missing"});
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) return res.status(401).json({data: null, error: "Invalid token"});
if (payload.role !== "admin") return res.status(403).json({data: null, error: "admins only"});
next();
} catch (err) {
if (err instanceof Error && err.message === 'jwt expired') return res.status(401).json({data: null, message: "Token expired", error: err});
res.status(401).json({data: null, message: "Invalid token", error: err});
}
},
/**
* Express middleware to verify JWT and check for the user role.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<e.Response<any, Record<string, any>> | void>}
*/
authenticateUser: async (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).send({data: null, error: "Token missing"});
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET || "super-secret-key");
// Check if payload has id and role
if (!payload || !payload.id || !payload.role) {
return res.status(401).json({data: null, error: "Invalid token"});
}
// check if the user id matches the id in the token
else if (payload.role !== "admin" && req.params.id && req.params.id !== payload.id.toString()) {
return res.status(403).json({data: null, error: "Forbidden"});
}
next();
} catch (err) {
if (err instanceof Error && err.message === 'jwt expired') {
return res.status(401).json({data: null, message: "Token expired", error: err});
}
res.status(401).json({data: null, message: "Invalid token", error: err});
}
}
}

View File

@ -0,0 +1,18 @@
/** @typedef {import("@types/express").Request} Request */
/** @typedef {import("@types/express").Response} Response */
/** @typedef {import("@types/express").NextFunction} NextFunction */
/**
* Express error handler middleware.
* @param {Error & {status?: number}} err
* @param {Request} req
* @param {Response} res
* @param {NextFunction} _
* @returns {void}
*/
export default (err, req, res, _) => {
console.error(err);
if (!res.statusCode) {
res.status(err.status || 500).json({message: err.message || 'Internal Server Error'});
}
};

141
server/models/db.js Normal file
View File

@ -0,0 +1,141 @@
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.
* @template T
* @param {string} text
* @param {any[]} [params]
* @return {Promise<T[]>}
*/
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});
/**@type T[]*/
return res.rows;
}
/**
* function to insert data into a table.
* @template T
* @param {string} table The name of the table to insert data into.
* @param {Object.<string, any>} map The {keys: values} to insert data into.
* @returns {Promise<T[] | Error>} Returns inserted value if the insertion was successful, error otherwise.
* */
async function insert(table, map) {
const valuePlaceholders = Object.keys(map).map((_, index) => `$${index + 1}`).join(", ");
const queryText = `INSERT INTO ${table} (${Object.keys(map).join(", ")}) VALUES (${valuePlaceholders}) RETURNING *`;
return await query(queryText, Object.values(map))
.then(resRows => {
if (resRows.length === 0) {
console.error('Insert failed: No rows affected');
return new Error('Insert failed: No rows affected');
}
console.log('Insert successful:', resRows);
return resRows;
})
.catch(err => {
console.error('Insert error:', err);
return err;
});
}
/**
* Function to update data in a table.
* @template T
* @param {string} table
* @param {Object.<string, any>} updateMap The {keys: values} to update.
* @param {Object.<string, any>} conditions The conditions to match to update.
* @returns {Promise<T[] | Error>} Returns updated rows if the insertion was successful, error otherwise.
*/
async function update(table, updateMap, conditions) {
if (Object.keys(updateMap).length === 0) {
console.error('Update failed: No fields to update');
return new Error('Update failed: No fields to update');
}
if (Object.keys(conditions).length === 0) {
console.error('Update failed: No conditions specified');
return new Error('Update failed: No conditions specified');
}
const setClause = Object.keys(updateMap).map((key, index) => `${key} = ${index + 1}`).join(', ');
const whereClause = Object.entries(conditions).map(([key, value]) => `${key} = ${value}`).join(' AND ');
return await query(`UPDATE ${table} SET ${setClause} WHERE ${whereClause} RETURNING *`, Object.values(updateMap))
.then(resRows => {
if (resRows.length === 0) {
console.error('Update failed: No rows affected');
return new Error('Update failed: No rows affected');
}
console.log('Update successful:', resRows);
return resRows;
})
.catch(err => {
console.error('Update error:', err);
return err;
});
}
/**
* Function to delete a row from a table by ID.
* @template T
* @param {string} table The name of the table to delete from.
* @param {Object.<string, any>} conditions The conditions to match to delete row/rows.
* @return {Promise<T[] | Error>} Returns true if the deletion was successful, false otherwise.
*/
async function remove(table, conditions = {}) {
if (!conditions || Object.keys(conditions).length === 0) {
console.error('Delete failed: No identifier specified');
return new Error('Delete failed: No identifier specified');
}
const whereClause = Object.entries(conditions).map(([key, _], index) => `${key} = ${index + 1}`).join(' AND ');
const queryText = `DELETE FROM ${table} WHERE ${whereClause} RETURNING *`;
return await query(queryText, Object.values(conditions))
.then(resRows => {
if (resRows.length === 0) {
console.error('Delete failed: No rows affected');
return new Error('Delete failed: No rows affected');
}
console.log('Delete successful:', resRows);
return resRows;
})
.catch(err => {
console.error('Delete error:', err);
return err;
});
}
/**
* Function to get rows from a table based on conditions.
* @template T
* @param {string} table The name of the table to query.
* @param {{}} conditions The conditions to match rows.
* @return {Promise<T[]>} Returns an array of rows that match the conditions.
*/
async function get(table, conditions = {}) {
if (Object.keys(conditions).length === 0) {
return await query(`SELECT * FROM "${table}"`);
}
const whereClause = Object.entries(conditions).map(([key, _], index) => `${key} = $${index + 1}`).join(' AND ');
return await query(`SELECT * FROM ${table} WHERE ${whereClause}`, Object.values(conditions));
}
export default {
pool,
query,
insert,
update,
remove,
get,
};

146
server/models/users.js Normal file
View File

@ -0,0 +1,146 @@
import jwt from "jsonwebtoken";
import db from "./db.js";
import bcrypt from "bcryptjs";
/** @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.
* @property {string} role - The role of the user, e.g., 'user' or 'admin'.
*/
/**
* Fetches all users from the database.
* @return {Promise<User[]>}
*/
async function getAllUsers() {
try {
return await db.get('users').then((users) => users);
} catch (error) {
console.error('Error fetching users:', error);
throw error;
}
}
/**
* gets user by id or username
* @param {string | number} identifier can be either user id or username
* @return {Promise<{id: number, username: string, password: string, role: string}>}
*/
async function getUser(identifier) {
/** @type {User[]} users */
let users;
if (/^\d+$/.test(identifier)) users = await db.get('users',{"id": identifier});
else users = await db.get('users', {'username': identifier});
if (users.length === 0) throw new Error("User not found");
else if (users.length > 1) throw new Error("Multiple users found with the same identifier, something has gone wrong");
return users[0];
}
/**
* adds new user to db as non-admin. only direct db manipulation can make users admins
* @param {string} username
* @param {string} password
* @return {Promise<User|void>}
*/
async function addNewUser(username, password) {
if (!username || !password) throw new Error('Username and password are required');
// Check if the user already exists
if ((await db.get('users', {'username': username})).length > 0) throw new Error('User already exists with this username');
// Hash the password before storing it
const hashedPassword = await bcrypt.hash(password, 10);
/** @type {Promise<User|Error>} */
const result = await db.insert('users', {username, password: hashedPassword, role: 'user'});
if (!result) throw new Error('User could not be created');
if (result instanceof Error) throw result;
return result;
}
/**
* Logs in a user by validating their credentials and generating a JWT token.
* @param {string} username
* @param {string} password
* @return {Promise<{id: number, username: string, role: string, token: (*)}>}
*/
async function login (username, password) {
/** @type {User} user*/
const user = await db.get('users', {'username': username}).then(response => {
if (response instanceof Error) throw response;
if (response.length === 0) throw new Error('User not found');
if (response.length > 1) throw new Error('Multiple users found with the same username, something has gone wrong');
return response[0];
});
if (!user || !bcrypt.compareSync(password, user.password)) {
throw new Error('Invalid username or password');
}
console.log(user);
const token = jwt.sign({id: user.id, role: user.role}, process.env.JWT_SECRET, {expiresIn: "1d"});
return {
id: user.id,
username: user.username,
role: user.role,
token: token
}
}
/**
* Updates a user's information.
* @param {{id?: number, username?: string, password?: string, newPassword?: string}} mapping
* @return {Promise<User>}
*/
async function updateUser(mapping) {
if (!mapping) throw new Error('User mapping is required');
if (!mapping.id && !mapping.username) throw new Error('User id or username is required for update');
let user;
if (mapping.id) user = await getUser(mapping.id);
else user = await getUser(mapping.username);
if (!user) throw new Error('User not found');
if (mapping.newPassword) {
// validate the old password to make sure the user is authenticated
if (!mapping.password) throw new Error('Old password is required to update password');
if (!bcrypt.compareSync(mapping.password, user.password)) throw new Error('Old password is incorrect');
// Hash the new password before updating
mapping.password = await bcrypt.hash(mapping.newPassword, 10);
delete mapping.newPassword; // Remove newPassword from the mapping
}
/** @type {User[] | Error} */
let result;
if (mapping.id) {
// remove id from mapping to avoid updating it
const id = mapping.id;
delete mapping.id;
result = await db.update('users', mapping, {'id': id});
} else {
// remove username from mapping to avoid updating it
const username = mapping.username;
delete mapping.username;
result = await db.update('users', mapping, {'username': username});
}
if (result instanceof Error) throw result;
if (result.length > 1) throw new Error('Multiple users updated, something has gone wrong');
return result[0];
}
/**
* Deletes a user by their ID.
* @param {string | number} identifier can be either user id or username
* @return {Promise<User>}
*/
async function deleteUser(identifier) {
if (!identifier) throw new Error('User identifier is required');
/** @type {User[] | Error} */
let result;
if (/^\d+$/.test(identifier)) result = await db.remove('users', {"id": identifier});
else result = await db.remove('users', {'username': identifier});
if (result instanceof Error) throw result;
return result[0];
}
export default {
getAllUsers,
getUser,
addNewUser,
login,
updateUser,
deleteUser
}

2093
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
server/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "server",
"version": "0.1.0",
"private": false,
"type": "module",
"scripts": {
"dev": "nodemon app.js",
"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",
"multer": "^2.0.1",
"nodemailer": "^7.0.5",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/nodemailer": "^6.4.7",
"jsdoc": "^4.0.4",
"nodemon": "^3.1.10"
}
}