Initial commit
22
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
name: 'Deploy To Dokku'
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
container: node:24
|
||||||
|
steps:
|
||||||
|
- name: Cloning repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to dokku
|
||||||
|
uses: dokku/github-action@master
|
||||||
|
with:
|
||||||
|
git_remote_url: 'ssh://dokku@192.168.1.2:22/to-do-list-partner'
|
||||||
|
ssh_private_key: { secrets.DOKKU_DEPLOY_KEY }
|
||||||
228
.gitignore
vendored
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# ---> Vue
|
||||||
|
# gitignore template for Vue.js projects
|
||||||
|
#
|
||||||
|
# Recommended template: Node.gitignore
|
||||||
|
|
||||||
|
# TODO: where does this rule come from?
|
||||||
|
docs/_book
|
||||||
|
|
||||||
|
# TODO: where does this rule come from?
|
||||||
|
test/
|
||||||
|
|
||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# ---> JetBrains
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# AWS User-specific
|
||||||
|
.idea/**/aws.xml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# SonarLint plugin
|
||||||
|
.idea/sonarlint/
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
11
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
/dataSources.xml
|
||||||
|
/deployment.xml
|
||||||
|
/GitLink.xml
|
||||||
12
.idea/Website-template.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/compiler.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="TypeScriptCompiler">
|
||||||
|
<option name="useTypesFromServer" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
32
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyPep8Inspection" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="E275" />
|
||||||
|
<option value="E302" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N802" />
|
||||||
|
<option value="N803" />
|
||||||
|
<option value="N806" />
|
||||||
|
<option value="N801" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredIdentifiers">
|
||||||
|
<list>
|
||||||
|
<option value="server.models.printers.*" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
6
.idea/jsLibraryMappings.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<includedPredefinedLibrary name="Node.js Core" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/Website-template.iml" filepath="$PROJECT_DIR$/.idea/Website-template.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/sqldialects.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="PROJECT" dialect="PostgreSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
25
.idea/watcherTasks.xml
generated
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectTasksOptions">
|
||||||
|
<TaskOptions isEnabled="true">
|
||||||
|
<option name="arguments" value="run build:sass" />
|
||||||
|
<option name="checkSyntaxErrors" value="true" />
|
||||||
|
<option name="description" />
|
||||||
|
<option name="exitCodeBehavior" value="ERROR" />
|
||||||
|
<option name="fileExtension" value="scss" />
|
||||||
|
<option name="immediateSync" value="true" />
|
||||||
|
<option name="name" value="SCSS" />
|
||||||
|
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
|
||||||
|
<option name="outputFilters">
|
||||||
|
<array />
|
||||||
|
</option>
|
||||||
|
<option name="outputFromStdout" value="false" />
|
||||||
|
<option name="program" value="npm" />
|
||||||
|
<option name="runOnExternalChanges" value="true" />
|
||||||
|
<option name="scopeName" value="Project Files" />
|
||||||
|
<option name="trackOnlyRoot" value="true" />
|
||||||
|
<option name="workingDir" value="$PROJECT_DIR$/client" />
|
||||||
|
<envs />
|
||||||
|
</TaskOptions>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
44
Dockerfile
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Use official Node.js image
|
||||||
|
LABEL authors="Ari Yeger"
|
||||||
|
### Client Stage
|
||||||
|
FROM node:24 AS client-build
|
||||||
|
# Set the working directory for the client
|
||||||
|
WORKDIR /app/client
|
||||||
|
|
||||||
|
# Copy package files and install dependencies
|
||||||
|
COPY /client/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy server source code
|
||||||
|
COPY /client .
|
||||||
|
|
||||||
|
# Build the server (if using TypeScript or build step)
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
### Server Stage
|
||||||
|
FROM node:24 AS server-build
|
||||||
|
# Set the working directory for the server
|
||||||
|
WORKDIR /app/server
|
||||||
|
|
||||||
|
# Copy package files and install dependencies
|
||||||
|
COPY /server/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy server source code
|
||||||
|
COPY /server .
|
||||||
|
|
||||||
|
# no build step for server, its already JavaScript
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:24-slim AS production
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy built client files from client-build stage
|
||||||
|
COPY --from=client-build /app/client/dist ./client/dist
|
||||||
|
# Copy server files from server-build stage
|
||||||
|
COPY --from=server-build /app/server ./server
|
||||||
|
WORKDIR /app/server
|
||||||
|
# Expose the port your server runs on (change if needed)
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["npm", "start"]
|
||||||
18
LICENSE
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 li0nhunter
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
51
README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Website-template
|
||||||
|
|
||||||
|
vue ts + vite frontend, express backend, with a dockerfile and a deploy script to my dokku
|
||||||
|
|
||||||
|
### steps to get started on the dokku side
|
||||||
|
1. create a dokku app
|
||||||
|
```bash
|
||||||
|
dokku apps:create to-do-list-partner
|
||||||
|
```
|
||||||
|
2. create postgres database
|
||||||
|
```bash
|
||||||
|
dokku postgres:create to-do-list-partner-db
|
||||||
|
```
|
||||||
|
3. link the database to the app
|
||||||
|
```bash
|
||||||
|
dokku postgres:link to-do-list-partner-db to-do-list-partner
|
||||||
|
```
|
||||||
|
4. setup db if applicable
|
||||||
|
- dump local db
|
||||||
|
```bash
|
||||||
|
pg_dump -Fc --no-acl --no-owner -h localhost -U <db-user> <db-name> > db.dump
|
||||||
|
```
|
||||||
|
- restore to dokku db
|
||||||
|
```bash
|
||||||
|
dokku postgres:import to-do-list-partner-db < db.dump
|
||||||
|
```
|
||||||
|
5. set app to use nginx
|
||||||
|
- set proxy to nginx
|
||||||
|
```bash
|
||||||
|
dokku proxy:set to-do-list-partner nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
- map nginx port to internal docker port
|
||||||
|
```bash
|
||||||
|
dokku ports:add to-do-list-partner <scheme>:<nginx-port>:<internal-docker-port>
|
||||||
|
```
|
||||||
|
6. set repo dokku deploy key
|
||||||
|
- copy the private key from dokku into the repo secrets under the name `DOKKU_DEPLOY_KEY`
|
||||||
|
7. add the public key to known hosts on dokku
|
||||||
|
```bash
|
||||||
|
dokku ssh-keys:add to-do-list-partner <path-to-public-key>
|
||||||
|
```
|
||||||
|
8. set environment variables
|
||||||
|
- create a `.env` file on the dokku server
|
||||||
|
- add the variables to the `.env` file
|
||||||
|
- add the variables to the app
|
||||||
|
```bash
|
||||||
|
cat .env | xargs dokku config:set --no-restart to-do-list-partner
|
||||||
|
```
|
||||||
|
it should be ready to go now, you can deploy with the deployment script
|
||||||
|
|
||||||
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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
63
server/app.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// server/app.js
|
||||||
|
console.log("Starting Server...");
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
// import multer from 'multer';
|
||||||
|
// import path from 'path';
|
||||||
|
import db from './models/db.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// test db connection
|
||||||
|
try {
|
||||||
|
await db.query("SELECT 1")
|
||||||
|
console.log("DB connection successful")
|
||||||
|
await db.get('users', {id: 1});
|
||||||
|
console.log("Users table exists");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Serve static files from the client build directory
|
||||||
|
if (process.env.NODE_ENV !== 'development') app.use("/", express.static('../client/dist'));
|
||||||
|
|
||||||
|
/*const UPLOAD_FOLDER = path.join(process.cwd(), 'Scans');
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, UPLOAD_FOLDER);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
cb(null, file.originalname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const upload = multer({storage});*/
|
||||||
|
|
||||||
|
// user routes
|
||||||
|
import userRouter from './controllers/userController.js';
|
||||||
|
app.use('/api/users', userRouter);
|
||||||
|
|
||||||
|
// email routes
|
||||||
|
import emailRouter from "./controllers/emailController.js";
|
||||||
|
app.use('/api/email', emailRouter);
|
||||||
|
|
||||||
|
// other routes
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
import errorHandler from './middleware/ErrorHandler.js';
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
// 404 handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({message: 'Route not found'});
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8000;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Express server running on port ${PORT}`);
|
||||||
|
});
|
||||||
148
server/controllers/emailController.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import {Router} from 'express';
|
||||||
|
|
||||||
|
/** @typedef Email
|
||||||
|
* @property {string} name - name of sender.
|
||||||
|
* @property {string} email - email addr of sender.
|
||||||
|
* @property {string} subject - subject of email.
|
||||||
|
* @property {string} message - message body of email.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {import("@types/express").Request} Request */
|
||||||
|
/** @typedef {import("@types/express").Response} Response */
|
||||||
|
/** @typedef {import("@types/express").NextFunction} NextFunction */
|
||||||
|
/** @typedef {import("@types/nodemailer").TransportOptions} TransportOptions */
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(
|
||||||
|
/** @type {TransportOptions}*/
|
||||||
|
{
|
||||||
|
service: 'gmail', // Use Gmail as the email service
|
||||||
|
target: process.env.EMAIL_TARGET, // The email address to send the email to
|
||||||
|
auth: {
|
||||||
|
user: process.env.MAILER_EMAIL_ADDR, // Your Gmail address
|
||||||
|
pass: process.env.MAILER_EMAIL_PASS, // Your Gmail password or App Password
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTimeout(transporter.verify(), 15000)
|
||||||
|
.then(() => {
|
||||||
|
console.log("Transporter is ready to send emails");
|
||||||
|
})
|
||||||
|
.catch((error) => console.error("Error verifying transporter:", error));
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/',
|
||||||
|
/**
|
||||||
|
* Sends an email using the configured transporter.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
console.debug("made it to sendEmail function");
|
||||||
|
let exitonError = false;
|
||||||
|
|
||||||
|
await withTimeout(transporter.verify(), 15000)
|
||||||
|
.then(() => {
|
||||||
|
console.debug("Transporter is ready to send emails");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error verifying transporter:", error);
|
||||||
|
res.status(500).json({message: 'Failed to verify email transporter'});
|
||||||
|
exitonError = true;
|
||||||
|
});
|
||||||
|
if (exitonError) return;
|
||||||
|
const emailWithZeroWidthSpace = req.body.email.replace(/@/g, '@\u200B').replace(/\./g, ".\u200B"); // Add zero-width space to prevent email scraping
|
||||||
|
/** @type {Email} */
|
||||||
|
const contact = {
|
||||||
|
name: req.body.name,
|
||||||
|
email: emailWithZeroWidthSpace, // Add zero-width space to prevent email scraping
|
||||||
|
subject: req.body.subject,
|
||||||
|
message: req.body.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!contact.name || !contact.email || !contact.subject || !contact.message) {
|
||||||
|
return res.status(400).json({message: 'To, subject, and text are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailText = `
|
||||||
|
This email was sent from Li0nhunter's Bot.
|
||||||
|
Name: ${contact.name}
|
||||||
|
Email: ${contact.email}
|
||||||
|
Subject: ${contact.subject}
|
||||||
|
Message: ${contact.message}
|
||||||
|
|
||||||
|
Respond now: mailto:${contact.email}
|
||||||
|
`
|
||||||
|
|
||||||
|
const emailHtml = `
|
||||||
|
<div style="max-width:80%;margin:40px auto;background:var(--color-background);border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.14);padding:32px 24px;font-family:Arial,sans-serif;">
|
||||||
|
<h1 style="margin-bottom:24px;text-align:center;color:var(--color-text);">You got a message from your website's contact page!</h1>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Name:</b></label>
|
||||||
|
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);"><b>${contact.name}</b></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Email:</b></label>
|
||||||
|
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);"><b>${contact.email}</b></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Subject:</b></label>
|
||||||
|
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);"><b>${contact.subject}</b></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<label style="display:block;font-size:1rem;font-weight:500;margin-bottom:4px;color:var(--color-text);"><b>Message:</b></label>
|
||||||
|
<div style="font-size:1rem;font-weight:500;padding:10px 12px;border:1px solid #333333;border-radius:4px;background:var(--color-form-background);color:var(--color-text);white-space:pre-line;"><b>${contact.message}</b></div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<a href="mailto:${contact.email}" style="display:inline-block;font-weight:500;color:var(--color-text);background-color:var(--color-form-background);border:1px solid #333333;padding:10px 32px;text-align:center;border-radius:4px;text-decoration:none;font-size:1.1rem;transition:background 0.2s;"><b>Respond now</b></a>
|
||||||
|
</div>
|
||||||
|
<p style="text-align:center;color:var(--color-text);">This email was sent from Li0nhunter's Bot.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"Li0nhunter's Web Bot" <${process.env.EMAIL_ADDR}>`, // sender address
|
||||||
|
to: process.env.EMAIL_TARGET,
|
||||||
|
replyTo: contact.email,
|
||||||
|
subject: contact.subject, // Subject line
|
||||||
|
text: emailText, // plain text body
|
||||||
|
html: emailHtml, // html body
|
||||||
|
};
|
||||||
|
// Set a timeout of 15 seconds (15,000 ms) for sending the email
|
||||||
|
await withTimeout(transporter.sendMail(mailOptions), 15000)
|
||||||
|
.then((result) => res.status(200).json({data: result, message: 'Email sent successfully'}))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({message: error.message || 'Failed to send email'});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a promise with a timeout.
|
||||||
|
* @param {Promise<any>} promise The promise to execute.
|
||||||
|
* @param {number} ms The timeout in milliseconds.
|
||||||
|
* @throws {Error} If the promise does not resolve within the specified time.
|
||||||
|
* @return {Promise<any>}
|
||||||
|
*/
|
||||||
|
async function withTimeout(promise, ms) {
|
||||||
|
const timeout = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Operation timed out')), ms)
|
||||||
|
);
|
||||||
|
return Promise.race([promise, timeout])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
161
server/controllers/userController.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import authHandler from "../middleware/AuthHandler.js";
|
||||||
|
import users from "../models/users.js";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/** @typedef User
|
||||||
|
* @property {number} id - The unique identifier for the user.
|
||||||
|
* @property {string} username - The username of the user.
|
||||||
|
* @property {string} password - The hashed password of the user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {import("@types/express").Request} Request */
|
||||||
|
/** @typedef {import("@types/express").Response} Response */
|
||||||
|
/** @typedef {import("@types/express").NextFunction} NextFunction */
|
||||||
|
|
||||||
|
router.get("/",
|
||||||
|
/** * Fetches all users from the database.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
await users.getAllUsers()
|
||||||
|
.then((users) => {
|
||||||
|
res.status(200).json({data: users});
|
||||||
|
})
|
||||||
|
.catch((err) => next(err));
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router.get("/",
|
||||||
|
/**
|
||||||
|
* adds new user to db as non-admin. only direct db manipulation can make users admins
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
if (!req.body || !req.body.username || !req.body.password) {
|
||||||
|
return res.status(400).json({data: null, message: 'Username and password are required'});
|
||||||
|
}
|
||||||
|
await users.addNewUser(req.body.username, req.body.password)
|
||||||
|
.then((user) => {
|
||||||
|
if (!user) {
|
||||||
|
return res.status(500).json({data: null, message: 'User not found after insertion'});
|
||||||
|
}
|
||||||
|
res.status(200).json({data: user, message: 'User added successfully'});
|
||||||
|
})
|
||||||
|
.catch((err) => next(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
router.get("/:identifier",
|
||||||
|
/**
|
||||||
|
* Fetches a user by ID or username from the database.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
const userIdentifier = req.params.identifier;
|
||||||
|
if (!userIdentifier) return res.status(400).json({data: null, message: 'User identifier is required'});
|
||||||
|
await users.getUser(userIdentifier)
|
||||||
|
.then(user => {
|
||||||
|
if (!user) return res.status(404).json({data: null, message: 'User not found'});
|
||||||
|
res.status(200).json({data: user});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message === 'User not found') {
|
||||||
|
return res.status(404).json({data: null, error: err});
|
||||||
|
}
|
||||||
|
if (err.message === 'Multiple users found with the same identifier, something has gone wrong') {
|
||||||
|
res.status(500).json({
|
||||||
|
data: null,
|
||||||
|
error: err
|
||||||
|
});
|
||||||
|
} else next(err);
|
||||||
|
} else {
|
||||||
|
next(new Error('An unhandled error occurred while fetching the user'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post("/login",
|
||||||
|
/**
|
||||||
|
* Fetches a user by ID or username from the database.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
const {username, password} = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({
|
||||||
|
data: null,
|
||||||
|
message: 'Username and password are required'
|
||||||
|
});
|
||||||
|
await users.login(username, password).then(data => {
|
||||||
|
if (!data) return res.status(401).json({data: null, message: 'Invalid username or password'});
|
||||||
|
res.status(200).json({data: data, message: 'Login successful'});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message === 'Invalid username or password') {
|
||||||
|
res.status(401).json({data: null, error: err});
|
||||||
|
} else next(err);
|
||||||
|
} else {
|
||||||
|
next(new Error('An unhandled error occurred while login'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.patch("/:identifier", authHandler.authenticateUser,
|
||||||
|
/**
|
||||||
|
* Updates a user by ID or username in the database.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
const userIdentifier = req.params.id;
|
||||||
|
if (!userIdentifier) return res.status(400).json({data: null, message: 'User Identifier is required'});
|
||||||
|
await users.updateUser(req.body)
|
||||||
|
.then(user => {
|
||||||
|
if (!user) res.status(404).json({data: null, message: 'User not found'});
|
||||||
|
else res.status(200).json({data: user, message: 'User updated successfully'});
|
||||||
|
})
|
||||||
|
.catch((err) => next(err))
|
||||||
|
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete("/:identifier", authHandler.authenticateUser,
|
||||||
|
/**
|
||||||
|
* deletes a user by ID or username from the database.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @return {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
async (req, res, next) => {
|
||||||
|
const userIdentifier = req.params.identifier;
|
||||||
|
if (!userIdentifier) return res.status(400).json({data: null, message: 'User identifier is required'});
|
||||||
|
await users.deleteUser(userIdentifier)
|
||||||
|
.then(user => {
|
||||||
|
if (!user) return res.status(404).json({data: null, message: 'User not found'});
|
||||||
|
res.status(200).json({data: user, message: 'User deleted successfully'});
|
||||||
|
})
|
||||||
|
.catch((err) => next(err));
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
65
server/middleware/AuthHandler.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
/** @typedef {import("@types/express").Request} Request */
|
||||||
|
/** @typedef {import("@types/express").Response} Response */
|
||||||
|
/** @typedef {import("@types/express").NextFunction} NextFunction */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/**
|
||||||
|
* Express middleware to verify JWT and check for the admin role.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @returns {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
authenticateAdmin: async (req, res, next) => {
|
||||||
|
const token = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!token) return res.status(401).send({data: null, error: "Token missing"});
|
||||||
|
try {
|
||||||
|
const payload = await new Promise((resolve, reject) => {
|
||||||
|
jwt.verify(token, process.env.JWT_SECRET || "super-secret-key", (err, decoded) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
return resolve(decoded);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Check if payload has id and role and if the role is admin
|
||||||
|
if (!payload || !payload.id || !payload.role) return res.status(401).json({data: null, error: "Invalid token"});
|
||||||
|
if (payload.role !== "admin") return res.status(403).json({data: null, error: "admins only"});
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === 'jwt expired') return res.status(401).json({data: null, message: "Token expired", error: err});
|
||||||
|
res.status(401).json({data: null, message: "Invalid token", error: err});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Express middleware to verify JWT and check for the user role.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @returns {Promise<e.Response<any, Record<string, any>> | void>}
|
||||||
|
*/
|
||||||
|
authenticateUser: async (req, res, next) => {
|
||||||
|
const token = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).send({data: null, error: "Token missing"});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, process.env.JWT_SECRET || "super-secret-key");
|
||||||
|
// Check if payload has id and role
|
||||||
|
if (!payload || !payload.id || !payload.role) {
|
||||||
|
return res.status(401).json({data: null, error: "Invalid token"});
|
||||||
|
}
|
||||||
|
// check if the user id matches the id in the token
|
||||||
|
else if (payload.role !== "admin" && req.params.id && req.params.id !== payload.id.toString()) {
|
||||||
|
return res.status(403).json({data: null, error: "Forbidden"});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === 'jwt expired') {
|
||||||
|
return res.status(401).json({data: null, message: "Token expired", error: err});
|
||||||
|
}
|
||||||
|
res.status(401).json({data: null, message: "Invalid token", error: err});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/middleware/ErrorHandler.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/** @typedef {import("@types/express").Request} Request */
|
||||||
|
/** @typedef {import("@types/express").Response} Response */
|
||||||
|
/** @typedef {import("@types/express").NextFunction} NextFunction */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express error handler middleware.
|
||||||
|
* @param {Error & {status?: number}} err
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} _
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export default (err, req, res, _) => {
|
||||||
|
console.error(err);
|
||||||
|
if (!res.statusCode) {
|
||||||
|
res.status(err.status || 500).json({message: err.message || 'Internal Server Error'});
|
||||||
|
}
|
||||||
|
};
|
||||||
141
server/models/db.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import {Pool} from 'pg';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = new Pool(process.env.DATABASE_URL ? {connectionString: process.env.DATABASE_URL} : {
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic query function for PostgreSQL database.
|
||||||
|
* @template T
|
||||||
|
* @param {string} text
|
||||||
|
* @param {any[]} [params]
|
||||||
|
* @return {Promise<T[]>}
|
||||||
|
*/
|
||||||
|
async function query(text, params= []) {
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await pool.query(text, params).then(qr => qr);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (params) for (let i = 0; i < params.length; i++) text = text.replace("$".concat(String(i + 1)), params[i]);
|
||||||
|
console.log('executed query', {text, duration, rows: res.rowCount});
|
||||||
|
/**@type T[]*/
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* function to insert data into a table.
|
||||||
|
* @template T
|
||||||
|
* @param {string} table The name of the table to insert data into.
|
||||||
|
* @param {Object.<string, any>} map The {keys: values} to insert data into.
|
||||||
|
* @returns {Promise<T[] | Error>} Returns inserted value if the insertion was successful, error otherwise.
|
||||||
|
* */
|
||||||
|
async function insert(table, map) {
|
||||||
|
const valuePlaceholders = Object.keys(map).map((_, index) => `$${index + 1}`).join(", ");
|
||||||
|
const queryText = `INSERT INTO ${table} (${Object.keys(map).join(", ")}) VALUES (${valuePlaceholders}) RETURNING *`;
|
||||||
|
return await query(queryText, Object.values(map))
|
||||||
|
.then(resRows => {
|
||||||
|
if (resRows.length === 0) {
|
||||||
|
console.error('Insert failed: No rows affected');
|
||||||
|
return new Error('Insert failed: No rows affected');
|
||||||
|
}
|
||||||
|
console.log('Insert successful:', resRows);
|
||||||
|
return resRows;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Insert error:', err);
|
||||||
|
return err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to update data in a table.
|
||||||
|
* @template T
|
||||||
|
* @param {string} table
|
||||||
|
* @param {Object.<string, any>} updateMap The {keys: values} to update.
|
||||||
|
* @param {Object.<string, any>} conditions The conditions to match to update.
|
||||||
|
* @returns {Promise<T[] | Error>} Returns updated rows if the insertion was successful, error otherwise.
|
||||||
|
*/
|
||||||
|
async function update(table, updateMap, conditions) {
|
||||||
|
if (Object.keys(updateMap).length === 0) {
|
||||||
|
console.error('Update failed: No fields to update');
|
||||||
|
return new Error('Update failed: No fields to update');
|
||||||
|
}
|
||||||
|
if (Object.keys(conditions).length === 0) {
|
||||||
|
console.error('Update failed: No conditions specified');
|
||||||
|
return new Error('Update failed: No conditions specified');
|
||||||
|
}
|
||||||
|
const setClause = Object.keys(updateMap).map((key, index) => `${key} = ${index + 1}`).join(', ');
|
||||||
|
const whereClause = Object.entries(conditions).map(([key, value]) => `${key} = ${value}`).join(' AND ');
|
||||||
|
return await query(`UPDATE ${table} SET ${setClause} WHERE ${whereClause} RETURNING *`, Object.values(updateMap))
|
||||||
|
.then(resRows => {
|
||||||
|
if (resRows.length === 0) {
|
||||||
|
console.error('Update failed: No rows affected');
|
||||||
|
return new Error('Update failed: No rows affected');
|
||||||
|
}
|
||||||
|
console.log('Update successful:', resRows);
|
||||||
|
return resRows;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Update error:', err);
|
||||||
|
return err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to delete a row from a table by ID.
|
||||||
|
* @template T
|
||||||
|
* @param {string} table The name of the table to delete from.
|
||||||
|
* @param {Object.<string, any>} conditions The conditions to match to delete row/rows.
|
||||||
|
* @return {Promise<T[] | Error>} Returns true if the deletion was successful, false otherwise.
|
||||||
|
*/
|
||||||
|
async function remove(table, conditions = {}) {
|
||||||
|
if (!conditions || Object.keys(conditions).length === 0) {
|
||||||
|
console.error('Delete failed: No identifier specified');
|
||||||
|
return new Error('Delete failed: No identifier specified');
|
||||||
|
}
|
||||||
|
const whereClause = Object.entries(conditions).map(([key, _], index) => `${key} = ${index + 1}`).join(' AND ');
|
||||||
|
const queryText = `DELETE FROM ${table} WHERE ${whereClause} RETURNING *`;
|
||||||
|
return await query(queryText, Object.values(conditions))
|
||||||
|
.then(resRows => {
|
||||||
|
if (resRows.length === 0) {
|
||||||
|
console.error('Delete failed: No rows affected');
|
||||||
|
return new Error('Delete failed: No rows affected');
|
||||||
|
}
|
||||||
|
console.log('Delete successful:', resRows);
|
||||||
|
return resRows;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Delete error:', err);
|
||||||
|
return err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to get rows from a table based on conditions.
|
||||||
|
* @template T
|
||||||
|
* @param {string} table The name of the table to query.
|
||||||
|
* @param {{}} conditions The conditions to match rows.
|
||||||
|
* @return {Promise<T[]>} Returns an array of rows that match the conditions.
|
||||||
|
*/
|
||||||
|
async function get(table, conditions = {}) {
|
||||||
|
if (Object.keys(conditions).length === 0) {
|
||||||
|
return await query(`SELECT * FROM "${table}"`);
|
||||||
|
}
|
||||||
|
const whereClause = Object.entries(conditions).map(([key, _], index) => `${key} = $${index + 1}`).join(' AND ');
|
||||||
|
return await query(`SELECT * FROM ${table} WHERE ${whereClause}`, Object.values(conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
pool,
|
||||||
|
query,
|
||||||
|
insert,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
get,
|
||||||
|
};
|
||||||
146
server/models/users.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import db from "./db.js";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
/** @typedef User
|
||||||
|
* @property {number} id - The unique identifier for the user.
|
||||||
|
* @property {string} username - The username of the user.
|
||||||
|
* @property {string} password - The hashed password of the user.
|
||||||
|
* @property {string} role - The role of the user, e.g., 'user' or 'admin'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all users from the database.
|
||||||
|
* @return {Promise<User[]>}
|
||||||
|
*/
|
||||||
|
async function getAllUsers() {
|
||||||
|
try {
|
||||||
|
return await db.get('users').then((users) => users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets user by id or username
|
||||||
|
* @param {string | number} identifier can be either user id or username
|
||||||
|
* @return {Promise<{id: number, username: string, password: string, role: string}>}
|
||||||
|
*/
|
||||||
|
async function getUser(identifier) {
|
||||||
|
/** @type {User[]} users */
|
||||||
|
let users;
|
||||||
|
if (/^\d+$/.test(identifier)) users = await db.get('users',{"id": identifier});
|
||||||
|
else users = await db.get('users', {'username': identifier});
|
||||||
|
if (users.length === 0) throw new Error("User not found");
|
||||||
|
else if (users.length > 1) throw new Error("Multiple users found with the same identifier, something has gone wrong");
|
||||||
|
return users[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* adds new user to db as non-admin. only direct db manipulation can make users admins
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @return {Promise<User|void>}
|
||||||
|
*/
|
||||||
|
async function addNewUser(username, password) {
|
||||||
|
if (!username || !password) throw new Error('Username and password are required');
|
||||||
|
// Check if the user already exists
|
||||||
|
if ((await db.get('users', {'username': username})).length > 0) throw new Error('User already exists with this username');
|
||||||
|
// Hash the password before storing it
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
/** @type {Promise<User|Error>} */
|
||||||
|
const result = await db.insert('users', {username, password: hashedPassword, role: 'user'});
|
||||||
|
if (!result) throw new Error('User could not be created');
|
||||||
|
if (result instanceof Error) throw result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in a user by validating their credentials and generating a JWT token.
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @return {Promise<{id: number, username: string, role: string, token: (*)}>}
|
||||||
|
*/
|
||||||
|
async function login (username, password) {
|
||||||
|
/** @type {User} user*/
|
||||||
|
const user = await db.get('users', {'username': username}).then(response => {
|
||||||
|
if (response instanceof Error) throw response;
|
||||||
|
if (response.length === 0) throw new Error('User not found');
|
||||||
|
if (response.length > 1) throw new Error('Multiple users found with the same username, something has gone wrong');
|
||||||
|
return response[0];
|
||||||
|
});
|
||||||
|
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||||
|
throw new Error('Invalid username or password');
|
||||||
|
}
|
||||||
|
console.log(user);
|
||||||
|
const token = jwt.sign({id: user.id, role: user.role}, process.env.JWT_SECRET, {expiresIn: "1d"});
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
token: token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a user's information.
|
||||||
|
* @param {{id?: number, username?: string, password?: string, newPassword?: string}} mapping
|
||||||
|
* @return {Promise<User>}
|
||||||
|
*/
|
||||||
|
async function updateUser(mapping) {
|
||||||
|
if (!mapping) throw new Error('User mapping is required');
|
||||||
|
if (!mapping.id && !mapping.username) throw new Error('User id or username is required for update');
|
||||||
|
let user;
|
||||||
|
if (mapping.id) user = await getUser(mapping.id);
|
||||||
|
else user = await getUser(mapping.username);
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
if (mapping.newPassword) {
|
||||||
|
// validate the old password to make sure the user is authenticated
|
||||||
|
if (!mapping.password) throw new Error('Old password is required to update password');
|
||||||
|
if (!bcrypt.compareSync(mapping.password, user.password)) throw new Error('Old password is incorrect');
|
||||||
|
// Hash the new password before updating
|
||||||
|
mapping.password = await bcrypt.hash(mapping.newPassword, 10);
|
||||||
|
delete mapping.newPassword; // Remove newPassword from the mapping
|
||||||
|
}
|
||||||
|
/** @type {User[] | Error} */
|
||||||
|
let result;
|
||||||
|
if (mapping.id) {
|
||||||
|
// remove id from mapping to avoid updating it
|
||||||
|
const id = mapping.id;
|
||||||
|
delete mapping.id;
|
||||||
|
result = await db.update('users', mapping, {'id': id});
|
||||||
|
} else {
|
||||||
|
// remove username from mapping to avoid updating it
|
||||||
|
const username = mapping.username;
|
||||||
|
delete mapping.username;
|
||||||
|
result = await db.update('users', mapping, {'username': username});
|
||||||
|
}
|
||||||
|
if (result instanceof Error) throw result;
|
||||||
|
if (result.length > 1) throw new Error('Multiple users updated, something has gone wrong');
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user by their ID.
|
||||||
|
* @param {string | number} identifier can be either user id or username
|
||||||
|
* @return {Promise<User>}
|
||||||
|
*/
|
||||||
|
async function deleteUser(identifier) {
|
||||||
|
if (!identifier) throw new Error('User identifier is required');
|
||||||
|
/** @type {User[] | Error} */
|
||||||
|
let result;
|
||||||
|
if (/^\d+$/.test(identifier)) result = await db.remove('users', {"id": identifier});
|
||||||
|
else result = await db.remove('users', {'username': identifier});
|
||||||
|
if (result instanceof Error) throw result;
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getAllUsers,
|
||||||
|
getUser,
|
||||||
|
addNewUser,
|
||||||
|
login,
|
||||||
|
updateUser,
|
||||||
|
deleteUser
|
||||||
|
}
|
||||||
2093
server/package-lock.json
generated
Normal file
28
server/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Server application for to-do-list-partner",
|
||||||
|
"author": "li0nhunter",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon app.js",
|
||||||
|
"start": "node app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.1",
|
||||||
|
"nodemailer": "^7.0.5",
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/nodemailer": "^6.4.7",
|
||||||
|
"jsdoc": "^4.0.4",
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||