improved ui

This commit is contained in:
Kyattsukuro 2025-08-04 15:17:06 +02:00
parent bff37eab52
commit edc9334521
23 changed files with 603 additions and 99 deletions

Binary file not shown.

View File

@ -3,7 +3,7 @@ from bottle import Bottle, request, response
from json import dumps, loads
from db_handler import User, Message
from auth import user_guard
from endpoints.auth import user_guard
from utils import read_keys_from_request
app = Bottle()

View File

@ -2,8 +2,8 @@ from bottle import request, Bottle
from db_handler import DbConnector
import auth
import messages
import endpoints.auth as auth
import endpoints.messages as messages
import bcrypt
# Needet because of: https://github.com/pyca/bcrypt/issues/684

Binary file not shown.

View File

@ -35,6 +35,7 @@
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vite-svg-loader": "^5.1.0",
"vitest": "^3.1.1",
"vue-tsc": "^2.2.8"
}
@ -2162,6 +2163,16 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@tsconfig/node22": {
"version": "22.0.2",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz",
@ -3415,6 +3426,50 @@
"node": ">= 8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -3428,6 +3483,42 @@
"node": ">=4"
}
},
"node_modules/csso": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "~2.2.0"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/css-tree": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.28",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/mdn-data": {
"version": "2.0.28",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/cssstyle": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.4.0.tgz",
@ -3563,6 +3654,65 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -5169,6 +5319,13 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@ -6297,6 +6454,42 @@
"node": ">=8"
}
},
"node_modules/svgo": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.0.0"
},
"bin": {
"svgo": "bin/svgo"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/svgo"
}
},
"node_modules/svgo/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -6842,6 +7035,19 @@
"vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0"
}
},
"node_modules/vite-svg-loader": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vite-svg-loader/-/vite-svg-loader-5.1.0.tgz",
"integrity": "sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"svgo": "^3.0.2"
},
"peerDependencies": {
"vue": ">=3.2.13"
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",

View File

@ -43,6 +43,7 @@
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vite-svg-loader": "^5.1.0",
"vitest": "^3.1.1",
"vue-tsc": "^2.2.8"
}

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import Header from '@/components/Header.vue'
</script>
<template>
<Header />
<RouterView />
</template>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>error</title>
<g id="Page-1" stroke="none" stroke-width="1" fill-rule="evenodd">
<g id="add" fill="#000000" transform="translate(42.666667, 42.666667)">
<path d="M213.333333,3.55271368e-14 C331.136,3.55271368e-14 426.666667,95.5306667 426.666667,213.333333 C426.666667,331.136 331.136,426.666667 213.333333,426.666667 C95.5306667,426.666667 3.55271368e-14,331.136 3.55271368e-14,213.333333 C3.55271368e-14,95.5306667 95.5306667,3.55271368e-14 213.333333,3.55271368e-14 Z M213.333333,42.6666667 C119.232,42.6666667 42.6666667,119.232 42.6666667,213.333333 C42.6666667,307.434667 119.232,384 213.333333,384 C307.434667,384 384,307.434667 384,213.333333 C384,119.232 307.434667,42.6666667 213.333333,42.6666667 Z M262.250667,134.250667 L292.416,164.416 L243.498667,213.333333 L292.416,262.250667 L262.250667,292.416 L213.333333,243.498667 L164.416,292.416 L134.250667,262.250667 L183.168,213.333333 L134.250667,164.416 L164.416,134.250667 L213.333333,183.168 L262.250667,134.250667 Z" id="error">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,5 @@
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75Z"/>
<path d="M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12ZM12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>success</title>
<g id="Page-1" stroke="none" stroke-width="1" fill-rule="evenodd">
<g id="add-copy" fill="#000000" transform="translate(42.666667, 42.666667)">
<path d="M213.333333,3.55271368e-14 C95.51296,3.55271368e-14 3.55271368e-14,95.51296 3.55271368e-14,213.333333 C3.55271368e-14,331.153707 95.51296,426.666667 213.333333,426.666667 C331.153707,426.666667 426.666667,331.153707 426.666667,213.333333 C426.666667,95.51296 331.153707,3.55271368e-14 213.333333,3.55271368e-14 Z M213.333333,384 C119.227947,384 42.6666667,307.43872 42.6666667,213.333333 C42.6666667,119.227947 119.227947,42.6666667 213.333333,42.6666667 C307.43872,42.6666667 384,119.227947 384,213.333333 C384,307.43872 307.438933,384 213.333333,384 Z M293.669333,137.114453 L323.835947,167.281067 L192,299.66912 L112.916693,220.585813 L143.083307,190.4192 L192,239.335893 L293.669333,137.114453 Z" id="Shape">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1 +1,36 @@
@import 'tailwindcss';
h1 {
@apply text-3xl font-bold;
}
h2 {
@apply text-2xl font-semibold;
}
h3 {
@apply text-xl font-medium;
}
p {
@apply text-base;
}
input,
textarea {
@apply w-full p-2 border border-gray-500 bg-teal-200 rounded hover:bg-teal-300 focus:bg-teal-300 focus:outline-none focus:ring-2 focus:ring-teal-500;
}
input::placeholder {
@apply text-gray-500 hover:text-gray-700;
}
button {
@apply w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600;
}
main {
@apply w-full h-screen flex items-center justify-center;
}
.boxed {
@apply space-y-2 bg-blue-200 p-4 rounded-2xl shadow-lg;
}

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { primaryUser, type User } from '@/composable/auth'
import router from '@/router'
</script>
<template>
<div
class="flex flex-row justify-between items-center bg-blue-200 border-b-2 border-blue-400 p-2"
>
<div class="subbox">
<h3 @click="() => router.push('/')" class="hover:cursor-pointer">Simple Chat</h3>
<button
v-if="primaryUser.currentUser.value && primaryUser.currentUser.value.role === 'admin'"
@click="() => router.push('admin')"
>
Admin Panel
</button>
</div>
<div class="subbox" v-if="primaryUser.currentUser.value">
<p class="h-min">User: {{ primaryUser.currentUser.value.user }}</p>
<button
@click="
() => {
primaryUser.removeToken()
router.push('login')
}
"
class="w-min h-min"
>
Logout
</button>
</div>
</div>
</template>
<style scoped>
@reference '@/assets/main.css';
button,
input {
@apply h-min w-auto;
}
.subbox {
@apply flex flex-row space-x-2 items-center;
}
</style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import ErrorSvg from '@/assets/icons/error.svg'
import InfoSvg from '@/assets/icons/info.svg' // Add the correct icon
import SuccessSvg from '@/assets/icons/success.svg' // Add the correct icon
import { computed } from 'vue'
const props = defineProps<{
type?: 'error' | 'info' | 'success'
}>()
const type = computed(() => props.type || 'info')
const icon = computed(() => {
switch (type.value) {
case 'error':
return ErrorSvg
case 'info':
return InfoSvg
case 'success':
return SuccessSvg
default:
return InfoSvg
}
})
const classes = computed(() => {
switch (type.value) {
case 'error':
return 'bg-red-100 text-red-800 border-red-400 fill-red-500'
case 'info':
return 'bg-blue-100 text-blue-800 border-blue-400 fill-blue-500'
case 'success':
return 'bg-green-100 text-green-800 border-green-400 fill-green-500'
default:
return 'bg-gray-100 text-gray-800 border-gray-400 fill-gray-500'
}
})
</script>
<template>
<div class="flex flex-row rounded-2xl border-2 p-2" :class="classes">
<div class="h-auto aspect-square border-r-2 flex items-center justify-center mr-1 pr-1">
<component :is="icon" class="w-6 h-6" />
</div>
<div class="flex mx-auto slot-box">
<slot />
</div>
</div>
</template>
<style scoped>
.slot-box {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -1,5 +1,6 @@
import { API_URL } from '@/main'
import { getJsonOrError } from '@/composable/utils'
import { computed, ref, type Ref } from 'vue'
export interface User {
user: string
@ -14,38 +15,69 @@ const readToken = () => {
return null
}
export const getSessionFromJWT = (): User => {
let coockie = readToken()
if (!coockie) {
throw new Error('No token found in cookies')
const userHandler = () => {
let curentUser: Ref<User | null> = ref(null)
const removeToken = () => {
document.cookie = 'oauth2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;'
curentUser.value = null
}
let token = coockie.split(' ')[coockie.split(' ').length - 1] // Get the last part of the token
try {
// Phrase JWT
const base64Url = token.split('.')[1] // Get the payload part
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') // Base64 adjustments
return JSON.parse(atob(base64)).sub // Decode and parse JSON
} catch (_) {
throw new Error('Invalid token format')
const getSessionFromJWT = (): User => {
let coockie = readToken()
if (!coockie) {
throw new Error('No token found in cookies')
}
let token = coockie.split(' ')[coockie.split(' ').length - 1] // Get the last part of the token
try {
// Phrase JWT
const base64Url = token.split('.')[1] // Get the payload part
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') // Base64 adjustments
return JSON.parse(atob(base64)).sub // Decode and parse JSON
} catch (_) {
throw new Error('Invalid token format')
}
}
const requestToken = async (user: string, password: string): Promise<User> => {
return fetch(`${API_URL}/user/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // set coockies from responce
body: JSON.stringify({
user: user,
password: password,
}),
}).then(async (response) => {
let data = await getJsonOrError(response)
curentUser.value = {
user: data.sub.user as string,
role: data.sub.role as string,
}
return curentUser.value
})
}
const currentUser = (): User | null => {
if (curentUser.value === null) {
try {
curentUser.value = getSessionFromJWT()
} catch (e) {
console.error('Error getting session from JWT:', e)
curentUser.value = null
}
}
return curentUser.value
}
return {
getSessionFromJWT,
requestToken,
removeToken,
currentUser: computed(() => currentUser()),
}
}
export const requestToken = async (user: string, password: string): Promise<User> => {
return fetch(`${API_URL}/user/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // set coockies from responce
body: JSON.stringify({
user: user,
password: password,
}),
}).then(async (response) => {
let data = await getJsonOrError(response)
return {
user: data.sub.user as string,
role: data.sub.role as string,
} as User
})
}
export const primaryUser = userHandler()

View File

@ -13,7 +13,9 @@ interface Message {
export const messageHandler = (room: string = 'general') => {
let messages: Ref<Record<number, Message>> = ref({})
function requestMessages(since: string | undefined): Promise<Record<number, Message>> {
function requestMessages(
since: string | undefined = undefined,
): Promise<Record<number, Message>> {
let query = since ? `?since=${since}` : ''
return fetch(`${API_URL}/messages/${room}${query}`, {
@ -53,5 +55,14 @@ export const messageHandler = (room: string = 'general') => {
}
return messages.value[highestKey]
},
previousMessage: (id: number): Message | undefined => {
const keys = Object.keys(messages.value).map(Number)
const index = keys.indexOf(Number(id))
if (index > 0) {
console.log('Previous message found:', messages.value[keys[index - 1]])
return messages.value[keys[index - 1]]
}
return undefined
},
}
}

View File

@ -16,8 +16,8 @@ const router = createRouter({
meta: { requiresAuth: true },
},
{
path: '/user',
name: 'user',
path: '/admin',
name: 'admin',
component: () => import('../views/User.vue'),
},
{

View File

@ -1,18 +1,46 @@
<script setup lang="ts">
import { getSessionFromJWT, type User } from '@/composable/auth'
import { messageHandler } from '@/composable/message'
import router from '@/router'
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { ref, onMounted, onBeforeUnmount, nextTick, watch, type Ref } from 'vue'
const user: Ref<User> = ref(getSessionFromJWT())
const msg = messageHandler()
const newMessage = ref('')
const msgTimer = ref()
const scrollContainer = ref<null | HTMLElement>(null)
watch(msg.messages, async () => {
// To automatically scroll down when new message arrives
await nextTick()
const el = scrollContainer.value
if (!el) return
el.scrollTop = el.scrollHeight
})
const onSentMessage = () => {
if (newMessage.value.trim() === '') {
return
}
msg
.sendMessage(newMessage.value)
.then(() => {
newMessage.value = ''
})
.catch((error) => {
console.error('Error sending message:', error)
})
}
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Enter' && event.shiftKey === false) {
onSentMessage()
}
}
// Instantiate polling for messages
// ToDo: Try using a WebSocket instead
// ToDo: Try using a WebSocket instea
onMounted(() => {
document.addEventListener('keypress', handleKeyPress)
msg.requestMessages()
msgTimer.value = setInterval(() => {
console.log('Polling for messages...')
msg.requestMessages(msg.lastMsg()?.timestamp)
@ -22,33 +50,58 @@ onMounted(() => {
// Clean up polling
onBeforeUnmount(() => {
clearInterval(msgTimer.value)
document.removeEventListener('keypress', handleKeyPress)
msgTimer.value = null
})
</script>
<template>
<main>
<h1>Chat</h1>
<div class="max-w-128">
<div v-if="msg" v-for="message in msg.messages.value" :key="message.id" class="mb-4">
<p>
<a>{{ message.user }}</a
><a>{{ message.timestamp }}</a
>: {{ message.content }}
</p>
<div class="grid grid-cols-1 grid-rows-10 boxed h-[75vh] max-w-128">
<h1 class="border-b-2">Chat</h1>
<div class="row-span-8 flex flex-col overflow-scroll" ref="scrollContainer">
<div v-if="msg" v-for="(message, id) in msg.messages.value" :key="message.id" class="mb-4">
<div
v-if="
!msg.previousMessage(id) ||
new Date(Number(message.timestamp) * 1000).toLocaleDateString() !==
new Date(Number(msg.previousMessage(id)?.timestamp) * 1000).toLocaleDateString()
"
class="text-center text-gray-500 my-2"
>
{{ new Date(Number(message.timestamp) * 1000).toLocaleDateString() }}
</div>
<p class="whitespace-pre-line">
<a class="italic mr-1">{{
new Date(Number(message.timestamp) * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}}</a>
<a class="font-bold">{{ message.user }}</a
>: {{ message.content }}
</p>
</div>
</div>
<div class="flex flex-row mt-auto">
<textarea
v-model="newMessage"
placeholder="Type your message here..."
class="w-full h-12 min-h-12 p-2 border rounded"
/>
<button
@click="
() =>
msg.sendMessage(newMessage).then(() => {
newMessage = ''
})
"
class="!w-min"
>
Send
</button>
</div>
<input
v-model="newMessage"
type="text"
placeholder="Type your message here..."
class="w-full p-2 border rounded"
/>
<button
@click="() => msg.sendMessage(newMessage)"
class="mt-2 bg-blue-500 text-white p-2 rounded hover:bg-blue-600"
>
Send
</button>
</div>
</main>
</template>

View File

@ -1,15 +1,18 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { API_URL } from '@/main.ts'
import { requestToken } from '@/composable/auth.ts'
import { primaryUser } from '@/composable/auth.ts'
import router from '@/router'
import UserInfo from '@/components/UserInfo.vue'
const name = ref('')
const password = ref('')
const msg = ref('')
const onLogin = () => {
requestToken(name.value, password.value)
primaryUser
.requestToken(name.value, password.value)
.then((user) => {
console.log('Login successful:', user)
router.push({ name: 'chat' })
@ -18,15 +21,36 @@ const onLogin = () => {
msg.value = error.message || 'Login failed'
})
}
onMounted(() => {
document.addEventListener('keypress', handleKeyPress)
})
onBeforeUnmount(() => {
document.removeEventListener('keypress', handleKeyPress)
})
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
onLogin()
}
}
</script>
<template>
<main>
<div>
<input v-model="name" placeholder="Username" />
<input v-model="password" type="password" placeholder="Password" />
<button @click="() => onLogin()">Login</button>
<p>{{ msg }}</p>
<div class="flex flex-col w-80 boxed">
<h3>Auth:</h3>
<div class="flex flex-col space-y-1 items-center">
<input v-model="name" placeholder="Username" />
<input v-model="password" type="password" placeholder="Password" />
<button @click="() => onLogin()">Login</button>
</div>
<UserInfo type="error" v-if="msg">
<template #default>
<p v-if="msg">{{ msg }}</p>
</template>
</UserInfo>
</div>
</main>
</template>

View File

@ -1,18 +1,14 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { API_URL } from '@/main.ts'
import { type User, getSessionFromJWT } from '@/composable/auth.ts'
import { type User, primaryUser } from '@/composable/auth.ts'
const user = ref<User | undefined>()
import UserInfo from '@/components/UserInfo.vue'
const new_user_name = ref('')
const new_user_passwd = ref('')
const new_admin = ref(false)
const msg = ref('')
onMounted(() => {
user.value = getSessionFromJWT()
})
const msg = ref({ message: '', type: 'info' })
const onNewUserCreation = async () => {
try {
@ -31,34 +27,44 @@ const onNewUserCreation = async () => {
const data = await response.json()
if (response.ok) {
msg.value = data.message
msg.value = { message: data.message, type: 'success' }
} else {
throw new Error(data.error || 'Failed to create user')
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
msg.value = `Error creating user: ${errorMessage}`
msg.value = { message: `Error creating user: ${errorMessage}`, type: 'error' }
console.error('Error creating user:', error)
}
}
</script>
<template>
<div v-if="user" class="user-profile">
<h1>User Profile</h1>
<p><strong>Name:</strong> {{ user.user }}</p>
<p><strong>Role:</strong> {{ user.role }}</p>
<div v-if="user.role === 'admin'">
<h2>New user</h2>
<input v-model="new_user_name" placeholder="Username" />
<input v-model="new_user_passwd" type="password" placeholder="Password" />
<input v-model="new_admin" type="checkbox" />
<button @click="() => onNewUserCreation()">Create User</button>
<main>
<div v-if="primaryUser.currentUser.value" class="flex flex-wrap space-x-4 space-y-4">
<div class="boxed">
<h3>User Profile</h3>
<p><a class="font-bold">Name:</a> {{ primaryUser.currentUser.value.user }}</p>
<p><a class="font-bold">Role:</a> {{ primaryUser.currentUser.value.role }}</p>
</div>
<div v-if="primaryUser.currentUser.value.role === 'admin'" class="boxed">
<h3>New user</h3>
<input v-model="new_user_name" placeholder="Username" />
<input v-model="new_user_passwd" type="password" placeholder="Password" />
<span class="flex flex-row">
<label for="new_admin">Admin:</label>
<input v-model="new_admin" id="new_admin" type="checkbox" class="!w-min ml-1" />
</span>
<button @click="() => onNewUserCreation()">Create User</button>
<UserInfo :type="msg.type as any" v-if="msg.message">
<template #default>
<p>{{ msg.message }}</p>
</template>
</UserInfo>
</div>
</div>
</div>
<div v-else>
<p>No user information...</p>
</div>
<p>{{ msg }}</p>
<div v-else>
<p>No user information...</p>
</div>
</main>
</template>

View File

@ -5,10 +5,11 @@ import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import svgLoader from 'vite-svg-loader'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx(), vueDevTools(), tailwindcss()],
plugins: [vue(), vueJsx(), vueDevTools(), tailwindcss(), svgLoader()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),