Merge pull request #4 from joschuatonn/feature/chatSettingsComponent

Feature/chat settings component
This commit is contained in:
Joschua Tonn
2024-06-27 19:30:36 +02:00
committed by GitHub
23 changed files with 1329 additions and 512 deletions

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# build stage
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# production stage
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist/spa/ /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,41 +1,60 @@
# Praxistransferprojekt II (praxistransfer-frontend)
# Praxistransferprojekt II (ptpChat) - Frontend
Praxistransferprojekt | Zweites Semester
Dieses Repository beinhaltet das Frontend für das im zweiten Semester realisierte Projekt "ptpChat"
## Install the dependencies
> **_Notiz:_** Die folgende Anleitung erlaubt ein Setup des Frontends komplett ohne IDE. Durch die Verwendung einer solchen entfallen manche Schritte gegebenenfalls.
## Voraussetzungen
Vor der Durchführung der anderen Schritte sollte sichergestellt werden, dass die folgenden Softwarekomponenten installiert sind. Grundsätzlich kann, je nach persönlicher Präferenz, zwischen `npm` und `yarn` ausgewählt werden:
- [Node.js](https://nodejs.org/)
- [Yarn](https://yarnpkg.com/) oder [npm](https://www.npmjs.com/)
(In der Entwicklung wurde ausschließlich npm verwendet, was aus diesem Grund hier empfohlen wird)
Außerdem wird `git` benötigt:
- [git Donwloads](https://git-scm.com/downloads)
## Clonen des Repositories
Zum Clonen des Repositories muss folgender Befehl in einer beliebigen Shell ausgeführt werden:
```bash
git clone https://github.com/joschuatonn/praxistransferFrontend
```
Für die folgenden Schritte muss zuerst in das Projektverzeichnis navigiert werden:
```bash
cd praxistransferFrontend
```
## Installieren der Dependencies
```bash
yarn
# or
# oder
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
## Starten der Applikation im Development-Modus
```bash
yarn dev
# oder
npm run dev
```
Sofern die Quasar-CLI installiert ist, kann auch folgender Befehl verwendet werden:
```bash
quasar dev
```
### Nutzung der Applikation
### Lint the files
```bash
yarn lint
# or
npm run lint
```
Das Setup des Frontends ist nun abgeschlossen. Standardmäßig ist dieses über `http://localhost:8080` erreichbar. Um die reibungslose Funktionalität der gesamten Applikation zu gewährleisten, wird von einer Änderung der Standardeinstellung abgeraten.
Um die Installation der Applikation vollständig abzuschließen, müssen außerdem die Anleitungen in folgenden Repos befolgt werden:
### Format the files
```bash
yarn format
# or
npm run format
```
[ptpChat-Services](https://github.com/joschuatonn/praxistransferServices) --> Enthält die Installation von Keycloak und Postgres
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
[ptpChat-Backend](https://github.com/joschuatonn/praxistransferBackend) --> Enthält das Java-SpringBoot Backend

View File

@@ -1,46 +1,78 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import ProtectedData from './components/ProtectedData.vue';
import IndexPage from 'pages/IndexPage.vue';
defineOptions({
name: 'App'
});
import { RouterView } from 'vue-router';
import ProtectedData from './components/ProtectedData.vue';
import IndexPage from 'pages/IndexPage.vue';
import { useUserStore } from './stores/user-store';
import { useQuasar } from 'quasar';
defineOptions({
name: 'App'
});
const userStore = useUserStore();
</script>
<template>
<IndexPage></IndexPage>
<IndexPage v-if="userStore.userLoaded"></IndexPage>
<div style="display: flex; align-items: center;justify-content: center;padding-top: 45vh;flex-direction: column;width: 100vw; height: 100vh;background: white;" v-else>
<div>
<q-spinner
color="primary"
size="3em"
/>
</div>
<div class="text-h6" v-if="userStore.reconnectTries < 2">ptpChat wird geladen...</div>
<div class="text-h6" v-else-if="userStore.reconnectTries < 20">Verbindung wird wiederhergestellt (Versuch {{ userStore.reconnectTries }})</div>
<div class="text-h6" v-else>Also langsam wirds peinlich</div>
</div>
<router-view />
</template>
<style>
header {
line-height: 1.5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 7vh;
}
header {
line-height: 1.5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 7vh;
}
/* CUSTOM SCROLLBAR DESIGN START */
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* CUSTOM SCROLLBAR DESIGN START */
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track {
background: white;
}
/* Track */
::-webkit-scrollbar-track {
background: white;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* CUSTOM SCROLLBAR DESIGN ENDE */
:root {
--defaultGridBorderRadius: 1rem;
--defaultElementBackground: rgb(197, 197, 197);
}
body {
background: rgb(50, 50, 50);
height: 100vh;
width: 100vw;
overflow: hidden !important;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* CUSTOM SCROLLBAR DESIGN ENDE */
</style>

View File

@@ -1,48 +1,63 @@
<script setup lang="ts">
import {message, ptpTestUser} from 'src/models';
const props = defineProps({
chat: Object,
chat: {
type: Object,
required: true
},
selected: Boolean,
lastMessage: String,
})
</script>
<template>
<div class="profileCard" :class="selected ? 'selected' : ''">
<div class="profilePicture" :style="{'background-image' : 'url(https://i.pinimg.com/736x/f2/6d/38/f26d38b9685a48e8a43481391f75471c.jpg)' }"></div>
<div style="margin-left:10px;">
<p style="margin-bottom: 0;line-height: 25px;vertical-align: middle;height: 25px;display: table-cell;">
<span style="font-weight: bold;font-size: 1.2rem;">{{props.chat!.displayName}}</span>
</p>
<div class="profileCard" :class="{ selected: props.selected }">
<div class="profilePicture" :style="{ backgroundImage: 'url(' + props.chat.profilepicture + ')' }"></div>
<div class="profileInfo">
<p class="displayName">{{ props.chat.displayName }}</p>
<p class="lastMessage">{{ props.lastMessage }}</p>
</div>
</div>
</template>
<style scoped>
.profileCard {
display: flex;
border-radius: 10px;
padding: 10px;
transition: 0.3s;
cursor: pointer;
}
.profileCard:hover,
.selected {
background: rgba(0, 0, 0, 0.2);
}
.profilePicture {
width: 50px;
height: 50px;
border-radius: 50%;
background-size: cover;
background-position: center;
}
.profileCard {
display: flex;
border-radius: 10px;
padding: 10px;
transition: .3s;
cursor: pointer;
.profileInfo {
margin-left: 10px;
}
.profileCard:hover {
transition: .3s;
background: rgba(0,0,0,0.2);
.displayName {
font-weight: bold;
font-size: 1.2rem;
margin-bottom: 0;
}
.selected {
background: rgba(0,0,0,0.2);
.lastMessage {
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 20rem;
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<q-dialog v-bind:model-value="internalShowDialog" @update:model-value="closeDialog" style="overflow-x: hidden;" full-width>
<q-card style="margin: 3rem 15rem;height: 80vh;padding: 20px;overflow: auto !important;">
<q-card-section class="row items-center q-pb-none">
<div class="text-h4">{{ props.chat!.displayName }}</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<div class="grid-container" v-if="chat?.members">
<q-card-section style="grid-row: span 2;">
<div class="text-h5">Mitglieder ({{ props.chat!.members.length }})</div>
<div style="height:15rem;overflow-y: auto;">
<table>
<tr v-for="user in props.chat!.members">
<td>
<ProfileCardComponent :user="user" :selected="false" :showAdminTag="isUserAdmin(user.id)"></ProfileCardComponent>
</td>
<td v-if="user.id != userStore.user!.id && currentUserIsAdmin">
<q-btn round flat icon="person_remove" style="color: darkred;" title="Nutzer entfernen" @click="removeUser(user.id)"></q-btn>
<q-btn round flat icon="arrow_downward" title="Adminrechte entziehen" @click="removeAdmin(user.id)" v-if="isUserAdmin(user.id)"></q-btn>
<q-btn round flat icon="arrow_upward" title="Nutzer zum Admin machen" @click="addAdmin(user.id)" v-if="!isUserAdmin(user.id)"></q-btn>
</td>
</tr>
</table>
</div>
</q-card-section>
<!-- Namen des Chats ändern -->
<q-card-section v-if="currentUserIsAdmin">
<div class="text-h5">Namen ändern</div>
<q-input filled v-model="props.chat!.displayName" maxlength="256" counter @update:model-value="showUnsavedChangesAlert" style="width: 30rem"/>
</q-card-section>
<q-card-section v-if="currentUserIsAdmin">
<div class="text-h5">Gruppenbild ändern</div>
<div style="display: flex;" v-if="chatStore.profilePicturesLoaded">
<div
v-for="(image, index) in chatStore.profilePictures"
:key="index"
:style="{'background-image' : 'url('+image.url+')' , 'outline' : image.url == chat.profilepicture ? 'dashed grey' : 'none'}"
style="color: red;"
round
flat
outline
class="profilePictureSelector"
@click="selectProfilePicture(image)"
>
</div>
</div>
<div v-else>
<q-spinner
color="primary"
size="3em"
/>
</div>
</q-card-section>
<q-card-section v-if="currentUserIsAdmin">
<div class="text-h5">Mitglieder hinzufügen</div>
<q-input
filled
v-model="searchQuery"
placeholder="Nach Nutzern suchen"
@update:model-value="updateSearchResults"
>
<template v-slot:prepend>
<q-icon name="search"></q-icon>
</template>
</q-input>
<div style="height: 12rem; overflow-y: auto;">
<div v-for="(user, index) in searchResults" class="grid-container-search">
<div class="wrapper-div">
<q-btn
v-if="!props.chat!.members.find((u : ptpUser) => user.id == u.id)"
round
flat
dense
icon="add"
title="Hinzufügen"
style="width:3rem;height:3rem;"
class="button"
@click="handleClickEvent(user)"
/>
</div>
<ProfileCardComponent :user="user" :selected="false"></ProfileCardComponent>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="text-h5">Aktionen</div>
<q-btn class="deleteButton" @click="removeUser(userStore.user!.id)">Gruppenchat verlassen</q-btn>
<q-btn class="deleteButton" v-if="currentUserIsAdmin" @click="deleteChat">Gruppenchat löschen</q-btn>
</q-card-section>
</div>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import ProfileCardComponent from './ProfileCardComponent.vue';
import { useUserStore } from 'src/stores/user-store';
import { profilePicture, ptpUser, responseModel } from 'src/models';
import type { chat } from 'src/models';
import { useChatStore } from 'src/stores/chat-store';
import { useQuasar } from 'quasar';
import { sendNotification, getFilteredUserList } from 'src/utils';
const userStore = useUserStore();
const chatStore = useChatStore();
const showingAlert = ref(false);
const searchQuery = ref("");
const searchResults = ref([] as ptpUser[]);
const emit = defineEmits(['update:showDialog', 'showModal']);
const props = defineProps({
showDialog: Boolean,
chatID: Number,
chat: Object,
leaveChat: {
type: Function,
required: true
}
});
const internalShowDialog = computed({
get: () => props.showDialog,
set: (value) => emit('update:showDialog', value)
});
const closeDialog = () => {
emit('showModal', true);
}
const selectProfilePicture = (image: profilePicture) => {
chat.value!.profilepicture = image.url;
//props.chat!.profilepictureAsString = image.url;
showUnsavedChangesAlert();
}
const $q = useQuasar();
const chat = ref(chatStore.chats.find((chat) => chat.id == props.chat!.id));
const currentUserIsAdmin = computed(() => {
return props.chat!.adminIds.includes(userStore.user!.id);
});
const updateSearchResults = () => {
searchResults.value = getFilteredUserList(searchQuery.value, userStore.users, userStore.user!.id);
}
const handleClickEvent = (user : ptpUser) => {
chatStore.addUserToChat(user.id, props.chatID!).then((response : responseModel) => {
chatStore.loadChatsByUser().then(() => {
sendNotification(response.message, response.success);
props.chat!.members.unshift(user);
});
});
}
const removeUser = (keycloakID : string) => {
chatStore.removeUserFromChat(keycloakID, props.chatID!).then((response : responseModel) => {
chatStore.loadChatsByUser().then(() => {
if(response.success) {
if(userStore.user!.id == keycloakID) {
sendNotification("Du hast den Chat verlassen", true);
props.leaveChat();
closeDialog();
} else {
sendNotification(response.message, response.success);
}
//props.chat!.members.shift();
const index = props.chat!.members.indexOf(props.chat!.members.find((user : ptpUser) => user.id == keycloakID));
if (index > -1) {
props.chat!.members.splice(index, 1);
}
//chat.value = chatStore.chats.find((chat) => chat.id == props.chatID);
}
});
});
}
const deleteChat = () => {
chatStore.deleteChat(props.chatID!).then((response : responseModel) => {
sendNotification(response.message, response.success);
props.leaveChat();
closeDialog();
});
}
const addAdmin = (keycloakID : string) => {
chatStore.addAdmin(props.chatID!, keycloakID).then((response : responseModel) => {
chatStore.loadChatsByUser().then(() => {
sendNotification(response.message, response.success);
chat.value = chatStore.chats.find((chat) => chat.id == props.chatID);
});
});
}
const removeAdmin = (keycloakID : string) => {
chatStore.removeAdmin(props.chatID!, keycloakID).then((response : responseModel) => {
chatStore.loadChatsByUser().then(() => {
sendNotification(response.message, response.success);
chat.value = chatStore.chats.find((chat) => chat.id == props.chatID);
});
});
}
const isUserAdmin = (keycloakID : string) => {
return chat.value!.adminIds.includes(keycloakID);
}
const saveChatChanges = () => {
chatStore.updateChat(chat.value as chat).then((response : responseModel) => {
showingAlert.value = false;
sendNotification(response.message, response.success);
});
}
const showUnsavedChangesAlert = () => {
if(!showingAlert.value) {
$q.notify({
message: 'Du hast Änderungen vorgenommen, die noch nicht gespeichert wurden',
position: 'top',
timeout: 0,
color: "green",
icon: 'announcement',
actions: [
{ label: 'Speichern', color: 'white', handler: () => { saveChatChanges() } },
{ label: 'Verwerfen', color: 'white', handler: () => { discardChanges() } },
]
});
showingAlert.value = true;
}
}
const discardChanges = () => {
showingAlert.value = false;
closeDialog();
}
</script>
<style scoped>
.deleteButton {
color:darkred;
border:2px solid darkred;
border-radius: 6px;
}
.grid-container {
display: grid;
grid-template-columns: auto auto;
padding: 10px;
}
.grid-container > div:nth-child(odd) {
margin-right: 50px;
min-width: 30vw;
}
.profilePictureSelector {
transition: .3s;
background-size: cover;
background-position:center;
width: 6rem;
height:6rem;
margin: 15px;
border-radius: 100%;
cursor: pointer;
}
.profilePictureSelector:hover {
transition: .3s;
filter: brightness(0.6);
}
/* MIGHT BE MOVED LATER */
.grid-container-search {
display: grid;
grid-template-columns: 70px auto;
}
.grid-container-search > div:nth-child(even) {
min-width: 20rem;
}
.wrapper-div {
height:100%;
position: relative;
}
.button {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<q-dialog v-bind:model-value="internalShowDialog" @update:model-value="closeDialog" full-width>
<q-card style="padding: 20px;margin: 0 40rem;height: 80vh;">
<q-card-section class="row items-center q-pb-none">
<div class="text-h4">Chat erstellen</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<div style="display: flex;overflow-x: auto;height: 42px;">
<div v-for="user in usersForChat" style="background: #e0e0e0;border-radius: 10px;display: flex;padding: 3px;margin: 3px;">
<div
style="width: 30px; height: 30px; background-size: cover;background-position: center;border-radius: 100%;"
:style="{'background-image' : 'url('+user.profilepicture+')'}"
></div>
<div class="text-body" style="margin: 3px 10px 0 10px;white-space: nowrap;">{{ user.firstName }} {{ user.lastName }}</div>
<q-btn round flat icon="close" style="font-size: 10px;" @click="handleClickEvent(user)"/>
</div>
</div>
<q-input
filled
v-model="chatName"
placeholder="Name des Chats"
maxlength="256"
counter
style="margin: 20px 0;"
>
<template v-slot:prepend>
<q-icon name="edit"></q-icon>
</template>
</q-input>
<q-input
filled
v-model="searchQuery"
placeholder="Nach Nutzern suchen"
@update:model-value="updateSearchResults"
>
<template v-slot:prepend>
<q-icon name="search"></q-icon>
</template>
</q-input>
<div style="height: 50%;">
<div v-for="(user, index) in searchResults" class="grid-container">
<div class="wrapper-div">
<q-btn
round
flat
dense
:icon="isUserSelected(user) ? 'remove' : 'add'"
:title="isUserSelected(user) ? 'Entfernen' : 'Hinzufügen'"
style="width:3rem;height:3rem;"
class="button"
@click="handleClickEvent(user)"
/>
</div>
<ProfileCardComponent :user="user" :selected="false"></ProfileCardComponent>
</div>
</div>
<q-btn style="width: 100%;" @click="createChat">Chat erstellen</q-btn>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import ProfileCardComponent from './ProfileCardComponent.vue';
import { useUserStore } from 'src/stores/user-store';
import type { ptpUser, responseModel } from 'src/models';
import {getFilteredUserList, sendNotification} from 'src/utils';
import { useChatStore } from 'src/stores/chat-store';
const userStore = useUserStore();
const searchQuery = ref("");
const chatName = ref("");
const usersForChat = ref([] as ptpUser[]);
const searchResults = ref([] as ptpUser[]);
const emit = defineEmits(['update:showDialog', 'showModal']);
const props = defineProps({
showDialog: Boolean
});
const internalShowDialog = computed({
get: () => props.showDialog,
set: (value) => emit('update:showDialog', value)
});
const closeDialog = () => {
emit('showModal', true);
}
const updateSearchResults = () => {
searchResults.value = getFilteredUserList(searchQuery.value, userStore.users, userStore.user!.id);
}
const isUserSelected = (user : ptpUser) => {
return usersForChat.value.indexOf(user) >= 0;
}
const handleClickEvent = (user : ptpUser) => {
if(isUserSelected(user)) {
const index = usersForChat.value.indexOf(user);
usersForChat.value.splice(index, 1);
} else {
usersForChat.value.push(user);
}
}
const createChat = () => {
let errorOccured = false;
if(chatName.value == "") {
sendNotification("Du hast keinen Namen festgelegt", false);
errorOccured = true;
}
if(!errorOccured) {
setupChat(chatName.value);
closeDialog();
chatName.value = "";
usersForChat.value = [] as ptpUser[];
}
}
const chatStore = useChatStore();
const setupChat = async(chatName: string)=>{
chatStore.createChat(chatName, usersForChat.value.map((user) => user.id), userStore.user!.id).then((response : responseModel) => {
sendNotification(response.message, response.success);
reloadChats();
});
}
const reloadChats = async() => {
await chatStore.loadChatsByUser();
};
</script>
<style scoped>
.grid-container {
display: grid;
grid-template-columns: 70px auto;
}
.grid-container > div:nth-child(even) {
min-width: 20rem;
}
.wrapper-div {
height:100%;
position: relative;
}
.button {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
</style>

View File

@@ -1,39 +0,0 @@
<template>
<q-item
clickable
tag="a"
target="_blank"
:href="link"
>
<q-item-section
v-if="icon"
avatar
>
<q-icon :name="icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ title }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
defineOptions({
name: 'EssentialLink'
});
export interface EssentialLinkProps {
title: string;
caption?: string;
link?: string;
icon?: string;
};
withDefaults(defineProps<EssentialLinkProps>(), {
caption: '',
link: '#',
icon: '',
});
</script>

View File

@@ -1,37 +0,0 @@
<template>
<div>
<p>{{ title }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="increment">
{{ todo.id }} - {{ todo.content }}
</li>
</ul>
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
<p>Active: {{ active ? 'yes' : 'no' }}</p>
<p>Clicks on todos: {{ clickCount }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Todo, Meta } from './models';
interface Props {
title: string;
todos?: Todo[];
meta: Meta;
active: boolean;
};
const props = withDefaults(defineProps<Props>(), {
todos: () => []
});
const clickCount = ref(0);
function increment() {
clickCount.value += 1;
return clickCount.value;
}
const todoCount = computed(() => props.todos.length);
</script>

View File

@@ -4,7 +4,7 @@
:style="{'background-image' : 'url('+props.image+')' }"
class="image"
></div>
<h6 class="displayName">{{ props.displayName }}</h6>
<h6 class="displayName" :title="props.displayName">{{ props.displayName }}</h6>
</div>
</template>
@@ -19,6 +19,10 @@
.displayName {
height: 100%;
margin: 0.5rem 1rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 200px;
}
.image {

View File

@@ -1,22 +1,46 @@
<script setup lang="ts">
import { getDaysOffset } from 'src/utils';
const props = defineProps({
ownMessage: Boolean,
timestamp: String,
message: String,
gif: Boolean,
link: Boolean,
url: String,
showSender: Boolean,
nametagColor: String,
message: {
type: Object,
required: true
}
})
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
};
const date = new Date(Number(props.message.timestamp));
let timestampFormatted = date.toLocaleString('de-DE', options);
const daysOffset = getDaysOffset(date, new Date());
if(daysOffset <= 2) {
const descriptions = ['Heute', 'Gestern', 'Vorgestern'];
timestampFormatted = descriptions[daysOffset] + ', ' + date.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'});
}
</script>
<template>
<div class="message" :class="{'sent' : props.ownMessage, 'received' : !props.ownMessage}">
<p v-if="!props.gif && !props.link" style="margin-bottom: 0;">{{props.message}}</p>
<img v-if="props.gif" :src="props.url" alt="GIF" style="width: 100%;border-radius: 10px;" />
<a v-if="props.link" :href="props.url" target="_blank">{{props.url}}</a>
<div v-if="props.showSender && !props.ownMessage" style="display: flex; justify-content: space-between;">
<div class="text-body" style="font-weight: bold;" :style="{'color' : props.nametagColor }">{{ props.message!.sender.firstName }} {{ props.message!.sender.lastName }}</div>
<div class="text-body" style="color: #505050"><small>@{{ props.message!.sender.username }}</small></div>
</div>
<p v-if="!props.message.gif && !props.message.link" style="margin-bottom: 0;">{{props.message.content}}</p>
<img v-if="props.message.gif" :src="props.message.content" alt="GIF" style="width: 100%;border-radius: 10px;" />
<a v-if="props.message.link" :href="props.message.content" target="_blank">{{props.message.content}}</a>
<div style="text-align: right;">
<small style="margin-top: 0;">{{props.timestamp}}</small>
<small style="margin-top: 0;color: #505050;">{{timestampFormatted}}</small>
</div>
</div>
</template>
@@ -24,7 +48,8 @@
<style scoped>
.message {
border-radius: 10px;
width: 15rem;
max-width: 20rem;
width: auto;
padding: 7px;
margin:3px;
box-shadow: 1px 1px 5px 0 rgba(0,0,0,0.15);

View File

@@ -1,23 +1,32 @@
<!--
Diese Komponente erlaubt das Anzeigen eines Nutzers
Sie wird unter anderem bei der Suche verwendet
-->
<script setup lang="ts">
import {message, ptpTestUser} from 'src/models';
const props = defineProps({
user: Object,
selected: Boolean,
slim: Boolean,
lastMessage: String,
showAdminTag: Boolean
})
</script>
<template>
<div class="profileCard" :class="selected ? 'selected' : ''">
<div class="profilePicture" :style="{'background-image' : 'url('+props.user!.profilePictureUrl+')' }"></div>
<div class="profilePicture" :style="{'background-image' : 'url('+props.user!.profilepicture+')' }"></div>
<div style="margin-left:10px;">
<p style="margin-bottom: 0;line-height: 25px;vertical-align: middle;height: 25px;display: table-cell;">
<span style="font-weight: bold;font-size: 1.2rem;">{{props.user!.firstName}} {{ props.user!.lastName }}</span>
@{{props.user!.username}}
<span v-if="!props.slim">@{{props.user!.username}}</span>
<span v-if="props.showAdminTag" style="color: darkred;"> [Administrator]</span>
</p>
<p style="margin: 0;">{{ props.user!.status }}</p>
<p v-if="!props.slim" style="margin: 0;">{{ props.user!.status }}</p>
<p v-else style="margin: 0;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;width:20rem;">{{ props.lastMessage }}</p>
</div>
</div>
</template>

View File

@@ -1,8 +1,21 @@
<!--
Diese Komponente erlaubt dem Nutzer die Änderung seiner Accountdaten
-->
<template>
<q-dialog v-model="internalShowDialog" backdrop-filter="brightness(60%)" full-width>
<q-dialog v-bind:model-value="showDeleteAccountPopup">
<q-card style="padding: 20px;">
<q-card-section>
<div class="text-h4">Account löschen</div>
<div class="text-body">Bist du sicher, dass du deinen Account löschen möchtest? (Dieses Feature ist momentan noch nicht aktiv)</div>
</q-card-section>
<q-card-actions align="right">
<q-btn label="Abbrechen" class="greyButton" style="width: 48%;" @click="showDeleteAccountPopup = false" />
<q-btn label="Account löschen" class="buttonRed" style="width: 48%" @click="" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-bind:model-value="internalShowDialog" @update:model-value="closeDialog" backdrop-filter="brightness(60%)" full-width>
<q-card style="margin: 3rem 20rem;padding: 30px;height: 80vh;overflow-y:scroll" v-if="userStore.userLoaded">
<!--<h1>{{ internalShowDialog }}</h1>-->
<!-- HEADLINE -->
<q-card-section class="row items-center q-pb-none">
<h4 style="margin: 0;">Einstellungen</h4>
@@ -15,20 +28,24 @@
<h5>Nutzerdaten</h5>
<div style="display: flex; flex-wrap: nowrap">
<div>
<table>
<tr>
<td>Vorname:</td>
<td>{{ userStore.user!.firstName }}</td>
</tr>
<tr>
<td>Nachname:</td>
<td>{{ userStore.user!.lastName }}</td>
</tr>
<tr>
<td>Nutzername:</td>
<td>{{ userStore.user!.username }}</td>
</tr>
</table>
<table>
<tr>
<td>Vorname:</td>
<td>{{ userStore.user!.firstName }}</td>
</tr>
<tr>
<td>Nachname:</td>
<td>{{ userStore.user!.lastName }}</td>
</tr>
<tr>
<td>Nutzername:</td>
<td>{{ userStore.user!.username }}</td>
</tr>
<tr>
<td>Sichtbarkeit:</td>
<td>{{ userStore.user!.isPrivate ? 'Privat' : 'Öffentlich' }}</td>
</tr>
</table>
</div>
</div>
</q-card-section>
@@ -37,26 +54,32 @@
<h5>Profilbild</h5>
<p>Du kannst eins der folgenden Porfilbilder aussuchen</p>
<div style="display: flex;">
<div style="display: flex;" v-if="userStore.profilePicturesLoaded">
<div
v-for="(imageUrl, index) in profilePictures"
:style="{'background-image' : 'url('+imageUrl+')' , 'outline' : imageUrl == userStore.user!.profilePictureUrl ? 'dashed grey' : 'none'}"
v-for="(image, index) in userStore.profilePictures"
:style="{'background-image' : 'url('+image.url+')' , 'outline' : image.url == userStore.user!.profilepicture ? 'dashed grey' : 'none'}"
style="color: red;"
round
flat
outline
class="profilePictureSelector"
@click="selectProfilePicture(index)"
@click="selectProfilePicture(image)"
>
</div>
</div>
<div v-else>
<q-spinner
color="primary"
size="3em"
/>
</div>
</q-card-section>
<q-card-section>
<h5>Aktionen</h5>
<q-btn @click="logout()" label="Abmelden" style="color:darkred;border:2px solid darkred;border-radius: 6px;width: 100%;margin-bottom: 14px;"/>
<q-btn @click="logout()" label="Account privatisieren" style="color:darkred;border:2px solid darkred;border-radius: 6px;width: 100%;margin-bottom: 14px;"/>
<q-btn @click="" label="Account löschen" style="color:darkred;border:2px solid darkred;border-radius: 6px;width: 100%;"/>
<q-btn @click="toggleSetPrivate()" :label="userStore.user!.isPrivate ? 'Account entprivatisieren' : 'Account privatisieren'" style="color:darkred;border:2px solid darkred;border-radius: 6px;width: 100%;margin-bottom: 14px;"/>
<q-btn @click="deleteAccount()" label="Account löschen" style="color:darkred;border:2px solid darkred;border-radius: 6px;width: 100%;"/>
</q-card-section>
<q-card-section>
@@ -73,16 +96,17 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import {message, ptpTestUser} from 'src/models';
import {profilePicture, responseModel,} from 'src/models';
import { useUserStore } from 'src/stores/user-store';
import { computed, onMounted, toRefs } from 'vue';
import { sendNotification } from 'src/utils';
import { computed } from 'vue';
import { ref } from 'vue';
const userStore = useUserStore();
const showingAlert = ref(false);
const $q = useQuasar();
const emit = defineEmits(['update:showDialog']);
const emit = defineEmits(['update:showDialog', 'showModal']);
const props = defineProps({
showDialog: Boolean
});
@@ -92,31 +116,38 @@
set: (value) => emit('update:showDialog', value)
});
// Eine Liste der möglichen Bilder soll später aus dem Backend kommen
let profilePictures : string[] = [
"https://assets.change.org/photos/8/jw/ax/QMjwAxeQAfcpoxs-1600x900-noPad.jpg?1597557421",
"https://media.tenor.com/fErt1YElZ2gAAAAe/shrek-rizz-reverse.png",
"https://i.pinimg.com/736x/8b/08/d0/8b08d0127947da23310a14135b865f61.jpg",
"https://th.bing.com/th/id/R.487fe8708797950ab745a3800c31b7a4?rik=qW3zkDdZfweqmQ&pid=ImgRaw&r=0",
"https://media.discordapp.net/attachments/698162708427833355/1241378744032297041/image.png?ex=6649fb8c&is=6648aa0c&hm=2024b84dc61ec55d1923ff2e517af250d8e63cf599fcc223d014801adfe5b4e0&=&format=webp&quality=lossless&width=416&height=350"
]
function selectProfilePicture(id : number) {
function selectProfilePicture(image : profilePicture) {
showUnsavedChangesAlert();
userStore.user!.profilePictureUrl = profilePictures[id];
userStore.user!.profilepicture = image.url;
}
function saveProfileChanges() {
showingAlert.value = false;
// Hier muss ein request ans Backend geschickt werden, welcher den gesamten User enthält
userStore.updateUser().then((response : responseModel) => {
sendNotification(response.message, true);
});
closeDialog();
}
function toggleSetPrivate() {
userStore.setPrivateForUser(userStore.user!.id, !userStore.user!.isPrivate).then((response : responseModel) => {
userStore.user!.isPrivate = !userStore.user!.isPrivate;
sendNotification(response.message, response.success);
});
}
const logout = () => {
userStore.logout();
};
const showDeleteAccountPopup = ref(false);
const deleteAccount = () => {
showDeleteAccountPopup.value = true;
}
const closeDialog = () => {
internalShowDialog.value = false;
emit('showModal', true);
};
const showUnsavedChangesAlert = () => {
@@ -128,13 +159,19 @@
color: "green",
icon: 'announcement',
actions: [
{ label: 'Änderungen speichern', color: 'white', handler: () => { saveProfileChanges() } },
{ label: 'Speichern', color: 'white', handler: () => { saveProfileChanges() } },
{ label: 'Verwerfen', color: 'white', handler: () => { discardChanges() } },
]
});
showingAlert.value = true;
}
}
const discardChanges = () => {
showingAlert.value = false;
closeDialog();
}
</script>
<style scoped>
@@ -172,4 +209,18 @@
.grid-container > div:nth-child(odd) {
margin-right: 50px;
}
.buttonRed {
color: white;
background: darkred;
}
.buttonRed:hover, .greyButton:hover {
filter: brightness(0.7);
}
.greyButton {
color: white;
background: grey;
}
</style>

View File

@@ -1,8 +0,0 @@
export interface Todo {
id: number;
content: string;
}
export interface Meta {
totalCount: number;
}

View File

@@ -3,8 +3,6 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import EssentialLink, { EssentialLinkProps } from 'components/EssentialLink.vue';
defineOptions({
name: 'MainLayout'

View File

@@ -5,25 +5,57 @@ import App from './App.vue';
import Keycloak from 'keycloak-js';
import {useUserStore} from 'stores/user-store';
import { useChatStore } from 'stores/chat-store';
import { useNotificationStore } from './stores/notification-store';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
const userStore = useUserStore();
const keycloak: Keycloak = await userStore.initKeycloak();
const chatStore = useChatStore();
let keycloak:Keycloak;
userStore.initKeycloak().then(r=> {
keycloak=r;
if (keycloak.subject) {
userStore.id = keycloak.subject;
userStore.getPtpUserById(keycloak.subject).then(() =>
userStore.loadPtpUsersById(keycloak.subject||"").then(user => {
console.log(JSON.stringify(user));
})
);
const interval = setInterval(() => {
if(userStore.userLoaded) {
chatStore.loadProfilePictures();
userStore.loadProfilePictures();
clearInterval(interval);
}
userStore.getPtpUserById(keycloak.subject!);
}, 5000);
Notification.requestPermission().then((result) => {
useNotificationStore().saveDecision(result === "granted");
});
} else {
console.error("hier ist was schlimmes passiert menno");
}
}).catch(e=>console.error(e));
window.onfocus = function() {
useNotificationStore().isTabActive = true;
}
window.onblur = function() {
useNotificationStore().isTabActive = false;
}
// Anfrage ans Backend
if (keycloak.subject) {
userStore.id = keycloak.subject;
await userStore.getPtpUserById(keycloak.subject);
userStore.loadPtpUsersById(keycloak.subject).then(user => {
console.log(JSON.stringify(user));
})
} else {
console.error("hier ist was schlimmes passiert menno");
}

View File

@@ -3,17 +3,10 @@ export interface ptpUser {
firstName: string;
lastName: string;
username: string;
status?: string;
profilePictureUrl?: string;
}
export interface ptpTestUser {
id: string;
firstName: string;
lastName: string;
username: string;
status?: string;
profilePictureUrl?: string;
status: string;
profilepicture: string;
isPrivate: boolean;
nametagColor?: string;
}
export interface responseModel {
@@ -27,20 +20,15 @@ export interface chat{
members: ptpUser[];
messages: message[];
displayName: string;
}
export interface user{
id: string;
firstName: string;
lastName: string;
username: string;
chats: chat[];
profilepicture: string;
groupChat: boolean;
adminIds: string[];
}
export interface message {
id: number;
chat: chat;
sender:user;
sender:ptpUser;
content: string;
timestamp: string;
isOwn: boolean;
@@ -70,3 +58,8 @@ export interface tenorResponseElement {
export interface tenorResponse {
results: Array<tenorGif>,
}
export interface profilePicture {
shortName: string;
url: string;
}

View File

@@ -1,21 +1,22 @@
<template>
<div style="width:100%;display: flex; align-items: center; justify-content: space-between;height:100vh;position: fixed;">
<div class="container">
<!-- Hier gibt es die ganzen PopUps und so -->
<SettingsPopUp :showDialog="settingsDialog"/>
<SettingsPopUp :showDialog="settingsDialog" @showModal="() => settingsDialog = false"/>
<div style="background: gray; width: 25%;height:100%;">
<CreateChatComponent :showDialog="createChatDialog" @showModal="() => createChatDialog = false"></CreateChatComponent>
<div class="menu">
<div style="display: flex; align-items: center; justify-content: space-between;" class="navigation">
<div title="Nach Nutzern suchen" @click="activeMenu = 'search'" :class="{menuItemActive : activeMenu === 'search', menuItemInactive : activeMenu !== 'search'}">
<div title="Nach Nutzern suchen" @click="selectTab('search')" :class="{menuItemActive : activeMenu === 'search', menuItemInactive : activeMenu !== 'search'}" style="border-top-left-radius: 1rem;">
<q-icon name="search"></q-icon>
</div>
<div title="Meine Chats anzeigen" @click="activeMenu = 'chats'" :class="{menuItemActive : activeMenu === 'chats', menuItemInactive : activeMenu !== 'chats'}">
<div title="Meine Chats anzeigen" @click="selectTab('chats')" :class="{menuItemActive : activeMenu === 'chats', menuItemInactive : activeMenu !== 'chats'}">
<q-icon name="chat"></q-icon>
</div>
</div>
<div style="margin: 20px;" v-if="activeMenu == 'search'">
<q-input filled v-model="searchQuery" placeholder="Nach Nutzern suchen">
<template v-slot:prepend>
@@ -23,16 +24,15 @@
</template>
</q-input>
<div v-if="userStore.usersLoaded && searchQuery.length > 0 && searchQuery.replace(/\s/g, '').length" style="margin-top: 20px;">
<div v-if="userStore.usersLoaded && userStore.userLoaded && searchQuery.length > 0 && searchQuery.replace(/\s/g, '').length" style="margin-top: 20px;">
<ProfileCardComponent
v-for="user in userStore.users.filter(u => (u.username.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()) || (u.firstName + ' ' + u.lastName).toLocaleLowerCase().includes(searchQuery.trimStart().toLocaleLowerCase()) || searchQuery == '*') && u.id != userStore.id && searchQuery.replace(/\s/g, '').length)" :displayName="user.firstName + ' ' + user.lastName" :username="user.username"
:profilePictureUrl="user.profilePictureUrl"
:status="user.status"
v-for="user in getFilteredUserList(searchQuery, userStore.users, userStore.user!.id)"
:user="user"
:selected="selectedUser.id == user.id"
@click="selectUser(user as ptpTestUser)"
@click="selectUser(user as ptpUser)"
></ProfileCardComponent>
<div v-if="userStore.users.filter(u => u.username.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()) || (u.firstName + ' ' + u.lastName).toLocaleLowerCase().includes(searchQuery.trimStart().toLocaleLowerCase()) || searchQuery == '*').length == 0 || !searchQuery.replace(/\s/g, '').length" style="text-align: center;">
<div v-if="getFilteredUserList(searchQuery, userStore.users, userStore.user!.id).length == 0" style="text-align: center;">
<img src="https://media.tenor.com/KOZLvzU0o4kAAAAd/no-results.gif" style="border-radius: 1rem;" alt="No search results found">
</div>
</div>
@@ -41,138 +41,94 @@
</div>
<div style="margin: 20px;" v-if="activeMenu == 'chats'">
<q-btn round flat icon="group_add" style="float:right;" @click="createChatDialog = true"></q-btn>
<p>Chats, in denen du Mitglied bist:</p>
<div v-if="chatStore.chatsLoaded">
<div v-for="chat in chatStore.chats" :key="chat.id">
<div v-if="chatStore.chatsLoaded" style="padding-top: 10px;">
<div v-for="chat in chatStore.chats" :key="chat.id" style="margin-top: 10px;">
<ChatCardComponent
v-if="chat.members.length > 2"
v-if="chat.groupChat"
:selected="chat.id == selectedChat.id"
:chat="chat"
@click="selectChat(chat as chat)"
:lastMessage="getChatPreviewMessage(chat.id)"
></ChatCardComponent>
<div v-else>
<h5>Ist ein Chat für zwei</h5>
</div>
<!--<p v-for="member in chat.members">
{{ member.id }}
</p>-->
<!----<ProfileCardComponent
<ProfileCardComponent
v-else
:user="userStore.users.find((user) => user.id === chat.members.find((member) => member.id !== userStore.user!.id)?.id)"
:selected="selectedUser.id == chat.members[0].id"
@click="selectUser(chat.members[0] as ptpTestUser)"
></ProfileCardComponent>-->
:selected="chat.id == selectedChat.id"
@click="selectChat(chat as chat)"
slim
:lastMessage="getChatPreviewMessage(chat.id)"
></ProfileCardComponent>
</div>
</div>
<br>
<q-input
v-model="chatName"
label="Chat Name"
clearable
/>
<div class="q-gutter-y-md" style="max-width: 300px">
<q-input
standout
bottom-slots
v-model="userSearch"
label="Search User"
clearable
filled
color="orange"
@update:model-value=""
>
</q-input>
</div>
<div class="q-pa-lg">
<q-option-group
v-model="usersForChat"
rules="[val => val.length > 0 || 'Please select at least one user']"
:options="userStore.users.filter(u => (u.username.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()) || (u.firstName + ' ' + u.lastName).toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()) || searchQuery == '*') && u.id != userStore.id).map((u) => {
return {label: u.firstName + ' ' + u.lastName, value: u.id}})"
type="checkbox"
color="primary"
inline
>
</q-option-group>
</div>
<q-btn @click="createChat(chatName)" label="Create Chat"/>
</div>
<q-btn
round
flat
v-if="userStore.userLoaded"
:style="{'background-image' : 'url('+userStore.user!.profilePictureUrl+')' }"
style="background-size: cover;background-position: center;position: fixed; left:1rem; bottom: 1rem;"
:style="{'background-image' : 'url('+userStore.user!.profilepicture+')' }"
style="background-size: cover;background-position: center;position: fixed; left:36px; bottom: 30px;"
title="Profil und Einstellungen"
@click="settingsDialog = true"
>
</q-btn>
></q-btn>
</div>
<!-- RECHTE SPALTE -->
<div style="width: 75%;height: 100%;background: #DFD0BF;">
<div class="header" v-if="selectedUser.id != null || selectedChat.id != null" style="padding: 10px;">
<HeaderInfoContainerComponent
v-if="selectedUser.id != null"
:displayName="selectedUser.firstName + ' ' + selectedUser.lastName"
:image="selectedUser.profilePictureUrl"
v-if="!selectedChat.groupChat"
:displayName="selectedChat.members.find((member) => member.id !== userStore.user!.id)?.firstName + ' ' + selectedChat.members.find((member) => member.id !== userStore.user!.id)?.lastName"
:image="selectedChat.members.find((member) => member.id !== userStore.user!.id)?.profilepicture"
></HeaderInfoContainerComponent>
<div v-if="selectedChat.id != null">
<div v-else style="display: flex;">
<HeaderInfoContainerComponent
:displayName="selectedChat.displayName"
image="https://i.pinimg.com/736x/f2/6d/38/f26d38b9685a48e8a43481391f75471c.jpg"
:image="selectedChat.profilepicture"
></HeaderInfoContainerComponent>
</div>
<div>
<p style="overflow: hidden;white-space: nowrap;text-overflow: ellipsis;line-height: 14px;height:50px;margin: 0px;display: table-cell;vertical-align: middle;padding: 10px;max-width: 300px;">
<span>Du</span>
<span v-if="selectedChat.members.length > 1">, </span>
<span v-for="(user, index) in selectedChat.members.filter(u => u.id != userStore.user!.id)">@{{ user.username }}{{ index < selectedChat.members.length - 2 ? ", " : "" }} </span>
</p>
</div>
<q-space />
<q-btn icon="more_vert" round flat @click="chatSettingsDialog = true"></q-btn>
<ChatSettingsComponent :chat="selectedChat" :chatID="selectedChat.id" :showDialog="chatSettingsDialog" @showModal="() => chatSettingsDialog = false" :leaveChat="resetSelectedChat"></ChatSettingsComponent>
</div>
</div>
<div v-else style="text-align: center; margin-top: 10rem;">
<img src="https://media1.tenor.com/m/SpXWQo0Mq7EAAAAd/welcome-michael-scott.gif" style="width:20rem;border-radius: 1rem; box-shadow: 1px 1px 5px 0 rgba(0,0,0,0.15);">
<div class="text-h6">Wähle einen Chat aus</div>
</div>
<div v-if="selectedUser.id != null" id="messageContainer" style="padding: 25px;overflow-y: auto;height: 90%;display: flex;flex-direction: column-reverse;">
<div v-if="!hasChatWith(selectedUser.id)" style="width: 100%; text-align: center;font-size: 1.2rem;">
<p>Das ist der Beginn deines Chats mit <strong>{{selectedUser.firstName}} {{selectedUser.lastName}}</strong></p>
<div v-if="selectedChat.id != null" class="main" style="overflow-y: auto;display: flex;flex-direction: column-reverse;">
<div v-if="selectedChat.id == -1" style="text-align: center;">
<div class="text-h5" style="font-weight: normal;">Das ist der Beginn deines Chats mit <b>{{ selectedChat.displayName }}</b></div>
</div>
<div v-else>
<p>Du hast schonmal mit dem geschrieben wie schön</p>
<div v-for="(message, index) in messages" :key="index">
<MessageComponent
v-if="message.gif"
:url="message.content"
:timestamp="message.timestamp"
:own-message="message.isOwn"
gif
></MessageComponent>
<MessageComponent
v-if="!message.gif && message.link"
:url="message.content"
:timestamp="message.timestamp"
:own-message="message.isOwn"
link
></MessageComponent>
<MessageComponent
v-if="!message.gif && !message.link"
:message="message.content"
:timestamp="message.timestamp"
:own-message="message.isOwn"
></MessageComponent>
</div>
</div>
<MessageComponent
v-for="(message, index) in (messageStore.messages[selectedChat.id])"
:key="index"
:message="message"
:nametagColor="!chatStore.userColors[message.sender.username] ? 'black' : chatStore.userColors[message.sender.username]"
:own-message="message.sender.id == userStore.user!.id"
:showSender="selectedChat.groupChat && (index == messageStore.messages[selectedChat.id].length - 1 || messageStore.messages[selectedChat.id][index+1].sender!.id != message.sender.id)"
></MessageComponent>
</div>
<div class="footer">
<q-input filled v-model="message" placeholder="Nachricht eingeben" tabindex="0" @keyup.enter.prevent="sendMessage">
<q-input filled v-model="message" placeholder="Nachricht eingeben" tabindex="0" @keyup.enter.prevent="sendMessage" :disable="!selectedChat.id">
<template v-slot:after>
<q-btn round dense flat icon="gif_box" id="sendMessageBtn" style="font-size: 1.2rem;" @click="toggleGifMenu"></q-btn>
</template>
@@ -206,23 +162,26 @@
</div>
</div>
<!-- GIF SUCHE ENDE -->
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ref } from 'vue';
import MessageComponent from 'components/MessageComponent.vue';
import { useTenorStore } from 'stores/tenor-store';
import ProfileCardComponent from 'components/ProfileCardComponent.vue';
import ChatCardComponent from 'components/ChatCardComponent.vue';
import HeaderInfoContainerComponent from 'components/HeaderInfoContainerComponent.vue';
import {message, ptpTestUser} from 'src/models';
import type {chat} from 'src/models';
import type {message} from 'src/models';
import type {chat, ptpUser} from 'src/models';
import {useUserStore} from 'stores/user-store';
import { useChatStore } from 'stores/chat-store';
import SettingsPopUp from 'src/components/SettingsPopUp.vue';
import ChatSettingsComponent from 'src/components/ChatSettingsComponent.vue';
import CreateChatComponent from 'src/components/CreateChatComponent.vue';
import { useMessageStore } from 'src/stores/message-store';
import { sendNotification, getFilteredUserList } from 'src/utils';
import { Notify } from 'quasar';
defineOptions({
name: 'IndexPage'
@@ -235,81 +194,72 @@
const searchQuery = ref("")
const message = ref("");
const gifSearch = ref("");
const chatName = ref('');
const userSearch = ref('');
const usersForChat = ref([] as string[]);
const messageStore = useMessageStore();
const settingsDialog = ref(false);
const chatSettingsDialog = ref(false);
const createChatDialog = ref(false);
const showGifMenu = ref(false);
// Speichert, ob der Suchbegriff im GIF-Menü sich geändert hat
// Wenn nicht, wird keine neue Anfrage an Tenor geschickt
const gifSearchQueryChanged = ref(true);
const activeMenu = ref("search");
const selectedUser = ref({} as ptpUser);
const selectedChat = ref({} as chat);
let loadedGifsAmount = 10;
let messages : message[] = [
// {content: "Halllo", timestamp: "13:38", isOwn: true, gif: false, link: false},
// {content: "https://www.google.com/", timestamp: "13:20", isOwn: false, gif: false, link: false},
// {content: "Halllo", timestamp: "13:15", isOwn: false, gif: false, link: false},
// {content: "Halllo", timestamp: "13:14", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:13", isOwn: true, gif: false, link: false},
// {content: "https://media.tenor.com/mtiOW6O-k8YAAAAd/shrek-shrek-rizz.gif", timestamp: "13:11", isOwn: false, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Halllo", timestamp: "13:10", isOwn: true, gif: false, link: false},
// {content: "Hallo", timestamp: "13:10", isOwn: true, gif: false, link: false}
];
function isUrl(url : string) {
try {
new URL(url);
return true;
} catch (err) {
return false;
}
const selectTab = (tab : string) => {
activeMenu.value = tab;
getMessages();
}
function isTenorGifUrl(url : string) {
return url.startsWith("https://media.tenor.com/") && url.endsWith(".gif");
const getChatPreviewMessage = (chatId : number) => {
const messageArray : message[] = messageStore.messages[chatId];
}
messages.forEach((msg) => {
if(isTenorGifUrl(msg.content)) {
msg.gif = true;
} else if(isUrl(msg.content)) {
msg.link = true;
if(messageArray && messageArray.length > 0) {
const newestMessage = messageArray[0];
let messageContent = newestMessage.content;
let userDescriptor = newestMessage.sender.firstName;
if(newestMessage.gif) {
messageContent = "GIF";
}
if(newestMessage.sender.id == userStore.user!.id) {
userDescriptor = "Du";
}
return userDescriptor + ": " + messageContent;
} else {
return "Der Chat wurde erstellt";
}
})
const selectedGifURL = ref("");
const showGifMenu = ref(false);
const changed = ref(true);
}
function inputChanged() {
changed.value = true;
gifSearchQueryChanged.value = true;
}
function getGifs() {
let query = gifSearch.value != "" ? gifSearch.value : "shrek";
let query = gifSearch.value != "" ? gifSearch.value : "the office";
if(changed.value) {
if(gifSearchQueryChanged.value) {
tenorStore.getGifsBySearchTerm(query, loadedGifsAmount);
changed.value = false;
gifSearchQueryChanged.value = false;
}
}
function toggleGifMenu() {
if(!selectedChat.value.id) return;
showGifMenu.value = !showGifMenu.value;
if(showGifMenu.value) {
// Somehow focus the input field
@@ -318,74 +268,130 @@
}
function sendMessage() {
//messages.unshift({content: message.value, timestamp: new Date().toTimeString().split(' ')[0].substring(0, 5), isOwn: true, gif: false, link: false});
if(message.value == "") {
sendNotification("Nachricht kann nicht leer sein", false);
return;
}
if(selectedChat.value.id != -1) {
messageStore.sendMessage(selectedChat.value.id, userStore.user!.id, message.value);
} else {
messageStore.createChatAndSendFirstMessage(selectedUser.value.id,userStore.user!.id, message.value).then((response) => {
reloadChats().then(() => {
activeMenu.value = 'chats';
selectChat(chatStore.chats.find((chat) => chat.id == response)!);
});
});
}
message.value = "";
}
function sendGif(url : string) {
//messages.unshift({content: url, timestamp: new Date().toTimeString().split(' ')[0].substring(0, 5), isOwn: true, gif: true, link: false});
messageStore.sendMessage(selectedChat.value.id, userStore.user!.id, url);
toggleGifMenu();
}
function loadMoreGifs() {
loadedGifsAmount += 10;
console.log(loadedGifsAmount);
changed.value = true;
gifSearchQueryChanged.value = true;
}
function getMessages() {
for(let chat of chatStore.chats) {
messageStore.loadMessagesByChatID(chat.id);
}
}
const reloadChats = async() => {
await chatStore.loadChatsByUser();
};
const updateChatList = () => {
chatStore.loadChatsByUser().then(() => {
// Hier wird geprüft, ob man immer noch Mitglied des momentan ausgewählten Chats ist
if(chatStore.chats.find((chat) => chat.id == selectedChat.value.id) == null && selectedChat.value.id && selectedChat.value.groupChat) {
Notify.create({
message: "Der Chat '"+selectedChat.value.displayName+"' existiert nicht mehr, oder du bist kein Teil mehr davon",
position: 'top',
color: "red",
icon: "warning",
actions: [
{ icon: 'close', color: 'white', round: true, handler: () => { /* ... */ } }
]
});
chatSettingsDialog.value = false;
selectedChat.value = {} as chat;
}
});
}
setInterval(getMessages, 5000);
setInterval(updateChatList, 5000);
setInterval(getGifs, 500);
userStore.getAllPtpUsers();
const createChat = async(chatName: string)=>{
if(chatName == ""){
chatName = "Chat";
}
await chatStore.createChat(chatName, usersForChat.value);
loadChats();
}
const loadChats = async() => {
await chatStore.loadChatsByUser();
};
const selectedUser = ref({} as ptpTestUser);
const selectedChat = ref({} as chat);
function selectUser(user : ptpTestUser) {
selectedUser.value = user;
selectedChat.value = {} as chat;
const resetSelectedChat = () => {
selectedChat.value = {} as chat
}
function selectUser(user : ptpUser) {
// Hier wird zuerst geprüft, ob es bereits einen Chat mit dem Nutzer gibt
// Wenn ja, wird dieser Chat ausgewählt
// Wenn nein, wird ein Dummy-Objekt erstellt
let chatExists = false;
chatStore.chats.forEach((chat) => {
if(!chat.groupChat && chat.members.find((member) => member.id == user.id)) {
selectedChat.value = chat;
getMessages();
chatExists = true;
}
});
if(!chatExists) {
selectedUser.value = user;
const exampleChat: chat = {
id: -1,
members: [user, userStore.user!],
messages: [],
displayName: user.firstName + " " + user.lastName,
profilepicture: user.profilepicture,
groupChat: false,
adminIds: []
};
selectedChat.value = exampleChat;
}
}
function selectChat(chat : chat) {
selectedChat.value = chat;
selectedUser.value = {} as ptpTestUser;
}
selectedUser.value = {} as ptpUser;
function hasChatWith(id : string) : boolean {
// Hier wird geprüft, ob es ein Chat mit einem bestimmten anderen Nutzer existiert
const chatExists = chatStore.chats.find((chat) => chat.members.length === 2 && chat.members.find((member) => member.id === id));
if(messageStore.messages[chat.id] == null) {
messageStore.messages[chat.id] = [] as message[];
}
if(chatExists) return true;
return false;
messageStore.loadMessagesByChatID(chat.id);
}
// Init methods
loadChats();
const user = ref();
onMounted(() => {
//alert("mounted");
user.value = userStore.user;
//alert(user.value.firstName);
})
</script>
<style scoped>
@@ -408,24 +414,6 @@
filter: brightness(0.5);
}
.footer {
width: 75%;
height:auto;
background: gray;
position: absolute;
bottom:0;
padding: 10px;
}
.header {
width: 75%;
height: auto;
background: gray;
position: fixed;
right: 0;
top: 0;
}
.navigation {
font-size: 2rem;
}
@@ -442,6 +430,10 @@
transition: .3s;
}
.navigation:nth-child(1) {
border-radius: 1rem;
}
.menuItemActive {
border-bottom: 2px solid black;
}
@@ -454,4 +446,55 @@
font-weight: bold;
margin-bottom: 0;
}
/* CSS GRID THINGS */
.container {
display: grid;
color: #111111;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"menu header"
"menu main"
"menu footer";
height: 97vh;
background: #FFF5E1;
margin: 1.5vh 20px;
border-radius: var(--defaultGridBorderRadius);
}
.menu {
grid-area: menu;
background-color: var(--defaultElementBackground);
color: black;
border-radius: 1rem 0 0 1rem;
width: 30rem;
}
.header {
grid-area: header;
background-color: var(--defaultElementBackground);
color: black;
padding: 10px;
border-top-right-radius: 1rem;
margin-bottom: 0;
}
.main {
grid-area: main;
background-color: transparent;
padding: 20px;
overflow-y: auto;
border-radius: var(--defaultGridBorderRadius);
margin-bottom: 0;
}
.footer {
grid-area: footer;
background-color: var(--defaultElementBackground);
color: black;
padding: 10px;
border-bottom-right-radius: 1rem;
}
</style>

View File

@@ -1,13 +1,22 @@
import { defineStore } from 'pinia';
import {chat, responseModel} from 'src/models';
import {chat, profilePicture, ptpUser, responseModel} from 'src/models';
import { api } from 'src/boot/axios';
import { useUserStore } from 'stores/user-store';
import { useQuasar } from 'quasar';
import { getRandomHexColor } from 'src/utils';
interface StringKeyValue {
[key: string]: string;
}
export const useChatStore = defineStore('chatStore',{
state:() => ({
chats: [] as chat[],
chatsLoaded: false,
chatExistsReponse: false,
profilePictures : [] as profilePicture[],
profilePicturesLoaded: false,
userColors: {} as StringKeyValue
}),
actions: {
async getChatsByUser(): Promise<chat[]> {
@@ -18,10 +27,25 @@ export const useChatStore = defineStore('chatStore',{
},
async loadChatsByUser() : Promise<chat[]>{
this.chats = (await api.get("/users/chat/" + useUserStore().id)).data.response;
this.chats.forEach((c) => {
c.members.forEach((member) => {
if(!this.userColors[member.username]){
this.userColors[member.username] = getRandomHexColor();
}
})
});
this.chatsLoaded = true;
return this.chats;
},
async createChat(chatName: string, members: string[]){
async loadProfilePictures(){
(await api.get("/chats/profilePictures")).data.forEach((element: string) => {
this.profilePictures.push({shortName: element.split(" ")[0].replace(":", ""), url: element.split(" ")[1]});
});
this.profilePicturesLoaded = true;
},
async createChat(chatName: string, members: string[], keycloakID: string){
const userStore = useUserStore();
if(userStore.id) {
if (!members.includes(userStore.id)) {
@@ -33,14 +57,42 @@ export const useChatStore = defineStore('chatStore',{
}
}
console.log(members);
return (await api.post("/chats/create/" + chatName, members)).data;
return (await api.post("/chats/create/" + chatName + "/" + keycloakID, members)).data;
},
async loadDoesChatExist(keycloakID : string, otherKeycloakID : string) : Promise<responseModel> {
return (await api.get("/chats/"+keycloakID+"/"+otherKeycloakID)).data;
async updateChat(chat : chat) {
const updatedChat : chat = {...chat};
console.log("UpdatedChatUrl: " + updatedChat.profilepicture);
this.profilePictures.forEach((element: profilePicture) => {
if(element.url === updatedChat.profilepicture){
updatedChat.profilepicture = element.shortName;
}
});
return (await api.post("/chats/update", updatedChat)).data;
},
doesChatExist(keycloakID : string, otherKeycloakID : string) {
this.loadDoesChatExist(keycloakID, otherKeycloakID)
.then(m => this.chatExistsReponse = m.success);
async addAdmin(chatID: number, keycloakID: string){
return (await api.post("/chats/addAdmin/"+chatID+"/"+keycloakID)).data;
},
async removeAdmin(chatID: number, keycloakID: string){
return (await api.post("/chats/removeAdmin/"+chatID+"/"+keycloakID)).data;
},
async removeUserFromChat(keycloakID : string, chatID : number) {
return (await api.delete("/chats/removeUser/"+chatID+"/"+keycloakID)).data;
},
async addUserToChat(keycloakID : string, chatID : number) {
return (await api.post("/chats/addUser/"+chatID+"/"+keycloakID)).data;
},
async deleteChat(chatID : number) {
return (await api.delete("/chats/"+chatID)).data;
}
}
});

View File

@@ -1,15 +0,0 @@
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
counter: 0,
}),
getters: {
doubleCount: (state) => state.counter * 2,
},
actions: {
increment() {
this.counter++;
},
},
});

View File

@@ -0,0 +1,64 @@
import { defineStore } from 'pinia';
import { api } from 'src/boot/axios';
import { message } from 'src/models';
import { useNotificationStore } from './notification-store';
import { useUserStore } from './user-store';
export const useMessageStore = defineStore('messageStore', {
state: () => ({
messages: [[]] as [message[]],
loadedMessagesForChats: [] as number[],
}),
actions: {
async sendMessage(chatId : number, senderId : string, content : string) {
const config = { headers: {'Content-Type': 'application/json'} };
await api.post("/messages/"+chatId+"/"+senderId, content, config).then((result) => {
this.loadMessagesByChatID(chatId);
});
},
async createChatAndSendFirstMessage(receiverId : string, senderId : string, content : string) {
const config = { headers: {'Content-Type': 'application/json'} };
return (await api.post("/messages/dm/"+senderId+"/"+receiverId, content, config).then((response) => {
this.loadMessagesByChatID(response.data.id);
return response.data.id;
}));
},
loadMessagesByChatID(chatID : number) {
const notificationStore = useNotificationStore();
const userStore = useUserStore();
const initialLoad = !this.loadedMessagesForChats.includes(chatID);
this.getAllMessagesByChatID(chatID).then((responseBody) => {
responseBody.response.forEach((message : message) => {
message.content = message.content.substring(1, message.content.length-1);
if(!this.loadedMessagesForChats.includes(chatID)){
this.messages[chatID] = [];
this.loadedMessagesForChats.push(chatID);
}
if(!initialLoad){
if(message.sender.id !== userStore.user!.id) {
notificationStore.sendNotificationIfAllowed(message.sender.profilepicture, message.sender.username, message.content);
}
}
this.messages[chatID].unshift(message);
});
});
},
async getAllMessagesByChatID(chatID : number) {
let timestamp = "0";
if(this.loadedMessagesForChats.includes(chatID)){
timestamp = this.messages[chatID][0].timestamp;
}
return (await api.get("/messages/"+chatID+"/"+timestamp)).data;
},
},
});

View File

@@ -0,0 +1,19 @@
import { defineStore } from 'pinia';
export const useNotificationStore = defineStore('notificationStore',{
state:() => ({
decision: false,
isTabActive: true,
}),
actions: {
saveDecision(decision : boolean) {
this.decision = decision;
},
sendNotificationIfAllowed(image : string, headline : string, message : string) {
if(this.decision && !this.isTabActive){
new Notification(headline, { body: message, icon: image });
}
}
}
});

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { api } from 'src/boot/axios';
import { ptpUser, responseModel } from 'src/models';
import { profilePicture, ptpUser, responseModel } from 'src/models';
import Keycloak, { KeycloakInitOptions, KeycloakLoginOptions } from 'keycloak-js';
/**
@@ -34,50 +34,59 @@ export const useUserStore = defineStore('userStore', {
usersLoaded: false,
keycloak: null as Keycloak | null,
id: "",
reconnectTries: 0,
profilePictures: [] as profilePicture[],
profilePicturesLoaded: false
}),
actions: {
async getPtpUserById(id : string) {
await this.loadPtpUsersById(id).then((response : responseModel) => {
this.user = response.response;
this.userLoaded = true;
if(this.user!.profilePictureUrl == null) {
this.user!.profilePictureUrl = "https://th.bing.com/th/id/R.487fe8708797950ab745a3800c31b7a4?rik=qW3zkDdZfweqmQ&pid=ImgRaw&r=0";
}
if(this.user!.status == null) {
this.user!.status = "Hey there, I am using ptpChat!";
}
}).catch(error => {
this.reconnectTries++;
});
},
async loadPtpUsersById(id : string) : Promise<responseModel> {
return (await api.get("/users/login/"+id)).data;
},
async loadProfilePictures() {
(await api.get("/users/profilePictures")).data.forEach((element: string) => {
this.profilePictures.push({ shortName: element.split(" ")[0].replace(":", ""), url: element.split(" ")[1]});
});
this.profilePicturesLoaded = true;
},
getAllPtpUsers() {
this.loadAllPtpUsers().then((response : ptpUser[]) => {
this.users = response;
this.users.forEach((user : ptpUser) => {
if(user.profilePictureUrl == null) {
user.profilePictureUrl = "https://th.bing.com/th/id/R.487fe8708797950ab745a3800c31b7a4?rik=qW3zkDdZfweqmQ&pid=ImgRaw&r=0";
}
if(user.status == null) {
user.status = "Hey there, I am using ptpChat!";
}
})
this.usersLoaded = true;
})
this.usersLoaded = true;
},
async loadAllPtpUsers() {
return (await api.get("/users/user")).data;
return (await api.get("/users")).data;
},
async updateUser() {
const updatedUser : ptpUser = {...this.user!};
this.profilePictures.forEach((element: profilePicture) => {
if(element.url === this.user!.profilepicture){
updatedUser.profilepicture = element.shortName;
}
});
return (await api.post("/users/update", updatedUser)).data;
},
async setPrivateForUser(keycloakId : string, isPrivate : boolean) {
return (await api.post("/users/setPrivate/"+keycloakId+"/"+isPrivate)).data;
},
async initKeycloak() : Promise<Keycloak>{
try {
await keycloak.init(initOptions).then( (auth: boolean): void => {
console.log("Authenticated: " + auth);
@@ -88,13 +97,16 @@ export const useUserStore = defineStore('userStore', {
this.keycloak = keycloak;
return keycloak;
},
async login() {
if(!this.keycloak){
await this.initKeycloak();
}else await this.keycloak?.login( {prompt: 'login'} as KeycloakLoginOptions);
},
async logout() {
window.location.href= 'http://localhost:' + keycloakPort + '/realms/' + realm + '/protocol/openid-connect/logout?post_logout_redirect_uri=http%3A%2F%2Flocalhost:' + frontendPort + '&client_id=' + clientId;
},
},
});

61
src/utils.ts Normal file
View File

@@ -0,0 +1,61 @@
import { Notify } from "quasar";
import { ptpUser } from "./models";
export const sendNotification = (message : string, successful : Boolean) => {
Notify.create({
message: message,
position: 'top',
timeout: 2000,
color: successful ? "green" : "red",
icon: successful ? "check" : "warning"
});
}
export const isToday = (date : Date) => {
const today = new Date();
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
}
export const getDaysOffset = (date1: Date, date2: Date) => {
// Berechne die Differenz in Millisekunden
const diffInMillis = date2.getTime() - date1.getTime();
// Eine Konstante für die Anzahl der Millisekunden in einem Tag
const millisInDay = 1000 * 60 * 60 * 24;
// Berechne die Differenz in vollen Tagen und runde ab
return Math.floor(diffInMillis / millisInDay);
}
export const getRandomHexColor2 = () => {
const hexChars = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(Math.random() * 16)];
}
return color;
}
export const hslToHex = (h : number, s: number, l : number) => {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n : number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0'); // Convert to Hex and pad with zeroes if necessary
};
return `#${f(0)}${f(8)}${f(4)}`;
}
export const getRandomHexColor = () => {
const hue = Math.floor(Math.random() * 360); // Random hue (0-360)
const saturation = 100; // Full saturation
const lightness = 30; // Adjust this value to set the desired lightness (0-100)
return hslToHex(hue, saturation, lightness);
}
export const getFilteredUserList = (searchQuery : string, userList : ptpUser[], ownID : string) => {
return userList.filter(u => (u.username.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()) || (u.firstName + ' ' + u.lastName).toLocaleLowerCase().includes(searchQuery.trimStart().toLocaleLowerCase()) || searchQuery == '*') && u.id != ownID && searchQuery.replace(/\s/g, '').length);
}