added questions, reply and like functionality"

main
alex 2024-09-12 20:06:10 +02:00
parent 679ee5bf28
commit 9be79b1481
14 changed files with 757 additions and 530 deletions

View File

@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>App</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,45 +1,67 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from "@reduxjs/toolkit";
import { CachedUser } from "core/types/userProfile";
export const appSlice = createSlice({ export const appSlice = createSlice({
name: 'app', name: "app",
initialState: { initialState: {
darkMode: false, darkMode: false,
userAuthenticated: null, userAuthenticated: null,
userProfilePictureUrl: null, userProfilePictureUrl: null,
primaryColor: '#111', primaryColor: "#111",
logoUrl: null, logoUrl: null,
bannerUrl: null, bannerUrl: null,
cachedUsers: {} as Record<string, CachedUser>,
},
reducers: {
setDarkMode: (state, action) => {
state.darkMode = action.payload;
}, },
reducers: { setUserAuthenticated: (state, action) => {
setDarkMode: (state, action) => { state.userAuthenticated = action.payload;
state.darkMode = action.payload;
},
setUserAuthenticated: (state, action) => {
state.userAuthenticated = action.payload;
},
setUserProfilePictureUrl: (state, action) => {
state.userProfilePictureUrl = action.payload;
},
setPrimaryColor: (state, action) => {
state.primaryColor = action.payload;
},
setLogoUrl: (state, action) => {
state.logoUrl = action.payload;
},
setBannerUrl: (state, action) => {
state.bannerUrl = action.payload;
},
}, },
selectors: { setUserProfilePictureUrl: (state, action) => {
darkMode: (state) => state.darkMode, state.userProfilePictureUrl = action.payload;
userAuthenticated: (state) => state.userAuthenticated,
userProfilePictureUrl: (state) => state.userProfilePictureUrl,
primaryColor: (state) => state.primaryColor,
logoUrl: (state) => state.logoUrl,
bannerUrl: (state) => state.bannerUrl,
}, },
setPrimaryColor: (state, action) => {
state.primaryColor = action.payload;
},
setLogoUrl: (state, action) => {
state.logoUrl = action.payload;
},
setBannerUrl: (state, action) => {
state.bannerUrl = action.payload;
},
addUserToCache: (state, action) => {
state.cachedUsers[action.payload.id] = action.payload;
},
},
selectors: {
darkMode: (state) => state.darkMode,
userAuthenticated: (state) => state.userAuthenticated,
userProfilePictureUrl: (state) => state.userProfilePictureUrl,
primaryColor: (state) => state.primaryColor,
logoUrl: (state) => state.logoUrl,
bannerUrl: (state) => state.bannerUrl,
cachedUsers: (state) => state.cachedUsers,
},
}); });
export const { setDarkMode, setUserAuthenticated, setUserProfilePictureUrl, setPrimaryColor, setLogoUrl, setBannerUrl } = appSlice.actions; export const {
setDarkMode,
setUserAuthenticated,
setUserProfilePictureUrl,
setPrimaryColor,
setLogoUrl,
setBannerUrl,
addUserToCache,
} = appSlice.actions;
export const { darkMode, userAuthenticated, userProfilePictureUrl, primaryColor, logoUrl, bannerUrl } = appSlice.selectors; export const {
darkMode,
userAuthenticated,
userProfilePictureUrl,
primaryColor,
logoUrl,
bannerUrl,
cachedUsers,
} = appSlice.selectors;

View File

@ -0,0 +1,30 @@
import { useDispatch, useSelector } from "react-redux";
import { useGetUserQuery } from "./userProfile";
import { addUserToCache, cachedUsers } from "core/reducers/appSlice";
import { CachedUser } from "core/types/userProfile";
import { useEffect } from "react";
export function useCachedUser(userId: string) {
const dispatch = useDispatch();
const cachedUser = useSelector(cachedUsers)[userId] as CachedUser;
const {
data: user,
error,
isLoading,
} = useGetUserQuery(userId, {
skip: !!cachedUser, // Skip the query if the user is already cached
});
useEffect(() => {
if (user) {
dispatch(addUserToCache(user));
}
}, [user]);
return {
user: cachedUser || user,
error,
isLoading,
};
}

View File

@ -5,6 +5,7 @@ import {
LessonContent, LessonContent,
LessonQuestion, LessonQuestion,
LessonSettings, LessonSettings,
QuestionsResponse,
UpdateLessonPreviewThumbnail, UpdateLessonPreviewThumbnail,
} from "core/types/lesson"; } from "core/types/lesson";
@ -94,12 +95,42 @@ export const lessonsApi = createApi({
method: "DELETE", method: "DELETE",
}), }),
}), }),
getQuestions: builder.query<LessonQuestion[], string>({ getLessonQuestions: builder.query<QuestionsResponse, string>({
query: (lessonId) => ({ query: (lessonId) => ({
url: `lessons/${lessonId}/questions`, url: `lessons/${lessonId}/questions`,
method: "GET", method: "GET",
}), }),
}), }),
createLessonQuestion: builder.mutation({
query: ({ lessonId, message }) => ({
url: `lessons/${lessonId}/questions`,
method: "POST",
body: {
Message: message,
},
}),
}),
createLessonQuestionReply: builder.mutation({
query: ({ lessonId, questionId, message }) => ({
url: `lessons/${lessonId}/questions/${questionId}/replies`,
method: "POST",
body: {
Message: message,
},
}),
}),
likeQuestion: builder.mutation({
query: ({ lessonId, questionId }) => ({
url: `lessons/${lessonId}/questions/${questionId}/likes`,
method: "POST",
}),
}),
dislikeQuestion: builder.mutation({
query: ({ lessonId, questionId }) => ({
url: `lessons/${lessonId}/questions/${questionId}/likes`,
method: "DELETE",
}),
}),
}), }),
}); });
@ -115,4 +146,9 @@ export const {
useUpdateLessonContentMutation, useUpdateLessonContentMutation,
useUpdateLessonContentPositionMutation, useUpdateLessonContentPositionMutation,
useDeleteLessonContentMutation, useDeleteLessonContentMutation,
useGetLessonQuestionsQuery,
useCreateLessonQuestionMutation,
useCreateLessonQuestionReplyMutation,
useLikeQuestionMutation,
useDislikeQuestionMutation,
} = lessonsApi; } = lessonsApi;

View File

@ -1,6 +1,6 @@
import { createApi } from "@reduxjs/toolkit/query/react"; import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithErrorHandling } from "core/helper/api"; import { baseQueryWithErrorHandling } from "core/helper/api";
import { UserProfile } from "core/types/userProfile"; import { CachedUser, UserProfile } from "core/types/userProfile";
export const userProfileApi = createApi({ export const userProfileApi = createApi({
reducerPath: "userProfileApi", reducerPath: "userProfileApi",
@ -12,7 +12,13 @@ export const userProfileApi = createApi({
method: "GET", method: "GET",
}), }),
}), }),
getUser: builder.query<CachedUser, string>({
query: (userId) => ({
url: `user/${userId}`,
method: "GET",
}),
}),
}), }),
}); });
export const { useGetUserProfileQuery } = userProfileApi; export const { useGetUserProfileQuery, useGetUserQuery } = userProfileApi;

View File

@ -1,255 +1,312 @@
import { Dispatch } from '@reduxjs/toolkit'; import { Dispatch } from "@reduxjs/toolkit";
import { setBannerUrl, setLogoUrl, setPrimaryColor, setUserProfilePictureUrl } from 'core/reducers/appSlice';
import { store } from 'core/store/store';
import { BrowserTabSession, Constants } from 'core/utils/utils';
import { WebSocketReceivedMessagesCmds } from 'core/utils/webSocket';
import { addLessonPageContent, deleteLessonPageContent, updateLessonPageContent, updateLessonPageContentPosition } from 'features/Lessons/LessonPage/lessonPageSlice';
import { import {
addLessonContent, setBannerUrl,
deleteLessonContent, setLogoUrl,
setLessonThumbnailTitle, setPrimaryColor,
setLessonThumbnailUrl, setUserProfilePictureUrl,
setPageEditorLessonState, } from "core/reducers/appSlice";
updateLessonContent, import { store } from "core/store/store";
updateLessonContentPosition, import { BrowserTabSession, Constants } from "core/utils/utils";
} from 'features/Lessons/LessonPageEditor/lessonPageEditorSlice'; import { WebSocketReceivedMessagesCmds } from "core/utils/webSocket";
import { addLesson, updateLessonPreviewThumbnail, updateLessonPreviewTitle, updateLessonState } from 'features/Lessons/lessonsSlice'; import {
import { addTeamMember, deleteTeamMember, updateTeamMemberRole } from 'features/Team/teamSlice'; addLessonPageContent,
import { setProfilePictureUrl } from 'features/UserProfile/userProfileSlice'; deleteLessonPageContent,
updateLessonPageContent,
updateLessonPageContentPosition,
} from "features/Lessons/LessonPage/lessonPageSlice";
import {
addLessonContent,
deleteLessonContent,
setLessonThumbnailTitle,
setLessonThumbnailUrl,
setPageEditorLessonState,
updateLessonContent,
updateLessonContentPosition,
} from "features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import {
addLesson,
updateLessonPreviewThumbnail,
updateLessonPreviewTitle,
updateLessonState,
} from "features/Lessons/lessonsSlice";
import {
addLikedQuestion,
addQuestion,
countDownLikedQuestion,
countUpLikedQuestion,
deleteLikedQuestion,
} from "features/Lessons/Questions/lessonQuestionSlice";
import {
addTeamMember,
deleteTeamMember,
updateTeamMemberRole,
} from "features/Team/teamSlice";
import { setProfilePictureUrl } from "features/UserProfile/userProfileSlice";
interface WebSocketMessage { interface WebSocketMessage {
Cmd: number; Cmd: number;
Body: any; Body: any;
} }
class WebSocketService { class WebSocketService {
private url: string; private url: string;
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private reconnectInterval: number = 2000; // in ms private reconnectInterval: number = 2000; // in ms
private offlineQueue: WebSocketMessage[] = []; private offlineQueue: WebSocketMessage[] = [];
private firstConnect: boolean = true; private firstConnect: boolean = true;
private messageHandler: ((message: WebSocketMessage, dispatch: Dispatch) => void) | null = null; private messageHandler:
| ((message: WebSocketMessage, dispatch: Dispatch) => void)
| null = null;
constructor(url: string) { constructor(url: string) {
this.url = url; this.url = url;
}
private dispatch: Dispatch | null = null;
public connect(): void {
this.socket = new WebSocket(
`${this.url}?auth=${localStorage.getItem(
"session"
)}&bts=${BrowserTabSession}`
);
this.socket.onopen = () => {
console.log("WebSocket connected", this.firstConnect);
// Send all messages from the offline queue
this.offlineQueue.forEach((message) => this.send(message));
this.offlineQueue = [];
// Dispatch event to notify that the WebSocket connection is established
if (!this.firstConnect) {
document.dispatchEvent(webSocketConnectionEvent);
} else {
this.firstConnect = false;
}
};
this.socket.onmessage = (event: MessageEvent) => {
const data: WebSocketMessage = JSON.parse(event.data);
if (this.messageHandler) {
this.messageHandler(data, this.dispatch!);
} else {
console.error("No handler defined for WebSocket messages");
}
};
this.socket.onclose = () => {
console.log("WebSocket disconnected. Reconnecting...");
setTimeout(() => this.connect(), this.reconnectInterval);
};
this.socket.onerror = (error: Event) => {
console.error("WebSocket error:", error);
};
}
public setHandler(
handler: (message: WebSocketMessage, dispatch: Dispatch) => void,
dispatch: Dispatch
): void {
this.messageHandler = handler;
this.dispatch = dispatch;
}
public send(message: WebSocketMessage): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(
JSON.stringify({
Cmd: message.Cmd,
Body: message.Body,
})
);
} else {
this.offlineQueue.push(message);
} }
}
private dispatch: Dispatch | null = null; public disconnect(): void {
if (this.socket) {
public connect(): void { this.socket.close();
this.socket = new WebSocket(`${this.url}?auth=${localStorage.getItem('session')}&bts=${BrowserTabSession}`);
this.socket.onopen = () => {
console.log('WebSocket connected', this.firstConnect);
// Send all messages from the offline queue
this.offlineQueue.forEach((message) => this.send(message));
this.offlineQueue = [];
// Dispatch event to notify that the WebSocket connection is established
if (!this.firstConnect) {
document.dispatchEvent(webSocketConnectionEvent);
} else {
this.firstConnect = false;
}
};
this.socket.onmessage = (event: MessageEvent) => {
const data: WebSocketMessage = JSON.parse(event.data);
if (this.messageHandler) {
this.messageHandler(data, this.dispatch!);
} else {
console.error('No handler defined for WebSocket messages');
}
};
this.socket.onclose = () => {
console.log('WebSocket disconnected. Reconnecting...');
setTimeout(() => this.connect(), this.reconnectInterval);
};
this.socket.onerror = (error: Event) => {
console.error('WebSocket error:', error);
};
}
public setHandler(handler: (message: WebSocketMessage, dispatch: Dispatch) => void, dispatch: Dispatch): void {
this.messageHandler = handler;
this.dispatch = dispatch;
}
public send(message: WebSocketMessage): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(
JSON.stringify({
Cmd: message.Cmd,
Body: message.Body,
})
);
} else {
this.offlineQueue.push(message);
}
}
public disconnect(): void {
if (this.socket) {
this.socket.close();
}
} }
}
} }
const webSocketConnectionEventName = 'WebSocketConnectionEvent'; const webSocketConnectionEventName = "WebSocketConnectionEvent";
const webSocketConnectionEvent = new CustomEvent(webSocketConnectionEventName, { const webSocketConnectionEvent = new CustomEvent(webSocketConnectionEventName, {
detail: 'wsReconnect', detail: "wsReconnect",
}); });
export function addWebSocketReconnectListener(callback: () => void): void { export function addWebSocketReconnectListener(callback: () => void): void {
document.addEventListener(webSocketConnectionEventName, callback); document.addEventListener(webSocketConnectionEventName, callback);
} }
export function removeWebSocketReconnectListener(callback: () => void): void { export function removeWebSocketReconnectListener(callback: () => void): void {
document.removeEventListener(webSocketConnectionEventName, callback); document.removeEventListener(webSocketConnectionEventName, callback);
} }
const webSocketService = new WebSocketService(Constants.WS_ADDRESS); const webSocketService = new WebSocketService(Constants.WS_ADDRESS);
export default webSocketService; export default webSocketService;
export function WebSocketMessageHandler(message: WebSocketMessage, dispatch: Dispatch) { export function WebSocketMessageHandler(
const { Cmd, Body } = message; message: WebSocketMessage,
dispatch: Dispatch
) {
const { Cmd, Body } = message;
console.log('WebSocketMessageHandler', Cmd, Body); console.log("WebSocketMessageHandler", Cmd, Body);
switch (Cmd) { switch (Cmd) {
case WebSocketReceivedMessagesCmds.SettingsUpdated: case WebSocketReceivedMessagesCmds.SettingsUpdated:
dispatch(setPrimaryColor(Body.PrimaryColor)); dispatch(setPrimaryColor(Body.PrimaryColor));
break; break;
case WebSocketReceivedMessagesCmds.SettingsUpdatedLogo: case WebSocketReceivedMessagesCmds.SettingsUpdatedLogo:
dispatch(setLogoUrl(Body)); dispatch(setLogoUrl(Body));
break; break;
case WebSocketReceivedMessagesCmds.SettingsUpdatedBanner: case WebSocketReceivedMessagesCmds.SettingsUpdatedBanner:
dispatch(setBannerUrl(Body)); dispatch(setBannerUrl(Body));
break; break;
case WebSocketReceivedMessagesCmds.SettingsUpdatedSubdomain: case WebSocketReceivedMessagesCmds.SettingsUpdatedSubdomain:
localStorage.removeItem('session'); localStorage.removeItem("session");
window.location.href = `${window.location.protocol}//${Body}.${window.location.hostname.split('.').slice(1).join('.')}`; window.location.href = `${
break; window.location.protocol
case WebSocketReceivedMessagesCmds.TeamAddedMember: }//${Body}.${window.location.hostname.split(".").slice(1).join(".")}`;
dispatch(addTeamMember(Body)); break;
break; case WebSocketReceivedMessagesCmds.TeamAddedMember:
case WebSocketReceivedMessagesCmds.TeamUpdatedMemberRole: dispatch(addTeamMember(Body));
dispatch(updateTeamMemberRole(Body)); break;
break; case WebSocketReceivedMessagesCmds.TeamUpdatedMemberRole:
case WebSocketReceivedMessagesCmds.TeamDeletedMember: dispatch(updateTeamMemberRole(Body));
dispatch(deleteTeamMember(Body)); break;
break; case WebSocketReceivedMessagesCmds.TeamDeletedMember:
case WebSocketReceivedMessagesCmds.LessonCreated: dispatch(deleteTeamMember(Body));
dispatch(addLesson(Body)); break;
break; case WebSocketReceivedMessagesCmds.LessonCreated:
case WebSocketReceivedMessagesCmds.LessonPreviewTitleUpdated: dispatch(addLesson(Body));
dispatch(updateLessonPreviewTitle(Body)); break;
case WebSocketReceivedMessagesCmds.LessonPreviewTitleUpdated:
dispatch(updateLessonPreviewTitle(Body));
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(setLessonThumbnailTitle(Body.Title)); dispatch(setLessonThumbnailTitle(Body.Title));
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonPreviewThumbnailUpdated: case WebSocketReceivedMessagesCmds.LessonPreviewThumbnailUpdated:
dispatch(updateLessonPreviewThumbnail(Body)); dispatch(updateLessonPreviewThumbnail(Body));
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(setLessonThumbnailUrl(Body.ThumbnailUrl)); dispatch(setLessonThumbnailUrl(Body.ThumbnailUrl));
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonStateUpdated: case WebSocketReceivedMessagesCmds.LessonStateUpdated:
dispatch(updateLessonState(Body)); dispatch(updateLessonState(Body));
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(setPageEditorLessonState(Body.State)); dispatch(setPageEditorLessonState(Body.State));
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonAddedContent: case WebSocketReceivedMessagesCmds.LessonAddedContent:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) { if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(addLessonPageContent(Body)); dispatch(addLessonPageContent(Body));
} }
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(addLessonContent(Body)); dispatch(addLessonContent(Body));
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonDeletedContent: case WebSocketReceivedMessagesCmds.LessonDeletedContent:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) { if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(deleteLessonPageContent(Body.ContentId)); dispatch(deleteLessonPageContent(Body.ContentId));
} }
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(deleteLessonContent(Body.ContentId)); dispatch(deleteLessonContent(Body.ContentId));
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonContentUpdated: case WebSocketReceivedMessagesCmds.LessonContentUpdated:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) { if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch( dispatch(
updateLessonPageContent({ updateLessonPageContent({
id: Body.ContentId, id: Body.ContentId,
data: Body.Data, data: Body.Data,
}) })
); );
} }
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch( dispatch(
updateLessonContent({ updateLessonContent({
id: Body.ContentId, id: Body.ContentId,
data: Body.Data, data: Body.Data,
}) })
); );
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonContentUpdatedPosition: case WebSocketReceivedMessagesCmds.LessonContentUpdatedPosition:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) { if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch( dispatch(
updateLessonPageContentPosition({ updateLessonPageContentPosition({
contentId: Body.ContentId, contentId: Body.ContentId,
position: Body.Position, position: Body.Position,
}) })
); );
} }
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch( dispatch(
updateLessonContentPosition({ updateLessonContentPosition({
contentId: Body.ContentId, contentId: Body.ContentId,
position: Body.Position, position: Body.Position,
}) })
); );
} }
break; break;
case WebSocketReceivedMessagesCmds.LessonContentFileUpdated: case WebSocketReceivedMessagesCmds.LessonContentFileUpdated:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) { if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch( dispatch(
updateLessonPageContent({ updateLessonPageContent({
id: Body.ContentId, id: Body.ContentId,
data: Body.Data, data: Body.Data,
}) })
); );
} }
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch( dispatch(
updateLessonContent({ updateLessonContent({
id: Body.ContentId, id: Body.ContentId,
data: Body.Data, data: Body.Data,
}) })
); );
} }
break; break;
case WebSocketReceivedMessagesCmds.UserProfilePictureUpdated: case WebSocketReceivedMessagesCmds.UserProfilePictureUpdated:
dispatch(setProfilePictureUrl(Body)); dispatch(setProfilePictureUrl(Body));
dispatch(setUserProfilePictureUrl(Body)); dispatch(setUserProfilePictureUrl(Body));
break; break;
default: case WebSocketReceivedMessagesCmds.LessonQuestionCreated:
console.error('Unknown message type:', Cmd); if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
} dispatch(addQuestion(Body));
}
break;
case WebSocketReceivedMessagesCmds.LessonQuestionLiked:
dispatch(addLikedQuestion(Body));
break;
case WebSocketReceivedMessagesCmds.LessonQuestionCountUpLikes:
dispatch(countUpLikedQuestion(Body));
break;
case WebSocketReceivedMessagesCmds.LessonQuestionDisliked:
dispatch(deleteLikedQuestion(Body));
break;
case WebSocketReceivedMessagesCmds.LessonQuestionCountDownLikes:
dispatch(countDownLikedQuestion(Body));
break;
default:
console.error("Unknown message type:", Cmd);
}
} }

View File

@ -10,6 +10,7 @@ import { lessonsSlice } from "features/Lessons/lessonsSlice";
import { lessonPageSlice } from "features/Lessons/LessonPage/lessonPageSlice"; import { lessonPageSlice } from "features/Lessons/LessonPage/lessonPageSlice";
import { userProfileApi } from "core/services/userProfile"; import { userProfileApi } from "core/services/userProfile";
import { userProfileSlice } from "features/UserProfile/userProfileSlice"; import { userProfileSlice } from "features/UserProfile/userProfileSlice";
import { lessonQuestionsSlice } from "features/Lessons/Questions/lessonQuestionSlice";
const makeStore = (/* preloadedState */) => { const makeStore = (/* preloadedState */) => {
const store = configureStore({ const store = configureStore({
@ -21,6 +22,7 @@ const makeStore = (/* preloadedState */) => {
[lessonsApi.reducerPath]: lessonsApi.reducer, [lessonsApi.reducerPath]: lessonsApi.reducer,
[lessonsSlice.reducerPath]: lessonsSlice.reducer, [lessonsSlice.reducerPath]: lessonsSlice.reducer,
[lessonPageSlice.reducerPath]: lessonPageSlice.reducer, [lessonPageSlice.reducerPath]: lessonPageSlice.reducer,
[lessonQuestionsSlice.reducerPath]: lessonQuestionsSlice.reducer,
[organizationApi.reducerPath]: organizationApi.reducer, [organizationApi.reducerPath]: organizationApi.reducer,
[teamSlice.reducerPath]: teamSlice.reducer, [teamSlice.reducerPath]: teamSlice.reducer,
[userProfileApi.reducerPath]: userProfileApi.reducer, [userProfileApi.reducerPath]: userProfileApi.reducer,

View File

@ -36,20 +36,16 @@ export interface UpdateLessonPreviewThumbnail {
export interface LessonQuestion { export interface LessonQuestion {
Id: string; Id: string;
LessionId: string; LessionId: string;
Question: string; QuestionId?: string;
ReplyId?: string;
Message: string;
Likes: number; Likes: number;
CreatorUserId: string; CreatorUserId: string;
CreatedAt: string; CreatedAt: string;
UpdatedAt: string; UpdatedAt: string;
} }
export interface LessonQuestionReply { export interface QuestionsResponse {
Id: string; Questions: LessonQuestion[];
QuestionId: string; LikedQuestions: string[];
ReplyId?: string;
Reply: string;
Likes: number;
CreatorUserId: string;
CreatedAt: string;
UpdatedAt: string;
} }

View File

@ -5,3 +5,10 @@ export interface UserProfile {
Email: string; Email: string;
RoleId: string; RoleId: string;
} }
export interface CachedUser {
Id: string;
FirstName: string;
LastName: string;
ProfilePictureUrl: string;
}

View File

@ -20,6 +20,11 @@ enum WebSocketReceivedMessagesCmds {
LessonContentUpdatedPosition = 15, LessonContentUpdatedPosition = 15,
LessonContentFileUpdated = 16, LessonContentFileUpdated = 16,
UserProfilePictureUpdated = 17, UserProfilePictureUpdated = 17,
LessonQuestionCreated = 18,
LessonQuestionLiked = 19,
LessonQuestionCountUpLikes = 20,
LessonQuestionDisliked = 21,
LessonQuestionCountDownLikes = 22,
} }
export { WebSocketSendMessagesCmds, WebSocketReceivedMessagesCmds }; export { WebSocketSendMessagesCmds, WebSocketReceivedMessagesCmds };

View File

@ -1,20 +1,23 @@
import { Descriptions, Typography } from 'antd'; import { Descriptions, Typography } from "antd";
import MyMiddleCard from 'shared/components/MyMiddleCard'; import MyMiddleCard from "shared/components/MyMiddleCard";
import MyBanner from 'shared/components/MyBanner'; import MyBanner from "shared/components/MyBanner";
export default function ContactSupport() { export default function ContactSupport() {
return ( return (
<> <>
<MyBanner title="Contact Support" /> <MyBanner title="Contact Support" />
<MyMiddleCard title="Support"> <MyMiddleCard title="Support">
<Typography.Paragraph>If you have any questions or need help, please contact us at the following e-mail address:</Typography.Paragraph> <Typography.Paragraph>
<Descriptions> If you have any questions or need help, please contact us at the
<Descriptions.Item label="E-Mail"> following e-mail address:
<a href="mailto:support@jannex.de">support@jannex.de</a> </Typography.Paragraph>
</Descriptions.Item> <Descriptions>
</Descriptions> <Descriptions.Item label="E-Mail">
</MyMiddleCard> <a href="mailto:support@.">support@.</a>
</> </Descriptions.Item>
); </Descriptions>
</MyMiddleCard>
</>
);
} }

View File

@ -79,7 +79,7 @@ export default function LessonPage() {
<MyMiddleCard <MyMiddleCard
outOfCardChildren={ outOfCardChildren={
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 24 }}>
<Questions lessionID={"lessionID"} /> <Questions />
</div> </div>
} }
> >

View File

@ -1,267 +1,295 @@
import { HeartFilled, HeartOutlined } from "@ant-design/icons"; import { HeartFilled, HeartOutlined } from "@ant-design/icons";
import { Avatar, Button, Flex, Form, Input, InputRef, Typography } from "antd"; import { Button, Flex, Form, Input, InputRef, Typography } from "antd";
import { LessonQuestion, LessonQuestionReply } from "core/types/lesson"; import {
import { Constants } from "core/utils/utils"; useCreateLessonQuestionMutation,
import React, { useRef } from "react"; useCreateLessonQuestionReplyMutation,
useDislikeQuestionMutation,
useGetLessonQuestionsQuery,
useLikeQuestionMutation,
} from "core/services/lessons";
import { LessonQuestion } from "core/types/lesson";
import { ReactNode, useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import {
likedQuestions,
questions,
setLikedQuestions,
setQuestions,
} from "./lessonQuestionSlice";
import MyCenteredSpin from "shared/components/MyCenteredSpin";
import MyErrorResult from "shared/components/MyResult";
import { useMessage } from "core/context/MessageContext";
import { useForm } from "antd/es/form/Form";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import { useCachedUser } from "core/services/cachedUser";
import MyUserAvatar from "shared/components/MyUserAvatar";
export default function Questions({ lessionID }: { lessionID: string }) { const CreateQuestionForm: React.FC = () => {
let questions: LessonQuestion[] = [ const { lessonId } = useParams();
const [form] = useForm();
const [createQuestion, { isLoading }] = useCreateLessonQuestionMutation();
const { success, error } = useMessage();
const handleCreateLessonQuestion = async () => {
try {
await createQuestion({
lessonId: lessonId as string,
message: form.getFieldValue("message"),
}).unwrap();
success("Question created successfully");
form.resetFields();
} catch (err) {
console.error(err);
error("Failed to create question");
}
};
return (
<Form
form={form}
layout="vertical"
onFinish={() => handleCreateLessonQuestion()}
requiredMark={false}
>
<Form.Item
label="Ask a question"
name="message"
rules={[{ required: true, message: "Please write a question" }]}
>
<Input.TextArea
placeholder={"Type something"}
autoSize={{ minRows: 2 }}
maxLength={5000}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={isLoading}>
Submit
</Button>
</Form.Item>
</Form>
);
};
export default function Questions() {
const dispatch = useDispatch();
const { lessonId } = useParams();
const dataQuestions = useSelector(questions);
const dataLikedQuestions = useSelector(likedQuestions);
const { data, error, isLoading, refetch } = useGetLessonQuestionsQuery(
lessonId as string,
{ {
Id: "1", refetchOnMountOrArgChange: true,
LessionId: "1", }
Question: "What is the capital of Germany?", );
Likes: 5,
CreatorUserId: "1", useEffect(() => {
CreatedAt: "2021-09-01T12:00:00Z", if (!data) return;
UpdatedAt: "2021-09-01T12:00:00Z",
}, dispatch(setQuestions(data.Questions));
{ dispatch(setLikedQuestions(data.LikedQuestions));
Id: "2", }, [data]);
LessionId: "1",
Question: "What is the capital of France?", useEffect(() => {
Likes: 3, addWebSocketReconnectListener(refetch);
CreatorUserId: "2",
CreatedAt: "2021-09-01T12:00:00Z", return () => removeWebSocketReconnectListener(refetch);
UpdatedAt: "2021-09-01T12:00:00Z", }, []);
},
{
Id: "3",
LessionId: "1",
Question: "What is the capital of Italy?",
Likes: 2,
CreatorUserId: "3",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
];
return ( return (
<Flex justify="center"> <Flex justify="center">
<Flex style={{ width: 800, maxWidth: 800 * 0.9 }} vertical> <Flex style={{ width: 800, maxWidth: 800 * 0.9 }} vertical>
<Typography.Title level={3}>Questions</Typography.Title> <Typography.Title level={3}>Questions</Typography.Title>
<Form layout="vertical">
<Form.Item label="Ask a question"> <CreateQuestionForm />
<Input.TextArea placeholder={"Type something"} />
</Form.Item>
<Form.Item>
<Button type="primary">Submit</Button>
</Form.Item>
</Form>
<Flex vertical style={{}}> <Flex vertical style={{}}>
{questions.map((question) => ( {error ? (
<QuestionItem key={question.Id} question={question} /> <MyErrorResult />
))} ) : isLoading ? (
<MyCenteredSpin height="100px" />
) : (
dataQuestions
.filter((question) => question.QuestionId === "")
.sort((b, a) => a.CreatedAt.localeCompare(b.CreatedAt))
.map((question) => (
<QuestionItem
key={question.Id}
lessonId={lessonId as string}
question={question}
replies={dataQuestions.filter(
(reply) => reply.QuestionId === question.Id
)}
likedQuestions={dataLikedQuestions}
/>
))
)}
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>
); );
} }
type HandleReplyFunction = (text: string, replyID?: string) => Promise<void>; type HandleReplyFunction = (message: string, replyId?: string) => Promise<void>;
export function QuestionItem({ question }: { question: LessonQuestion }) { export function QuestionItem({
const [showReplies, setShowReplies] = React.useState(1); lessonId,
question,
replies,
likedQuestions,
}: {
lessonId: string;
question: LessonQuestion;
replies: LessonQuestion[];
likedQuestions: string[];
}) {
const [showReplies, setShowReplies] = useState(1);
const { success, error } = useMessage();
let user = { const [reqCreateQuestionReply] = useCreateLessonQuestionReplyMutation();
Id: "132154153613", const [reqLikeQuestion] = useLikeQuestionMutation();
FirstName: "Anja", const [reqDislikeQuestion] = useDislikeQuestionMutation();
LastName: "Blasinstroment",
};
let questionsReplys: LessonQuestionReply[] = [ async function handleReply(message: string, replyId?: string) {
{ try {
Id: "1", await reqCreateQuestionReply({
QuestionId: "1", lessonId: lessonId as string,
Reply: "Berlin", questionId: question.Id,
Likes: 5, message: message,
CreatorUserId: "1", }).unwrap();
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
{
Id: "2",
QuestionId: "1",
Reply: "Munich",
Likes: 3,
CreatorUserId: "2",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
{
Id: "3",
QuestionId: "1",
Reply: "Hamburg",
Likes: 2,
CreatorUserId: "3",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
{
Id: "4",
QuestionId: "1",
Reply: "Cologne",
Likes: 0,
CreatorUserId: "3",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
{
Id: "5",
QuestionId: "1",
Reply: "Frankfurt",
Likes: 0,
CreatorUserId: "3",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
{
Id: "6",
QuestionId: "1",
Reply: "Stuttgart",
Likes: 2,
CreatorUserId: "3",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
{
Id: "7",
QuestionId: "1",
Reply: "Düsseldorf",
Likes: 10,
CreatorUserId: "3",
CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: "2021-09-01T12:00:00Z",
},
];
async function handleReply(text: string, replyID?: string) { success("Question created successfully");
console.log("reply", text);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 0));
} catch (err) {
console.error(err);
error("Failed to create question");
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function handleLike(questionId: string) {
try {
await reqLikeQuestion({
lessonId: lessonId as string,
questionId: questionId,
}).unwrap();
} catch (err) {
console.error(err);
error("Failed to like");
}
}
async function handleDislike(questionId: string) {
try {
await reqDislikeQuestion({
lessonId: lessonId as string,
questionId: questionId,
}).unwrap();
} catch (err) {
console.error(err);
error("Failed to dislike");
}
} }
return ( return (
<QuestionUIRaw <QuestionUIRaw
userID={user.Id} userId={question.CreatorUserId}
text={question.Question} message={question.Message}
childContent={ childContent={
<div> <>
{(() => { {replies
let nodes = []; .sort((a, b) => a.CreatedAt.localeCompare(b.CreatedAt))
.map((reply) => (
for (let i = 0; i < questionsReplys.length; i++) { <QuestionUIRaw
if (i > showReplies - 1) { key={reply.Id}
nodes.push( userId={reply.CreatorUserId}
<Button message={reply.Message}
key="showMore" childContent={<></>}
type="link" likes={reply.Likes}
color="primary" onReply={handleReply}
onClick={() => setShowReplies(showReplies + 3)} onLike={() => handleLike(reply.Id)}
style={{ marginLeft: 64 }} onDislike={() => handleDislike(reply.Id)}
> replyId={reply.Id}
Show more hasLiked={likedQuestions.some((likeId) => likeId === reply.Id)}
</Button> />
); ))}
break; </>
}
nodes.push(
<QuestionReplyItem
key={"reply_" + questionsReplys[i].Id}
question={questionsReplys[i]}
handleReply={handleReply}
/>
);
}
return nodes;
})()}
</div>
} }
likes={question.Likes} likes={question.Likes}
onReply={handleReply} onReply={handleReply}
onLike={() => {}} onLike={() => handleLike(question.Id)}
replyID={undefined} onDislike={() => handleDislike(question.Id)}
/> replyId={undefined}
); hasLiked={likedQuestions.some((likeId) => likeId === question.Id)}
}
export function QuestionReplyItem({
question,
handleReply,
}: {
question: LessonQuestionReply;
handleReply: HandleReplyFunction;
}) {
let user = {
Id: "132154153613",
FirstName: "Anja",
LastName: "Blasinstroment",
};
return (
<QuestionUIRaw
userID={user.Id}
text={question.Reply}
childContent={<></>}
likes={question.Likes}
onReply={handleReply}
onLike={() => {}}
replyID={question.Id}
/> />
); );
} }
export function QuestionUIRaw({ export function QuestionUIRaw({
userID, userId,
text, message,
childContent, childContent,
likes, likes,
replyID, replyId,
onReply, onReply,
onLike, onLike,
onDislike,
hasLiked,
}: { }: {
userID: string; userId: string;
text: string; message: string;
childContent: React.ReactNode; childContent: ReactNode;
likes: number; likes: number;
replyID?: string; replyId?: string;
onReply: HandleReplyFunction; onReply: HandleReplyFunction;
onLike: () => void; onLike: () => void;
onDislike: () => void;
hasLiked: boolean;
}) { }) {
const [hasLiked, setHasLiked] = React.useState(false); //const [hasLiked, setHasLiked] = useState(false);
const [replyFormVisible, setReplyFormVisible] = React.useState(false); const [replyFormVisible, setReplyFormVisible] = useState(false);
const [replyText, setReplyText] = React.useState<null | string>(null); const [replyMessage, setReplyMessage] = useState<null | string>(null);
const [isSendingReply, setIsSendingReply] = React.useState(false); const [isSendingReply, setIsSendingReply] = useState(false);
let user = { const { user, error, isLoading } = useCachedUser(userId);
Id: "132154153613",
FirstName: "Anja",
LastName: "Blasinstroment",
};
const userAt = `@${user.FirstName} ${user.LastName} `;
async function toggleLike() {
setHasLiked(!hasLiked);
}
// useref to focus on the input field // useref to focus on the input field
const inputRef = useRef<InputRef>(null); const inputRef = useRef<InputRef>(null);
const userAt =
isLoading || user === undefined
? ""
: `@${user.FirstName} ${user.LastName} `;
if (error) return <MyErrorResult />;
if (isLoading) return <MyCenteredSpin height="80px" />;
return ( return (
<> <>
<Flex gap={16}> <Flex gap={16}>
<Avatar <MyUserAvatar
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`} profilePictureUrl={user.ProfilePictureUrl}
size={56} firstName={user.FirstName}
disableCursorPointer
/> />
<Flex vertical style={{ width: "100%" }}> <Flex vertical style={{ width: "100%" }}>
<Typography style={{ fontSize: 24, fontWeight: 800 }}> <Typography style={{ fontSize: 24, fontWeight: 800 }}>
{user.FirstName} {user.LastName} {user.FirstName} {user.LastName}
</Typography> </Typography>
<Typography style={{ fontSize: 18, fontWeight: 500 }}> <Typography style={{ fontSize: 18, fontWeight: 500 }}>
{text} {message}
</Typography> </Typography>
<Flex gap={0} align="center"> <Flex gap={0} align="center">
<Button <Button
@ -272,9 +300,9 @@ export function QuestionUIRaw({
style={{ style={{
color: hasLiked ? "red" : undefined, color: hasLiked ? "red" : undefined,
transform: hasLiked ? "scale(1.2)" : "scale(1)", transform: hasLiked ? "scale(1.2)" : "scale(1)",
transition: "all 0.3s ease-in-out", transition: "all 0.2s ease-in-out",
}} }}
onClick={toggleLike} onClick={() => (hasLiked ? onDislike() : onLike())}
></Button> ></Button>
<Typography <Typography
@ -285,7 +313,7 @@ export function QuestionUIRaw({
<Button <Button
type={replyFormVisible ? "link" : "text"} type={replyFormVisible ? "link" : "text"}
onClick={() => { onClick={() => {
if (replyText === null) setReplyText(userAt); if (replyMessage === null) setReplyMessage(userAt);
setReplyFormVisible(!replyFormVisible); setReplyFormVisible(!replyFormVisible);
setTimeout(() => { setTimeout(() => {
@ -301,14 +329,17 @@ export function QuestionUIRaw({
</Flex> </Flex>
{replyFormVisible ? ( {replyFormVisible ? (
<Form <Form
initialValues={{
reply: replyMessage ? replyMessage : userAt,
}}
disabled={isSendingReply} disabled={isSendingReply}
onFinish={async () => { onFinish={async () => {
setIsSendingReply(true); setIsSendingReply(true);
await onReply(replyText ? replyText : "", replyID); await onReply(replyMessage ? replyMessage : "", replyId);
setIsSendingReply(false); setIsSendingReply(false);
setReplyFormVisible(false); setReplyFormVisible(false);
setReplyText(null); setReplyMessage(null);
}} }}
> >
<Form.Item <Form.Item
@ -317,10 +348,11 @@ export function QuestionUIRaw({
> >
<Input.TextArea <Input.TextArea
ref={inputRef} ref={inputRef}
defaultValue={replyText ? replyText : userAt} value={replyMessage ? replyMessage : userAt}
value={replyText ? replyText : userAt}
placeholder="Write a reply" placeholder="Write a reply"
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyMessage(e.target.value)}
autoSize={{ minRows: 2 }}
maxLength={5000}
/> />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>

View File

@ -1,34 +1,65 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from "@reduxjs/toolkit";
import { LessonContent, LessonState } from 'core/types/lesson'; import { LessonQuestion } from "core/types/lesson";
interface AddLessonContentAction { export const lessonQuestionsSlice = createSlice({
type: string; name: "questions",
payload: LessonContent; initialState: {
} questions: [] as LessonQuestion[],
/* likedQuestions: [] as string[],
export const lessonPageEditorSlice = createSlice({ },
name: 'lessonQuestions', reducers: {
initialState: { setQuestions: (state, action) => {
editorActive: false, state.questions = action.payload as LessonQuestion[];
currentLessonId: '', // required in sideMenu because has no access to useParams
lessonThumbnail: {
img: '',
title: 'Tesdt',
},
lessonContents: [] as LessonContent[],
lessonState: LessonState.Draft,
}, },
reducers: { addQuestion: (state, action) => {
setEditorActive: (state, action) => { state.questions.push(action.payload as LessonQuestion);
state.editorActive = action.payload;
},
}, },
selectors: { setLikedQuestions: (state, action) => {
editorActive: (state) => state.editorActive, state.likedQuestions = action.payload as string[];
}, },
addLikedQuestion: (state, action) => {
state.likedQuestions.push(action.payload as string);
},
deleteLikedQuestion: (state, action) => {
const questionId = action.payload as string;
state.likedQuestions = state.likedQuestions.filter(
(qId) => qId !== questionId
);
},
countUpLikedQuestion: (state, action) => {
const questionId = action.payload as string;
const question = state.questions.find((q) => q.Id === questionId);
if (question) {
question.Likes++;
}
},
countDownLikedQuestion: (state, action) => {
const questionId = action.payload as string;
const question = state.questions.find((q) => q.Id === questionId);
if (question) {
question.Likes--;
}
},
},
selectors: {
questions: (state) => state.questions,
likedQuestions: (state) => state.likedQuestions,
},
}); });
export const { setEditorActive } = lessonPageEditorSlice.actions; export const {
setQuestions,
addQuestion,
setLikedQuestions,
addLikedQuestion,
deleteLikedQuestion,
countUpLikedQuestion,
countDownLikedQuestion,
} = lessonQuestionsSlice.actions;
export const { editorActive } = lessonPageEditorSlice.selectors; export const { questions, likedQuestions } = lessonQuestionsSlice.selectors;
*/