Initial commit
24
client/.gitignore
vendored
Normal 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
@ -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
@ -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>to-do-list-partner</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2252
client/package-lock.json
generated
Normal file
36
client/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Client application for to-do-list-partner",
|
||||
"author": "li0nhunter",
|
||||
"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",
|
||||
"@volar/typescript": "2.4.23",
|
||||
"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
@ -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">©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>
|
||||
86
client/src/assets/css/base.css
Normal 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 */
|
||||
1
client/src/assets/css/base.css.map
Normal 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"}
|
||||
121
client/src/assets/css/base.scss
Normal 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);
|
||||
}
|
||||
64
client/src/assets/css/jic.css
Normal 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;
|
||||
}
|
||||
309
client/src/assets/css/main.css
Normal 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 */
|
||||
1
client/src/assets/css/main.css.map
Normal 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"}
|
||||
322
client/src/assets/css/main.scss
Normal 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;
|
||||
}
|
||||
|
||||
BIN
client/src/assets/img/Instagram_icon.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
client/src/assets/img/L10n.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/src/assets/img/Linkedin_icon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
9
client/src/assets/svg/edit.svg
Normal 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 |
8
client/src/assets/svg/eye-slash.svg
Normal 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 |
9
client/src/assets/svg/eye.svg
Normal 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 |
17
client/src/assets/svg/trash.svg
Normal 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 |
84
client/src/components/Modal.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import ReadableDiv from "@components/readableComponents/ReadableDiv.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">
|
||||
<ReadableDiv class="modal-header">
|
||||
<slot name="modal-header">
|
||||
Modal Header
|
||||
</slot>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"/>
|
||||
</ReadableDiv>
|
||||
<ReadableDiv v-if="$slots.body" class="modal-body">
|
||||
<slot name="body"></slot>
|
||||
</ReadableDiv>
|
||||
<ReadableDiv v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('close')">Close</button>
|
||||
</slot>
|
||||
</ReadableDiv>
|
||||
</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>
|
||||
182
client/src/components/Navbar.vue
Normal 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>
|
||||
40
client/src/components/Pagination.vue
Normal 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>
|
||||
276
client/src/components/Table.vue
Normal 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>
|
||||
13
client/src/components/readableComponents/ReadableButton.vue
Normal 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>
|
||||
13
client/src/components/readableComponents/ReadableDiv.vue
Normal 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>
|
||||
15
client/src/components/readableComponents/ReadableInput.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts" xmlns="http://www.w3.org/1999/html">
|
||||
import vRecolorText from '@/directives/vRecolorText.ts';
|
||||
const model = defineModel<string>({
|
||||
type: String,
|
||||
default: ''
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-recolor-text v-model="model">
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
65
client/src/components/readableComponents/ReadableSVG.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, computed} from 'vue';
|
||||
import vRecolorSvg from '@/directives/vRecolorSvg.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
svg: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
viewBox?: string;
|
||||
}>();
|
||||
|
||||
const paths = ref<Array<{
|
||||
'clip-rule': string | undefined;
|
||||
d: string | undefined;
|
||||
'fill-rule': "nonzero" | "evenodd" | "inherit" | undefined
|
||||
}>>([]);
|
||||
|
||||
const svgPaths = props.svg
|
||||
.replace(/%20/g, " ")
|
||||
.replace(/%3c/g, "<")
|
||||
.replace(/%3e/g, ">")
|
||||
.split('path')
|
||||
.map(chunk => chunk.trim())
|
||||
.filter((chunk) => !chunk.startsWith('data:image'))
|
||||
.map((path) => path
|
||||
.split('/>')[0]
|
||||
.replace(/fill=.* /g, "")
|
||||
.replace(/["']/g, ""));
|
||||
|
||||
try {
|
||||
for (const path of svgPaths) {
|
||||
const match = {
|
||||
'clip-rule': /clip-rule=(\w+)/.exec(path),
|
||||
'd': /d=(.*Z)/.exec(path),
|
||||
'fill-rule': /fill-rule=(\w+)/.exec(path),
|
||||
}
|
||||
paths.value.push({
|
||||
'clip-rule': match['clip-rule'] ? match['clip-rule'][1].toString() : undefined,
|
||||
'd': match['d'] ? match['d'][1].toString() : undefined,
|
||||
'fill-rule': match['fill-rule'] ? match['fill-rule'][1].toString() as "nonzero" | "evenodd" | "inherit" : undefined,
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing SVG paths:', error);
|
||||
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-rule']" :d="path['d']"
|
||||
:fill-rule="path['fill-rule']" v-recolor-svg/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
56
client/src/directives/vRecolorSvg.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
136
client/src/directives/vRecolorText.ts
Normal 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
@ -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')
|
||||
12
client/src/models/TransferTypes.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface DataEnvelope<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: Error;
|
||||
}
|
||||
export interface DataListEnvelope<T> extends DataEnvelope<T[]>{
|
||||
data: T[];
|
||||
totalItems?: number;
|
||||
pageLimit?: number;
|
||||
}
|
||||
|
||||
export type DynamicDataEnvelope<T> = T extends (infer U)[] ? DataListEnvelope<U> : DataEnvelope<T>;
|
||||
11
client/src/models/globals.ts
Normal 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
@ -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>>;
|
||||
}
|
||||
61
client/src/models/session.ts
Normal file
@ -0,0 +1,61 @@
|
||||
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> ) => {
|
||||
if (!response.data) throw new Error("Invalid login credentials. Please try again.");
|
||||
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((envelope: DataEnvelope<any>)=>{
|
||||
toast.error(envelope.message || envelope.error?.message || "An error occurred while trying to log in. Please try again later.")
|
||||
}) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
client/src/models/toast.ts
Normal 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',
|
||||
})
|
||||
78
client/src/router/index.ts
Normal 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
|
||||
117
client/src/views/AboutPage.vue
Normal 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>
|
||||
102
client/src/views/ContactPage.vue
Normal 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 ReadableButton from "@components/readableComponents/ReadableButton.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>
|
||||
<ReadableButton v-else type="submit" class="btn btn-submit">Send</ReadableButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
textarea {
|
||||
color: var(--color-primary-font);
|
||||
}
|
||||
|
||||
</style>
|
||||
59
client/src/views/LoginPage.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue';
|
||||
import {useLogin} from "@models/session.ts";
|
||||
import {isMobile} from "@models/globals.ts";
|
||||
import eye from '@/assets/svg/eye.svg';
|
||||
import eyeSlash from '@/assets/svg/eye-slash.svg';
|
||||
import ReadableButton from "@components/readableComponents/ReadableButton.vue";
|
||||
import ReadableSvg from "@components/readableComponents/ReadableSVG.vue";
|
||||
import ReadableInput from "@components/readableComponents/ReadableInput.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">
|
||||
<ReadableInput 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">
|
||||
<ReadableSvg :svg="eye" :width="20" :height="20"
|
||||
view-box="0 0 28 28" v-if="!showPassword"/>
|
||||
<ReadableSvg :svg="eyeSlash" :width="20" :height="20"
|
||||
view-box="0 0 28 28" v-else/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<ReadableButton class="btn btn-submit mt-4" type="submit"
|
||||
@click.prevent="login(username, password)">
|
||||
Login
|
||||
</ReadableButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
93
client/src/views/MainPage.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import ReadableDiv from "@components/readableComponents/ReadableDiv.vue";
|
||||
import ReadableButton from "@components/readableComponents/ReadableButton.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">
|
||||
<ReadableDiv class="col-md-1 primary">Main</ReadableDiv>
|
||||
<ReadableDiv class="col-md-1 primary hover">Hover</ReadableDiv>
|
||||
<ReadableDiv class="col-md-1 primary active">Active</ReadableDiv>
|
||||
<ReadableDiv class="col-md-1 primary disabled">Disabled</ReadableDiv>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<ReadableDiv class="col">Secondary:</ReadableDiv>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<ReadableDiv class="col-md-1 secondary">Main</ReadableDiv>
|
||||
<ReadableDiv class="col-md-1 secondary hover">Hover</ReadableDiv>
|
||||
<ReadableDiv class="col-md-1 secondary active">Active</ReadableDiv>
|
||||
<ReadableDiv class="col-md-1 secondary disabled">Disabled</ReadableDiv>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<ReadableDiv class="col">Buttons:</ReadableDiv>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<ReadableButton class="btn btn-primary">Primary Button</ReadableButton>
|
||||
<ReadableButton class="btn btn-secondary">Secondary Button</ReadableButton>
|
||||
<ReadableButton class="btn btn-submit">Submit Button</ReadableButton>
|
||||
<ReadableButton class="btn btn-danger">Danger Button</ReadableButton>
|
||||
</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
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
client/tsconfig.app.json
Normal 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
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
client/tsconfig.node.json
Normal 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
@ -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'),
|
||||
}
|
||||
}
|
||||
})
|
||||