diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86b97b2 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/README.md b/README.md index 2e72611..b8cda73 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/App.vue b/src/App.vue index 31388fe..acd40df 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,46 +1,78 @@ diff --git a/src/components/ChatCardComponent.vue b/src/components/ChatCardComponent.vue index b677429..f084963 100644 --- a/src/components/ChatCardComponent.vue +++ b/src/components/ChatCardComponent.vue @@ -1,48 +1,63 @@ diff --git a/src/components/ChatSettingsComponent.vue b/src/components/ChatSettingsComponent.vue new file mode 100644 index 0000000..24218ec --- /dev/null +++ b/src/components/ChatSettingsComponent.vue @@ -0,0 +1,308 @@ + + + + + \ No newline at end of file diff --git a/src/components/CreateChatComponent.vue b/src/components/CreateChatComponent.vue new file mode 100644 index 0000000..d53909d --- /dev/null +++ b/src/components/CreateChatComponent.vue @@ -0,0 +1,166 @@ + + + + + \ No newline at end of file diff --git a/src/components/EssentialLink.vue b/src/components/EssentialLink.vue deleted file mode 100644 index 0075b5c..0000000 --- a/src/components/EssentialLink.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/src/components/ExampleComponent.vue b/src/components/ExampleComponent.vue deleted file mode 100644 index 6fd2d2a..0000000 --- a/src/components/ExampleComponent.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/src/components/HeaderInfoContainerComponent.vue b/src/components/HeaderInfoContainerComponent.vue index 856183a..cd0c258 100644 --- a/src/components/HeaderInfoContainerComponent.vue +++ b/src/components/HeaderInfoContainerComponent.vue @@ -4,7 +4,7 @@ :style="{'background-image' : 'url('+props.image+')' }" class="image" > -
{{ props.displayName }}
+
{{ props.displayName }}
@@ -19,6 +19,10 @@ .displayName { height: 100%; margin: 0.5rem 1rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + width: 200px; } .image { diff --git a/src/components/MessageComponent.vue b/src/components/MessageComponent.vue index ee8b378..7f1efbe 100644 --- a/src/components/MessageComponent.vue +++ b/src/components/MessageComponent.vue @@ -1,22 +1,46 @@ @@ -24,7 +48,8 @@ diff --git a/src/components/models.ts b/src/components/models.ts deleted file mode 100644 index 6945920..0000000 --- a/src/components/models.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface Todo { - id: number; - content: string; -} - -export interface Meta { - totalCount: number; -} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index a1f7679..4abdc59 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -3,8 +3,6 @@ + + /* 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; + } + \ No newline at end of file diff --git a/src/stores/chat-store.ts b/src/stores/chat-store.ts index 98876d1..d6db580 100644 --- a/src/stores/chat-store.ts +++ b/src/stores/chat-store.ts @@ -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 { @@ -18,10 +27,25 @@ export const useChatStore = defineStore('chatStore',{ }, async loadChatsByUser() : Promise{ 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 { - 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; } } }); diff --git a/src/stores/example-store.ts b/src/stores/example-store.ts deleted file mode 100644 index 83e8390..0000000 --- a/src/stores/example-store.ts +++ /dev/null @@ -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++; - }, - }, -}); diff --git a/src/stores/message-store.ts b/src/stores/message-store.ts new file mode 100644 index 0000000..63a052a --- /dev/null +++ b/src/stores/message-store.ts @@ -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; + }, + }, +}); \ No newline at end of file diff --git a/src/stores/notification-store.ts b/src/stores/notification-store.ts new file mode 100644 index 0000000..22427a6 --- /dev/null +++ b/src/stores/notification-store.ts @@ -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 }); + } + } + } +}); diff --git a/src/stores/user-store.ts b/src/stores/user-store.ts index accb002..cf50b15 100644 --- a/src/stores/user-store.ts +++ b/src/stores/user-store.ts @@ -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 { 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{ - 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; }, + }, }); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..984dc58 --- /dev/null +++ b/src/utils.ts @@ -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); +} \ No newline at end of file