websocket events for live editing

main
alex 2024-09-08 19:29:13 +02:00
parent 61d01eedc7
commit 3bba897454
18 changed files with 1135 additions and 629 deletions

View File

@ -63,8 +63,6 @@ export default function App() {
}, [uAuthenticated]); }, [uAuthenticated]);
useEffect(() => { useEffect(() => {
console.log("App mounted");
if (!localStorage.getItem("session")) { if (!localStorage.getItem("session")) {
dispatch(setUserAuthenticated(false)); dispatch(setUserAuthenticated(false));
} else { } else {

View File

@ -1,60 +1,59 @@
import { Grid, Layout } from 'antd'; import { Grid, Layout } from "antd";
import PageContent from '../PageContent'; import PageContent from "../PageContent";
import SideMenuDesktop from '../SideMenu/Desktop'; import SideMenuDesktop from "../SideMenu/Desktop";
import SideMenuMobile from '../SideMenu/Mobile'; import SideMenuMobile from "../SideMenu/Mobile";
import { SideMenuContent, SideMenuEditorContent } from '../SideMenu'; import { SideMenuContent, SideMenuEditorContent } from "../SideMenu";
import { useEffect } from 'react'; import { useEffect } from "react";
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from "react-redux";
import { setIsSideMenuCollapsed } from '../SideMenu/sideMenuSlice'; import { setIsSideMenuCollapsed } from "../SideMenu/sideMenuSlice";
import { editorActive } from '../../../features/Lessons/LessonPageEditor/lessonPageEditorSlice'; import { editorActive } from "../../../features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import MyDndContext from './MyDndContext'; import MyDndContext from "./MyDndContext";
import AiChat from 'features/AiChat'; import AiChat from "features/AiChat";
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
export function SideMenu() { export function SideMenu() {
const screenBreakpoint = useBreakpoint(); const screenBreakpoint = useBreakpoint();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isEditorActive = useSelector(editorActive); const isEditorActive = useSelector(editorActive);
console.log('isEditorActive', isEditorActive); const Content = () => {
return isEditorActive ? <SideMenuEditorContent /> : <SideMenuContent />;
};
const Content = () => { useEffect(() => {
return isEditorActive ? <SideMenuEditorContent /> : <SideMenuContent />; dispatch(setIsSideMenuCollapsed(!screenBreakpoint.lg));
}; }, [screenBreakpoint]);
useEffect(() => { return (
dispatch(setIsSideMenuCollapsed(!screenBreakpoint.lg)); <>
}, [screenBreakpoint]); {screenBreakpoint.lg ? (
<SideMenuDesktop>
return ( <Content />
<> </SideMenuDesktop>
<AiChat /> ) : (
{screenBreakpoint.lg ? ( <SideMenuMobile>
<SideMenuDesktop> <Content />
<Content /> </SideMenuMobile>
</SideMenuDesktop> )}
) : ( </>
<SideMenuMobile> );
<Content />
</SideMenuMobile>
)}
</>
);
} }
export default function DashboardLayout() { export default function DashboardLayout() {
return ( return (
<MyDndContext> <MyDndContext>
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: "100vh" }}>
<Layout> <Layout>
<SideMenu /> <SideMenu />
<PageContent /> <PageContent />
</Layout>
</Layout> <AiChat />
</MyDndContext> </Layout>
); </Layout>
</MyDndContext>
);
} }

View File

@ -23,7 +23,6 @@ import {
BrowserTabSession, BrowserTabSession,
Constants, Constants,
} from "core/utils/utils"; } from "core/utils/utils";
import Search from "antd/es/input/Search";
import { MyContainer } from "shared/components/MyContainer"; import { MyContainer } from "shared/components/MyContainer";
import { import {
addLessonContent, addLessonContent,
@ -45,6 +44,8 @@ import webSocketService, {
removeWebSocketReconnectListener, removeWebSocketReconnectListener,
} from "core/services/websocketService"; } from "core/services/websocketService";
import { WebSocketSendMessagesCmds } from "core/utils/webSocket"; import { WebSocketSendMessagesCmds } from "core/utils/webSocket";
import { useMessage } from "core/context/MessageContext";
import styles from "./styles.module.css";
export function SideMenuContent() { export function SideMenuContent() {
const location = useLocation(); const location = useLocation();
@ -150,10 +151,9 @@ export function SideMenuContent() {
}; };
useEffect(() => { useEffect(() => {
// subscribe to the current page
const pathname = location.pathname; const pathname = location.pathname;
setSelectedKeys(pathname);
const subscribeTopicMessage = () => { const subscribeTopicMessage = () => {
webSocketService.send({ webSocketService.send({
Cmd: WebSocketSendMessagesCmds.SubscribeToTopic, Cmd: WebSocketSendMessagesCmds.SubscribeToTopic,
@ -165,14 +165,18 @@ export function SideMenuContent() {
}; };
subscribeTopicMessage(); subscribeTopicMessage();
addWebSocketReconnectListener(subscribeTopicMessage); addWebSocketReconnectListener(subscribeTopicMessage);
// set selected keys
let path = pathname.split("/"); let path = pathname.split("/");
if (path.length > 2) { if (path.length > 2) {
setSelectedKeys(`/${path[1]}`);
// /store/:storeId/:subPage - open the store menu // /store/:storeId/:subPage - open the store menu
setOpenKeys([`/${path[1]}/${path[2]}`]); //setOpenKeys([`/${path[1]}/${path[2]}`]);
} else {
setSelectedKeys(pathname);
} }
// auto close sideMenu on mobile // auto close sideMenu on mobile
@ -259,16 +263,37 @@ export function SideMenuContent() {
} }
export function SideMenuEditorContent() { export function SideMenuEditorContent() {
// create is dragging useState const location = useLocation();
const [isDragging, setIsDragging] = useState<String | null>(null); const [isDragging, setIsDragging] = useState<String | null>(null);
const { lessonId } = useParams(); const { lessonId } = useParams();
const { success, error } = useMessage();
const [form] = useForm(); const [form] = useForm();
const lnState = useSelector(lessonState); const lnState = useSelector(lessonState);
const currentLnId = useSelector(currentLessonId); const currentLnId = useSelector(currentLessonId);
const [updateLessonState] = useUpdateLessonStateMutation(); const [reqUpdateLessonState] = useUpdateLessonStateMutation();
useEffect(() => {
// subscribe to the current page
const pathname = location.pathname;
const subscribeTopicMessage = () => {
webSocketService.send({
Cmd: WebSocketSendMessagesCmds.SubscribeToTopic,
Body: {
topic: pathname,
browserTabSession: BrowserTabSession,
},
});
};
subscribeTopicMessage();
addWebSocketReconnectListener(subscribeTopicMessage);
return () => removeWebSocketReconnectListener(subscribeTopicMessage);
}, [location.pathname]);
useEffect(() => { useEffect(() => {
form.setFieldsValue({ form.setFieldsValue({
@ -276,14 +301,11 @@ export function SideMenuEditorContent() {
}); });
}, [lnState]); }, [lnState]);
// <Search placeholder="What would you like to insert?" />;
return ( return (
<Flex justify="space-between" vertical style={{ height: "100%" }}> <Flex justify="space-between" vertical style={{ height: "100%" }}>
<MyContainer> <MyContainer>
<Search
placeholder="What would you like to insert?"
style={{ paddingBottom: 16 }}
/>
<DndContext <DndContext
onDragStart={(event) => { onDragStart={(event) => {
console.log("drag start", event.active.id); console.log("drag start", event.active.id);
@ -296,10 +318,14 @@ export function SideMenuEditorContent() {
}} }}
> >
{componentsGroups.map((group, i) => ( {componentsGroups.map((group, i) => (
<div key={i}> <div key={i} style={{ paddingTop: 16 }}>
<span>{group.category}</span> <span>{group.category}</span>
<Flex gap={16} wrap style={{ paddingTop: 16 }}> <Flex
gap={16}
wrap
style={{ paddingTop: 16, userSelect: "none" }}
>
{group.components.map((component, i) => ( {group.components.map((component, i) => (
<DraggableCreateComponent key={i} component={component} /> <DraggableCreateComponent key={i} component={component} />
))} ))}
@ -340,12 +366,15 @@ export function SideMenuEditorContent() {
console.log("state changed", value, lessonId); console.log("state changed", value, lessonId);
try { try {
await updateLessonState({ await reqUpdateLessonState({
lessonId: currentLnId, lessonId: currentLnId,
newState: value, newState: value,
}).unwrap(); }).unwrap();
success("Lesson state updated successfully");
} catch (err) { } catch (err) {
console.log("error", err); console.log("error", err);
error("Failed to update lesson state");
} }
}} }}
> >
@ -366,7 +395,7 @@ export function DraggableCreateComponent({
}: { }: {
component: Component; component: Component;
}) { }) {
const dispatch = useDispatch(); // const dispatch = useDispatch();
/*const { attributes, listeners, setNodeRef, transform, isDragging, active } = /*const { attributes, listeners, setNodeRef, transform, isDragging, active } =
useDraggable({ useDraggable({
@ -394,9 +423,10 @@ export function DraggableCreateComponent({
} }
function CreateComponent({ component }: { component: Component }) { function CreateComponent({ component }: { component: Component }) {
const { error } = useMessage();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isDarkMode = useSelector(darkMode);
const isDarkMode = useSelector(darkMode);
const currentLnId = useSelector(currentLessonId); const currentLnId = useSelector(currentLessonId);
const [reqAddLessonContent] = useAddLessonContentMutation(); const [reqAddLessonContent] = useAddLessonContentMutation();
@ -406,15 +436,16 @@ function CreateComponent({ component }: { component: Component }) {
vertical vertical
align="center" align="center"
justify="center" justify="center"
className={styles.createComponentContainer}
style={{ style={{
backgroundColor: !isDarkMode ? "#f0f2f5" : "#1f1f1f", backgroundColor: !isDarkMode ? "#f0f2f5" : "#1f1f1f",
height: 80, height: 80,
width: 80, width: 80,
cursor: "pointer", cursor: "pointer",
borderRadius: 4,
transition: "background-color 0.2s",
}} }}
onClick={async () => { onClick={async () => {
console.log("insert component", component.type);
try { try {
const res = await reqAddLessonContent({ const res = await reqAddLessonContent({
lessonId: currentLnId, lessonId: currentLnId,
@ -422,8 +453,6 @@ function CreateComponent({ component }: { component: Component }) {
data: component.defaultData || "", data: component.defaultData || "",
}).unwrap(); }).unwrap();
console.log("add content", component);
dispatch( dispatch(
addLessonContent({ addLessonContent({
Id: res.Id, Id: res.Id,
@ -435,15 +464,18 @@ function CreateComponent({ component }: { component: Component }) {
); );
} catch (err) { } catch (err) {
console.log("error", err); console.log("error", err);
error("Failed to add content");
} }
}} }}
> >
{component.thumbnail ? ( {component.thumbnail ? (
<div> <div>
<img <img
draggable={false}
src={component.thumbnail} src={component.thumbnail}
style={{ style={{
width: 40, width: 40,
maxHeight: 40,
filter: filter:
isDarkMode && component.invertThumbnailAtDarkmode isDarkMode && component.invertThumbnailAtDarkmode
? "invert(1)" ? "invert(1)"

View File

@ -0,0 +1,3 @@
.createComponentContainer:hover {
background-color: rgba(0, 0, 0, 0.1) !important;
}

View File

@ -4,8 +4,30 @@ import {
setLogoUrl, setLogoUrl,
setPrimaryColor, setPrimaryColor,
} from "core/reducers/appSlice"; } from "core/reducers/appSlice";
import { store } from "core/store/store";
import { BrowserTabSession, Constants } from "core/utils/utils"; import { BrowserTabSession, Constants } from "core/utils/utils";
import { WebSocketReceivedMessagesCmds } from "core/utils/webSocket"; import { WebSocketReceivedMessagesCmds } from "core/utils/webSocket";
import {
addLessonPageContent,
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 { import {
addTeamMember, addTeamMember,
deleteTeamMember, deleteTeamMember,
@ -20,7 +42,7 @@ interface WebSocketMessage {
class WebSocketService { class WebSocketService {
private url: string; private url: string;
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private reconnectInterval: number = 1000; // in ms private reconnectInterval: number = 2000; // in ms
private offlineQueue: WebSocketMessage[] = []; private offlineQueue: WebSocketMessage[] = [];
private firstConnect: boolean = true; private firstConnect: boolean = true;
@ -157,6 +179,103 @@ export function WebSocketMessageHandler(
case WebSocketReceivedMessagesCmds.TeamDeletedMember: case WebSocketReceivedMessagesCmds.TeamDeletedMember:
dispatch(deleteTeamMember(Body)); dispatch(deleteTeamMember(Body));
break; break;
case WebSocketReceivedMessagesCmds.LessonCreated:
dispatch(addLesson(Body));
break;
case WebSocketReceivedMessagesCmds.LessonPreviewTitleUpdated:
dispatch(updateLessonPreviewTitle(Body));
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(setLessonThumbnailTitle(Body.Title));
}
break;
case WebSocketReceivedMessagesCmds.LessonPreviewThumbnailUpdated:
dispatch(updateLessonPreviewThumbnail(Body));
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(setLessonThumbnailUrl(Body.ThumbnailUrl));
}
break;
case WebSocketReceivedMessagesCmds.LessonStateUpdated:
dispatch(updateLessonState(Body));
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(setPageEditorLessonState(Body.State));
}
break;
case WebSocketReceivedMessagesCmds.LessonAddedContent:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(addLessonPageContent(Body));
}
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(addLessonContent(Body));
}
break;
case WebSocketReceivedMessagesCmds.LessonDeletedContent:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(deleteLessonPageContent(Body.ContentId));
}
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(deleteLessonContent(Body.ContentId));
}
break;
case WebSocketReceivedMessagesCmds.LessonContentUpdated:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(
updateLessonPageContent({
id: Body.ContentId,
data: Body.Data,
})
);
}
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(
updateLessonContent({
id: Body.ContentId,
data: Body.Data,
})
);
}
break;
case WebSocketReceivedMessagesCmds.LessonContentUpdatedPosition:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(
updateLessonPageContentPosition({
contentId: Body.ContentId,
position: Body.Position,
})
);
}
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(
updateLessonContentPosition({
contentId: Body.ContentId,
position: Body.Position,
})
);
}
break;
case WebSocketReceivedMessagesCmds.LessonContentFileUpdated:
if (Body.LessonId === store.getState().lessonPage.currentLessonId) {
dispatch(
updateLessonPageContent({
id: Body.ContentId,
data: Body.Data,
})
);
}
if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) {
dispatch(
updateLessonContent({
id: Body.ContentId,
data: Body.Data,
})
);
}
break;
default: default:
console.error("Unknown message type:", Cmd); console.error("Unknown message type:", Cmd);
} }

View File

@ -6,6 +6,8 @@ import { appSlice } from "../reducers/appSlice";
import { lessonsApi } from "core/services/lessons"; import { lessonsApi } from "core/services/lessons";
import { organizationApi } from "core/services/organization"; import { organizationApi } from "core/services/organization";
import { teamSlice } from "features/Team/teamSlice"; import { teamSlice } from "features/Team/teamSlice";
import { lessonsSlice } from "features/Lessons/lessonsSlice";
import { lessonPageSlice } from "features/Lessons/LessonPage/lessonPageSlice";
const makeStore = (/* preloadedState */) => { const makeStore = (/* preloadedState */) => {
const store = configureStore({ const store = configureStore({
@ -15,6 +17,8 @@ const makeStore = (/* preloadedState */) => {
lessonPageEditor: lessonPageEditorSlice.reducer, lessonPageEditor: lessonPageEditorSlice.reducer,
teamSlice: teamSlice.reducer, teamSlice: teamSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer, [lessonsApi.reducerPath]: lessonsApi.reducer,
[lessonsSlice.reducerPath]: lessonsSlice.reducer,
[lessonPageSlice.reducerPath]: lessonPageSlice.reducer,
[organizationApi.reducerPath]: organizationApi.reducer, [organizationApi.reducerPath]: organizationApi.reducer,
[teamSlice.reducerPath]: teamSlice.reducer, [teamSlice.reducerPath]: teamSlice.reducer,
}, },

View File

@ -10,6 +10,15 @@ enum WebSocketReceivedMessagesCmds {
TeamAddedMember = 5, TeamAddedMember = 5,
TeamUpdatedMemberRole = 6, TeamUpdatedMemberRole = 6,
TeamDeletedMember = 7, TeamDeletedMember = 7,
LessonCreated = 8,
LessonPreviewTitleUpdated = 9,
LessonPreviewThumbnailUpdated = 10,
LessonStateUpdated = 11,
LessonAddedContent = 12,
LessonDeletedContent = 13,
LessonContentUpdated = 14,
LessonContentUpdatedPosition = 15,
LessonContentFileUpdated = 16,
} }
export { WebSocketSendMessagesCmds, WebSocketReceivedMessagesCmds }; export { WebSocketSendMessagesCmds, WebSocketReceivedMessagesCmds };

View File

@ -1,63 +1,96 @@
import { Button, Flex } from 'antd'; import { Button, Flex } from "antd";
import { CheckOutlined } from '@ant-design/icons'; import { CheckOutlined } from "@ant-design/icons";
import HeaderBar from '../../../core/components/Header'; import HeaderBar from "core/components/Header";
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { Constants } from '../../../core/utils/utils'; import { Constants } from "core/utils/utils";
import React from 'react'; import React, { useEffect } from "react";
import MySpin from 'shared/components/MySpin'; import MyErrorResult from "shared/components/MyResult";
import MyErrorResult from 'shared/components/MyResult'; import MyEmpty from "shared/components/MyEmpty";
import MyEmpty from 'shared/components/MyEmpty'; import { useGetLessonContentsQuery } from "core/services/lessons";
import { useGetLessonContentsQuery } from 'core/services/lessons'; import MyMiddleCard from "shared/components/MyMiddleCard";
import MyMiddleCard from 'shared/components/MyMiddleCard'; import { Converter } from "../converter";
import { Converter } from '../converter'; import Questions from "../Questions";
import Questions from '../Questions'; import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import { useDispatch, useSelector } from "react-redux";
import {
lessonPageContents,
setLessonPageContents,
setLessonPageCurrentLessonId,
} from "./lessonPageSlice";
import MyCenteredSpin from "shared/components/MyCenteredSpin";
const LessonContents: React.FC = () => { const LessonContents: React.FC = () => {
const { lessonId } = useParams(); const dispatch = useDispatch();
const { lessonId } = useParams();
const { data, error, isLoading } = useGetLessonContentsQuery(lessonId as string, { const lessonContents = useSelector(lessonPageContents);
refetchOnMountOrArgChange: true,
});
if (isLoading) return <MySpin />; const { data, error, isLoading, refetch } = useGetLessonContentsQuery(
if (error) return <MyErrorResult />; lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
if (!data || data.length === 0) return <MyEmpty />; useEffect(() => {
dispatch(setLessonPageCurrentLessonId(lessonId as string));
addWebSocketReconnectListener(refetch);
return ( return () => removeWebSocketReconnectListener(refetch);
<> }, []);
{data.map((lessonContent) => (
<div key={lessonContent.Id} style={{ paddingBottom: 6 }}> useEffect(() => {
<Converter mode="view" lessonContent={lessonContent} /> if (!data) return;
</div>
))} dispatch(setLessonPageContents(data));
</> }, [data]);
);
if (isLoading) return <MyCenteredSpin height="140px" />;
if (error) return <MyErrorResult />;
if (!lessonContents || lessonContents.length === 0) return <MyEmpty />;
return (
<>
{lessonContents.map((lessonContent) => (
<div key={lessonContent.Id} style={{ paddingBottom: 6 }}>
<Converter mode="view" lessonContent={lessonContent} />
</div>
))}
</>
);
}; };
export default function LessonPage() { export default function LessonPage() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<HeaderBar theme="light" backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT} onEdit={() => navigate(`${location.pathname}/editor`)} /> <HeaderBar
theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
onEdit={() => navigate(`${location.pathname}/editor`)}
/>
<MyMiddleCard <MyMiddleCard
outOfCardChildren={ outOfCardChildren={
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 24 }}>
<Questions lessionID={'lessionID'} /> <Questions lessionID={"lessionID"} />
</div> </div>
} }
> >
<LessonContents /> <LessonContents />
<Flex justify="right"> <Flex justify="right">
<Button type="primary" icon={<CheckOutlined />}> <Button type="primary" icon={<CheckOutlined />}>
Finish lesson Finish lesson
</Button> </Button>
</Flex> </Flex>
</MyMiddleCard> </MyMiddleCard>
</> </>
); );
} }

View File

@ -0,0 +1,63 @@
import { createSlice } from "@reduxjs/toolkit";
import { LessonContent } from "core/types/lesson";
export const lessonPageSlice = createSlice({
name: "lessonPage",
initialState: {
currentLessonId: "",
lessonContents: [] as LessonContent[],
},
reducers: {
setLessonPageCurrentLessonId: (state, action) => {
state.currentLessonId = action.payload;
},
setLessonPageContents: (state, action) => {
state.lessonContents = action.payload;
},
addLessonPageContent: (state, action) => {
state.lessonContents.push(action.payload);
},
deleteLessonPageContent: (state, action) => {
state.lessonContents = state.lessonContents.filter(
(content) => content.Id !== action.payload
);
},
updateLessonPageContent: (state, action) => {
const index = state.lessonContents.findIndex(
(content) => content.Id === action.payload.id
);
if (index !== -1) {
state.lessonContents[index].Data = action.payload.data;
}
},
updateLessonPageContentPosition: (state, action) => {
// change only by contentId and new position
const content = state.lessonContents.find(
(content) => content.Id === action.payload.contentId
);
if (content) {
const index = state.lessonContents.indexOf(content);
state.lessonContents.splice(index, 1);
state.lessonContents.splice(action.payload.position - 1, 0, content);
}
},
},
selectors: {
lessonPageCurrentLessonId: (state) => state.currentLessonId,
lessonPageContents: (state) => state.lessonContents,
},
});
export const {
setLessonPageCurrentLessonId,
setLessonPageContents,
addLessonPageContent,
deleteLessonPageContent,
updateLessonPageContent,
updateLessonPageContentPosition,
} = lessonPageSlice.actions;
export const { lessonPageCurrentLessonId, lessonPageContents } =
lessonPageSlice.selectors;

View File

@ -1,94 +1,118 @@
import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from '@dnd-kit/utilities'; import { CSS } from "@dnd-kit/utilities";
import { HolderOutlined, DeleteOutlined, CameraOutlined, FolderOpenOutlined } from '@ant-design/icons'; import {
import { Flex } from 'antd'; HolderOutlined,
import { currentLessonId, deleteLessonContent, updateLessonContent } from './lessonPageEditorSlice'; DeleteOutlined,
import { useDispatch, useSelector } from 'react-redux'; CameraOutlined,
import { getComponentByType } from '../components'; FolderOpenOutlined,
import { LessonContent } from 'core/types/lesson'; } from "@ant-design/icons";
import './styles.module.css'; import { Flex } from "antd";
import { Converter } from '../converter'; import {
import { useDeleteLessonContentMutation } from 'core/services/lessons'; currentLessonId,
deleteLessonContent,
updateLessonContent,
} from "./lessonPageEditorSlice";
import { useDispatch, useSelector } from "react-redux";
import { getComponentByType } from "../components";
import { LessonContent } from "core/types/lesson";
import "./styles.module.css";
import { Converter } from "../converter";
import { useDeleteLessonContentMutation } from "core/services/lessons";
const animateLayoutChanges = (args: any) => (args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true); const animateLayoutChanges = (args: any) =>
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true;
const SortableEditorItem = (props: { item: LessonContent }) => { const SortableEditorItem = (props: { item: LessonContent }) => {
const lnContent = props.item; const lnContent = props.item;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: lnContent.Id }); const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: lnContent.Id });
const dispatch = useDispatch(); const dispatch = useDispatch();
const component = getComponentByType(lnContent.Type); const component = getComponentByType(lnContent.Type);
const [reqDeleteLessonContent] = useDeleteLessonContentMutation(); const [reqDeleteLessonContent] = useDeleteLessonContentMutation();
const currentLnId = useSelector(currentLessonId); const currentLnId = useSelector(currentLessonId);
if (!component) { if (!component) {
return null; return null;
} }
return ( return (
<div <div
style={{
transform: CSS.Translate.toString(transform),
transition,
}}
ref={setNodeRef}
{...attributes}
>
<Flex key={lnContent.Id}>
<Flex>
<HolderOutlined
style={{ style={{
transform: CSS.Translate.toString(transform), paddingLeft: 8,
transition, paddingRight: 8,
touchAction: "none",
cursor: "grab",
opacity: isDragging ? 0 : 1,
}} }}
ref={setNodeRef} {...listeners}
{...attributes} />
</Flex>
<Flex
style={{
overflow: "hidden",
width: "100%",
transition: "",
boxShadow: isDragging ? "rgba(0, 0, 0, 0.08) 0px 5px 15px" : "",
}}
> >
<Flex key={lnContent.Id}> <Converter
<Flex> mode="edititable"
<HolderOutlined lessonContent={lnContent}
style={{ onEdit={(data) => {
paddingLeft: 8, console.log("edit", lnContent.Id, data);
paddingRight: 8,
touchAction: 'none',
cursor: 'grab',
opacity: isDragging ? 0 : 1,
}}
{...listeners}
/>
</Flex>
<Flex style={{ overflow: 'hidden', width: '100%', transition: '', boxShadow: isDragging ? 'rgba(0, 0, 0, 0.35) 0px 5px 15px;' : '' }}>
<Converter
mode="edititable"
lessonContent={lnContent}
onEdit={(data) => {
console.log('edit', lnContent.Id, data);
dispatch( dispatch(
updateLessonContent({ updateLessonContent({
id: lnContent.Id, id: lnContent.Id,
data: data, data: data,
}) })
); );
}} }}
/> />
</Flex> </Flex>
<Flex vertical justify="center" style={{ paddingLeft: 12 }}> <Flex vertical justify="center" style={{ paddingLeft: 12 }}>
<div className="EditorActionIcon"> <div className="EditorActionIcon">
<DeleteOutlined <DeleteOutlined
className="EditorActionIcon" className="EditorActionIcon"
onClick={() => { onClick={() => {
console.log('delete', lnContent.Id); console.log("delete", lnContent.Id);
dispatch(deleteLessonContent(lnContent.Id)); dispatch(deleteLessonContent(lnContent.Id));
try { try {
reqDeleteLessonContent({ reqDeleteLessonContent({
lessonId: currentLnId, lessonId: currentLnId,
contentId: lnContent.Id, contentId: lnContent.Id,
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}} }}
/> />
</div> </div>
</Flex> </Flex>
</Flex> </Flex>
</div> </div>
); );
}; };
/* /*

View File

@ -1,120 +1,169 @@
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from "react-router-dom";
import { lessonContents, lessonThumbnail, setCurrentLessonId, setEditorActive, setLessonContents, setLessonState } from './lessonPageEditorSlice'; import {
import { useEffect } from 'react'; lessonContents,
import { useDispatch, useSelector } from 'react-redux'; lessonThumbnail,
import { Card, Flex } from 'antd'; setCurrentLessonId,
import { Constants } from 'core/utils/utils'; setEditorActive,
import HeaderBar from 'core/components/Header'; setLessonContents,
import Droppable from './Droppable'; setLessonThumbnailTitle,
import LessonPreviewCard from 'shared/components/MyLessonPreviewCard'; setLessonThumbnailUrl,
import { useGetLessonContentsQuery, useGetLessonSettingsQuery, useUpdateLessonPreviewTitleMutation } from 'core/services/lessons'; setPageEditorLessonState,
import MyErrorResult from 'shared/components/MyResult'; } from "./lessonPageEditorSlice";
import styles from './styles.module.css'; import { useEffect } from "react";
import MyEmpty from 'shared/components/MyEmpty'; import { useDispatch, useSelector } from "react-redux";
import { Card, Flex } from "antd";
import { Constants } from "core/utils/utils";
import HeaderBar from "core/components/Header";
import Droppable from "./Droppable";
import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
import {
useGetLessonContentsQuery,
useGetLessonSettingsQuery,
useUpdateLessonPreviewTitleMutation,
} from "core/services/lessons";
import MyErrorResult from "shared/components/MyResult";
import styles from "./styles.module.css";
import MyEmpty from "shared/components/MyEmpty";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
const PreviewCard: React.FC = () => { const PreviewCard: React.FC = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { lessonId } = useParams(); const { lessonId } = useParams();
const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(lessonId as string, { const dataLessonThumbnail = useSelector(lessonThumbnail);
refetchOnMountOrArgChange: true,
});
const [updateLessonPreviewTitle] = useUpdateLessonPreviewTitleMutation(); const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
useEffect(() => { const [updateLessonPreviewTitle] = useUpdateLessonPreviewTitleMutation();
if (data?.State) dispatch(setLessonState(data.State));
}, [data]);
if (error) return <MyErrorResult />; useEffect(() => {
if (!data) return;
return ( dispatch(setPageEditorLessonState(data.State));
<LessonPreviewCard dispatch(setLessonThumbnailTitle(data.Title));
mode="editable" dispatch(setLessonThumbnailUrl(data.ThumbnailUrl));
lessonId={lessonId as string} }, [data]);
loading={isLoading}
lessonSettings={{
Title: data?.Title || '',
ThumbnailUrl: data?.ThumbnailUrl || '',
}}
onEditTitle={async (newTitle) => {
try {
const res = await updateLessonPreviewTitle({
lessonId: lessonId as string,
newTitle: newTitle,
}).unwrap();
if (res) { if (error) return <MyErrorResult />;
refetch();
} return (
} catch (err) { <LessonPreviewCard
console.error(err); mode="editable"
} lessonId={lessonId as string}
}} loading={isLoading}
onThumbnailChanged={refetch} lessonSettings={{
/> Title: dataLessonThumbnail.title || "",
); ThumbnailUrl: dataLessonThumbnail.url || "",
}}
onEditTitle={async (newTitle) => {
try {
await updateLessonPreviewTitle({
lessonId: lessonId as string,
newTitle: newTitle,
}).unwrap();
dispatch(setLessonThumbnailTitle(newTitle));
} catch (err) {
console.error(err);
}
}}
/>
);
}; };
const LessonContentComponents: React.FC = () => { const LessonContentComponents: React.FC = () => {
const { lessonId } = useParams(); const { lessonId } = useParams();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { data, error, isLoading } = useGetLessonContentsQuery(lessonId as string, { const { data, error, isLoading, refetch } = useGetLessonContentsQuery(
refetchOnMountOrArgChange: true, lessonId as string,
}); {
refetchOnMountOrArgChange: true,
}
);
const lnContents = useSelector(lessonContents); const lnContents = useSelector(lessonContents);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
dispatch(setLessonContents(data)); dispatch(setLessonContents(data));
}, [data]); }, [data]);
if (error) return <MyErrorResult />; useEffect(() => {
addWebSocketReconnectListener(refetch);
return ( return () => removeWebSocketReconnectListener(refetch);
<Card loading={isLoading}> }, []);
<Flex vertical gap={16}>
{!lnContents || lnContents.length == 0 ? <MyEmpty /> : <Droppable items={lnContents} />} if (error) return <MyErrorResult />;
</Flex>
</Card> return (
); <Card loading={isLoading}>
<Flex vertical gap={16}>
{!lnContents || lnContents.length == 0 ? (
<MyEmpty />
) : (
<Droppable items={lnContents} />
)}
</Flex>
</Card>
);
}; };
export default function LessonPageEditor() { export default function LessonPageEditor() {
const { lessonId } = useParams(); const { lessonId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const lnContents = useSelector(lessonContents);
const lnThumbnail = useSelector(lessonThumbnail);
useEffect(() => { useEffect(() => {
dispatch(setEditorActive(true)); dispatch(setEditorActive(true));
dispatch(setCurrentLessonId(lessonId as string)); dispatch(setCurrentLessonId(lessonId as string));
return () => { return () => {
dispatch(setEditorActive(false)); dispatch(setEditorActive(false));
}; };
}, []); }, []);
return ( return (
<> <>
<HeaderBar <HeaderBar
theme="light" theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(':lessonId', lessonId as string)} backTo={Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(
onView={() => navigate(Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(':lessonId', lessonId as string))} ":lessonId",
/> lessonId as string
)}
onView={() =>
navigate(
Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(
":lessonId",
lessonId as string
)
)
}
/>
<Flex justify="center" style={{ paddingTop: 24 }}> <Flex justify="center" style={{ paddingTop: 24 }}>
<Flex justify="center" vertical gap={16} className={styles.cardContainer}> <Flex
<PreviewCard /> justify="center"
vertical
gap={16}
className={styles.cardContainer}
>
<PreviewCard />
<LessonContentComponents /> <LessonContentComponents />
</Flex> </Flex>
</Flex> </Flex>
</> </>
); );
} }

View File

@ -12,8 +12,8 @@ export const lessonPageEditorSlice = createSlice({
editorActive: false, editorActive: false,
currentLessonId: "", // required in sideMenu because has no access to useParams currentLessonId: "", // required in sideMenu because has no access to useParams
lessonThumbnail: { lessonThumbnail: {
img: "",
title: "Test", title: "Test",
url: "",
}, },
lessonContents: [] as LessonContent[], lessonContents: [] as LessonContent[],
lessonState: LessonState.Draft, lessonState: LessonState.Draft,
@ -48,6 +48,9 @@ export const lessonPageEditorSlice = createSlice({
setLessonThumbnailTitle: (state, action) => { setLessonThumbnailTitle: (state, action) => {
state.lessonThumbnail.title = action.payload; state.lessonThumbnail.title = action.payload;
}, },
setLessonThumbnailUrl: (state, action) => {
state.lessonThumbnail.url = action.payload;
},
onDragHandler: (state, action) => { onDragHandler: (state, action) => {
state.lessonContents.splice( state.lessonContents.splice(
action.payload.newIndex, action.payload.newIndex,
@ -55,9 +58,21 @@ export const lessonPageEditorSlice = createSlice({
state.lessonContents.splice(action.payload.oldIndex, 1)[0] state.lessonContents.splice(action.payload.oldIndex, 1)[0]
); );
}, },
setLessonState: (state, action) => { setPageEditorLessonState: (state, action) => {
state.lessonState = action.payload; state.lessonState = action.payload;
}, },
updateLessonContentPosition: (state, action) => {
// change only by contentId and new position
const content = state.lessonContents.find(
(content) => content.Id === action.payload.contentId
);
if (content) {
const index = state.lessonContents.indexOf(content);
state.lessonContents.splice(index, 1);
state.lessonContents.splice(action.payload.position - 1, 0, content);
}
},
}, },
selectors: { selectors: {
editorActive: (state) => state.editorActive, editorActive: (state) => state.editorActive,
@ -76,8 +91,10 @@ export const {
setLessonContents, setLessonContents,
updateLessonContent, updateLessonContent,
setLessonThumbnailTitle, setLessonThumbnailTitle,
setLessonThumbnailUrl,
onDragHandler, onDragHandler,
setLessonState, setPageEditorLessonState,
updateLessonContentPosition,
} = lessonPageEditorSlice.actions; } = lessonPageEditorSlice.actions;
export const { export const {

View File

@ -1,296 +1,342 @@
import { DownOutlined, HeartFilled, HeartOutlined } from '@ant-design/icons'; import { HeartFilled, HeartOutlined } from "@ant-design/icons";
import { Avatar, Button, Card, Collapse, Divider, Flex, Form, Input, InputRef, Typography } from 'antd'; import { Avatar, Button, Flex, Form, Input, InputRef, Typography } from "antd";
import Meta from 'antd/es/card/Meta'; import { LessonQuestion, LessonQuestionReply } from "core/types/lesson";
import TextArea from 'antd/es/input/TextArea'; import { Constants } from "core/utils/utils";
import { LessonQuestion, LessonQuestionReply } from 'core/types/lesson'; import React, { useRef } from "react";
import { Constants } from 'core/utils/utils';
import React, { useRef } from 'react';
export default function Questions({ lessionID }: { lessionID: string }) { export default function Questions({ lessionID }: { lessionID: string }) {
let questions: LessonQuestion[] = [ let questions: LessonQuestion[] = [
{ {
Id: '1', Id: "1",
LessionId: '1', LessionId: "1",
Question: 'What is the capital of Germany?', Question: "What is the capital of Germany?",
Likes: 5, Likes: 5,
CreatorUserId: '1', CreatorUserId: "1",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '2', Id: "2",
LessionId: '1', LessionId: "1",
Question: 'What is the capital of France?', Question: "What is the capital of France?",
Likes: 3, Likes: 3,
CreatorUserId: '2', CreatorUserId: "2",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '3', Id: "3",
LessionId: '1', LessionId: "1",
Question: 'What is the capital of Italy?', Question: "What is the capital of Italy?",
Likes: 2, Likes: 2,
CreatorUserId: '3', CreatorUserId: "3",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '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 layout="vertical">
<Form.Item label="Ask a question"> <Form.Item label="Ask a question">
<Input.TextArea placeholder={'Type something'} /> <Input.TextArea placeholder={"Type something"} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary">Submit</Button> <Button type="primary">Submit</Button>
</Form.Item> </Form.Item>
</Form> </Form>
<Flex vertical style={{}}> <Flex vertical style={{}}>
{questions.map((question) => ( {questions.map((question) => (
<QuestionItem key={question.Id} question={question} /> <QuestionItem key={question.Id} question={question} />
))} ))}
</Flex>
</Flex>
</Flex> </Flex>
); </Flex>
</Flex>
);
} }
type HandleReplyFunction = (text: string, replyID?: string) => Promise<void>; type HandleReplyFunction = (text: string, replyID?: string) => Promise<void>;
export function QuestionItem({ question }: { question: LessonQuestion }) { export function QuestionItem({ question }: { question: LessonQuestion }) {
const [showReplies, setShowReplies] = React.useState(1); const [showReplies, setShowReplies] = React.useState(1);
let user = { let user = {
Id: '132154153613', Id: "132154153613",
FirstName: 'Anja', FirstName: "Anja",
LastName: 'Blasinstroment', LastName: "Blasinstroment",
}; };
let questionsReplys: LessonQuestionReply[] = [ let questionsReplys: LessonQuestionReply[] = [
{ {
Id: '1', Id: "1",
QuestionId: '1', QuestionId: "1",
Reply: 'Berlin', Reply: "Berlin",
Likes: 5, Likes: 5,
CreatorUserId: '1', CreatorUserId: "1",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '2', Id: "2",
QuestionId: '1', QuestionId: "1",
Reply: 'Munich', Reply: "Munich",
Likes: 3, Likes: 3,
CreatorUserId: '2', CreatorUserId: "2",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '3', Id: "3",
QuestionId: '1', QuestionId: "1",
Reply: 'Hamburg', Reply: "Hamburg",
Likes: 2, Likes: 2,
CreatorUserId: '3', CreatorUserId: "3",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '4', Id: "4",
QuestionId: '1', QuestionId: "1",
Reply: 'Cologne', Reply: "Cologne",
Likes: 0, Likes: 0,
CreatorUserId: '3', CreatorUserId: "3",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '5', Id: "5",
QuestionId: '1', QuestionId: "1",
Reply: 'Frankfurt', Reply: "Frankfurt",
Likes: 0, Likes: 0,
CreatorUserId: '3', CreatorUserId: "3",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '6', Id: "6",
QuestionId: '1', QuestionId: "1",
Reply: 'Stuttgart', Reply: "Stuttgart",
Likes: 2, Likes: 2,
CreatorUserId: '3', CreatorUserId: "3",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
{ {
Id: '7', Id: "7",
QuestionId: '1', QuestionId: "1",
Reply: 'Düsseldorf', Reply: "Düsseldorf",
Likes: 10, Likes: 10,
CreatorUserId: '3', CreatorUserId: "3",
CreatedAt: '2021-09-01T12:00:00Z', CreatedAt: "2021-09-01T12:00:00Z",
UpdatedAt: '2021-09-01T12:00:00Z', UpdatedAt: "2021-09-01T12:00:00Z",
}, },
]; ];
async function handleReply(text: string, replyID?: string) { async function handleReply(text: string, replyID?: string) {
console.log('reply', text); console.log("reply", text);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} }
return ( return (
<QuestionUIRaw <QuestionUIRaw
userID={user.Id} userID={user.Id}
text={question.Question} text={question.Question}
childContent={ childContent={
<div> <div>
{(() => { {(() => {
let nodes = []; let nodes = [];
for (let i = 0; i < questionsReplys.length; i++) { for (let i = 0; i < questionsReplys.length; i++) {
if (i > showReplies - 1) { if (i > showReplies - 1) {
nodes.push( nodes.push(
<Button key="showMore" type="link" color="primary" onClick={() => setShowReplies(showReplies + 3)} style={{ marginLeft: 64 }}> <Button
Show more key="showMore"
</Button> type="link"
); color="primary"
break; onClick={() => setShowReplies(showReplies + 3)}
} style={{ marginLeft: 64 }}
>
Show more
</Button>
);
break;
}
nodes.push(<QuestionReplyItem key={'reply_' + questionsReplys[i].Id} question={questionsReplys[i]} handleReply={handleReply} />); nodes.push(
} <QuestionReplyItem
key={"reply_" + questionsReplys[i].Id}
return nodes; question={questionsReplys[i]}
})()} handleReply={handleReply}
</div> />
);
} }
likes={question.Likes}
onReply={handleReply} return nodes;
onLike={() => {}} })()}
replyID={undefined} </div>
/> }
); likes={question.Likes}
onReply={handleReply}
onLike={() => {}}
replyID={undefined}
/>
);
} }
export function QuestionReplyItem({ question, handleReply }: { question: LessonQuestionReply; handleReply: HandleReplyFunction }) { export function QuestionReplyItem({
let user = { question,
Id: '132154153613', handleReply,
FirstName: 'Anja', }: {
LastName: 'Blasinstroment', 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} />; 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, text,
childContent, childContent,
likes, likes,
replyID, replyID,
onReply, onReply,
onLike, onLike,
}: { }: {
userID: string; userID: string;
text: string; text: string;
childContent: React.ReactNode; childContent: React.ReactNode;
likes: number; likes: number;
replyID?: string; replyID?: string;
onReply: HandleReplyFunction; onReply: HandleReplyFunction;
onLike: () => void; onLike: () => void;
}) { }) {
const [hasLiked, setHasLiked] = React.useState(false); const [hasLiked, setHasLiked] = React.useState(false);
const [replyFormVisible, setReplyFormVisible] = React.useState(false); const [replyFormVisible, setReplyFormVisible] = React.useState(false);
const [replyText, setReplyText] = React.useState<null | string>(null); const [replyText, setReplyText] = React.useState<null | string>(null);
const [isSendingReply, setIsSendingReply] = React.useState(false); const [isSendingReply, setIsSendingReply] = React.useState(false);
let user = { let user = {
Id: '132154153613', Id: "132154153613",
FirstName: 'Anja', FirstName: "Anja",
LastName: 'Blasinstroment', LastName: "Blasinstroment",
}; };
const userAt = `@${user.FirstName} ${user.LastName} `; const userAt = `@${user.FirstName} ${user.LastName} `;
async function toggleLike() { async function toggleLike() {
setHasLiked(!hasLiked); 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);
return ( return (
<> <>
<Flex gap={16}> <Flex gap={16}>
<Avatar src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`} size={56} /> <Avatar
<Flex vertical style={{ width: '100%' }}> src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
<Typography style={{ fontSize: 24, fontWeight: 800 }}> size={56}
{user.FirstName} {user.LastName} />
</Typography> <Flex vertical style={{ width: "100%" }}>
<Typography style={{ fontSize: 18, fontWeight: 500 }}>{text}</Typography> <Typography style={{ fontSize: 24, fontWeight: 800 }}>
<Flex gap={0} align="center"> {user.FirstName} {user.LastName}
<Button </Typography>
type="text" <Typography style={{ fontSize: 18, fontWeight: 500 }}>
icon={hasLiked ? <HeartFilled /> : <HeartOutlined />} {text}
shape="circle" </Typography>
size="large" <Flex gap={0} align="center">
style={{ color: hasLiked ? 'red' : undefined, transform: hasLiked ? 'scale(1.2)' : 'scale(1)', transition: 'all 0.3s ease-in-out' }} <Button
onClick={toggleLike} type="text"
></Button> icon={hasLiked ? <HeartFilled /> : <HeartOutlined />}
shape="circle"
size="large"
style={{
color: hasLiked ? "red" : undefined,
transform: hasLiked ? "scale(1.2)" : "scale(1)",
transition: "all 0.3s ease-in-out",
}}
onClick={toggleLike}
></Button>
<Typography style={{ fontSize: 16, fontWeight: 400, pointerEvents: 'none' }}>{likes >= 1 ? likes : ' '}</Typography> <Typography
<Button style={{ fontSize: 16, fontWeight: 400, pointerEvents: "none" }}
type={replyFormVisible ? 'link' : 'text'} >
onClick={() => { {likes >= 1 ? likes : " "}
if (replyText === null) setReplyText(userAt); </Typography>
setReplyFormVisible(!replyFormVisible); <Button
type={replyFormVisible ? "link" : "text"}
onClick={() => {
if (replyText === null) setReplyText(userAt);
setReplyFormVisible(!replyFormVisible);
setTimeout(() => { setTimeout(() => {
if (inputRef.current) { if (inputRef.current) {
const input = inputRef.current; const input = inputRef.current;
input.focus({ cursor: 'end' }); input.focus({ cursor: "end" });
} }
}, 100); }, 100);
}} }}
> >
{replyFormVisible ? 'Hide' : 'Reply'} {replyFormVisible ? "Hide" : "Reply"}
</Button> </Button>
</Flex> </Flex>
{replyFormVisible ? ( {replyFormVisible ? (
<Form <Form
disabled={isSendingReply} disabled={isSendingReply}
onFinish={async () => { onFinish={async () => {
setIsSendingReply(true); setIsSendingReply(true);
await onReply(replyText ? replyText : '', replyID); await onReply(replyText ? replyText : "", replyID);
setIsSendingReply(false); setIsSendingReply(false);
setReplyFormVisible(false); setReplyFormVisible(false);
setReplyText(null); setReplyText(null);
}} }}
> >
<Form.Item name="reply" rules={[{ required: true, message: 'Please write a reply' }]}> <Form.Item
<Input.TextArea name="reply"
ref={inputRef} rules={[{ required: true, message: "Please write a reply" }]}
defaultValue={replyText ? replyText : userAt} >
value={replyText ? replyText : userAt} <Input.TextArea
placeholder="Write a reply" ref={inputRef}
onChange={(e) => setReplyText(e.target.value)} defaultValue={replyText ? replyText : userAt}
/> value={replyText ? replyText : userAt}
</Form.Item> placeholder="Write a reply"
<Form.Item> onChange={(e) => setReplyText(e.target.value)}
<Button type="primary" loading={isSendingReply} htmlType="submit"> />
Reply </Form.Item>
</Button> <Form.Item>
</Form.Item> <Button
</Form> type="primary"
) : null} loading={isSendingReply}
{childContent} htmlType="submit"
</Flex> >
</Flex> Reply
</> </Button>
); </Form.Item>
</Form>
) : null}
{childContent}
</Flex>
</Flex>
</>
);
} }

View File

@ -1,65 +1,65 @@
// Desc: This file contains the list of components that are used in the Lessons // Desc: This file contains the list of components that are used in the Lessons
type ComponentGroup = { type ComponentGroup = {
category: string; category: string;
components: Component[]; components: Component[];
}; };
export type Component = { export type Component = {
type: number; type: number;
name: string; name: string;
thumbnail?: string; thumbnail?: string;
invertThumbnailAtDarkmode?: boolean; invertThumbnailAtDarkmode?: boolean;
uploadFileTypes?: string[]; uploadFileTypes?: string[];
uploadImage?: boolean; uploadImage?: boolean;
defaultData?: string; defaultData?: string;
}; };
const componentsGroups: ComponentGroup[] = [ const componentsGroups: ComponentGroup[] = [
{ {
category: 'Common', category: "Common",
components: [ components: [
{ {
type: 0, type: 0,
name: 'Header', name: "Header",
thumbnail: '/editor/thumbnails/component_thumbnail_header.svg', thumbnail: "/editor/thumbnails/component_thumbnail_header.svg",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
defaultData: 'Header', defaultData: "Header",
}, },
{ {
type: 1, type: 1,
name: 'Text', name: "Text",
thumbnail: '/editor/thumbnails/component_thumbnail_text.svg', thumbnail: "/editor/thumbnails/component_thumbnail_text.svg",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
defaultData: 'Text', defaultData: "Text",
}, },
], ],
}, },
{ {
category: 'Media', category: "Media",
components: [ components: [
{ {
type: 2, type: 2,
name: 'Image', name: "Image",
thumbnail: '/editor/thumbnails/component_thumbnail_image.png', thumbnail: "/editor/thumbnails/component_thumbnail_image.png",
uploadImage: true, uploadImage: true,
uploadFileTypes: ['image/*'], uploadFileTypes: ["image/*"],
}, },
{ {
type: 3, type: 3,
name: 'YouTube', name: "YouTube",
thumbnail: '/editor/thumbnails/component_thumbnail_youtube.png', thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
}, },
{ {
type: 4, type: 4,
name: 'Video', name: "Video",
thumbnail: '/editor/thumbnails/component_thumbnail_youtube.png', thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
}, },
], ],
}, },
/* /*
{ {
category: "HTML", category: "HTML",
components: [ components: [
@ -84,30 +84,30 @@ const componentsGroups: ComponentGroup[] = [
]; ];
const componentsMap: { [key: string]: Component } = (() => { const componentsMap: { [key: string]: Component } = (() => {
const map: { [key: string]: Component } = {}; const map: { [key: string]: Component } = {};
for (const group of componentsGroups) { for (const group of componentsGroups) {
for (const component of group.components) { for (const component of group.components) {
map[component.name] = component; map[component.name] = component;
}
} }
return map; }
return map;
})(); })();
export function getTypeByName(name: string): number { export function getTypeByName(name: string): number {
const component = componentsMap[name]; const component = componentsMap[name];
return component ? component.type : -1; return component ? component.type : -1;
} }
export function getComponentByType(type: number): Component | null { export function getComponentByType(type: number): Component | null {
for (const component of Object.values(componentsMap)) { for (const component of Object.values(componentsMap)) {
if (component.type === type) { if (component.type === type) {
return component; return component;
}
} }
}
return null; return null;
} }
export { componentsGroups }; export { componentsGroups };

View File

@ -9,21 +9,32 @@ import {
useCreateLessonMutation, useCreateLessonMutation,
useGetLessonsQuery, useGetLessonsQuery,
} from "core/services/lessons"; } from "core/services/lessons";
import MySpin from "shared/components/MySpin";
import { Constants } from "core/utils/utils"; import { Constants } from "core/utils/utils";
import MyEmpty from "shared/components/MyEmpty"; import MyEmpty from "shared/components/MyEmpty";
import MyErrorResult from "shared/components/MyResult"; import MyErrorResult from "shared/components/MyResult";
import LessonPreviewCard from "shared/components/MyLessonPreviewCard"; import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
import { useMessage } from "core/context/MessageContext";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { lessons, setLessons } from "./lessonsSlice";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import MyCenteredSpin from "shared/components/MyCenteredSpin";
const CreateLessonButton: React.FC = () => { const CreateLessonButton: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [createLesson, { isLoading }] = useCreateLessonMutation(); const [createLesson, { isLoading }] = useCreateLessonMutation();
const { success, error } = useMessage();
const handleCreateLesson = async () => { const handleCreateLesson = async () => {
try { try {
const res = await createLesson({}).unwrap(); const res = await createLesson({}).unwrap();
if (res && res.Id) { if (res && res.Id) {
success("Lesson created successfully ");
navigate( navigate(
Constants.ROUTE_PATHS.LESSIONS.PAGE_EDITOR.replace( Constants.ROUTE_PATHS.LESSIONS.PAGE_EDITOR.replace(
":lessonId", ":lessonId",
@ -33,6 +44,7 @@ const CreateLessonButton: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
error("Failed to create lesson");
} }
}; };
@ -48,17 +60,32 @@ const CreateLessonButton: React.FC = () => {
}; };
const LessonList: React.FC = () => { const LessonList: React.FC = () => {
const { data, error, isLoading } = useGetLessonsQuery(undefined, { const dispatch = useDispatch();
const dataLessons = useSelector(lessons);
const { data, error, isLoading, refetch } = useGetLessonsQuery(undefined, {
refetchOnMountOrArgChange: true, refetchOnMountOrArgChange: true,
}); });
if (isLoading) return <MySpin />; useEffect(() => {
if (!data) return;
dispatch(setLessons(data));
}, [data]);
useEffect(() => {
addWebSocketReconnectListener(refetch);
return () => removeWebSocketReconnectListener(refetch);
}, []);
if (isLoading) return <MyCenteredSpin height="240px" />;
if (error) return <MyErrorResult />; if (error) return <MyErrorResult />;
if (!data || data.length === 0) return <MyEmpty />; if (!dataLessons || dataLessons.length === 0) return <MyEmpty />;
const publishedItems = data.filter((item) => item.State === 1); const publishedItems = dataLessons.filter((item) => item.State === 1);
const unpublishedItems = data.filter((item) => item.State === 2); const unpublishedItems = dataLessons.filter((item) => item.State === 2);
return ( return (
<> <>

View File

@ -0,0 +1,62 @@
import { createSlice } from "@reduxjs/toolkit";
import { Lesson } from "core/types/lesson";
interface AddLessonAction {
type: string;
payload: Lesson;
}
export const lessonsSlice = createSlice({
name: "lessons",
initialState: {
lessons: [] as Lesson[],
},
reducers: {
setLessons: (state, action) => {
state.lessons = action.payload;
},
addLesson: (state, action: AddLessonAction) => {
state.lessons.push(action.payload);
},
updateLessonPreviewTitle: (state, action) => {
const lesson = state.lessons.find(
(lesson) => lesson.Id === action.payload.LessonId
);
if (lesson) {
lesson.Title = action.payload.Title;
}
},
updateLessonPreviewThumbnail: (state, action) => {
const lesson = state.lessons.find(
(lesson) => lesson.Id === action.payload.LessonId
);
if (lesson) {
lesson.ThumbnailUrl = action.payload.ThumbnailUrl;
}
},
updateLessonState: (state, action) => {
const lesson = state.lessons.find(
(lesson) => lesson.Id === action.payload.LessonId
);
if (lesson) {
lesson.State = action.payload.State;
}
},
},
selectors: {
lessons: (state) => state.lessons,
},
});
export const {
setLessons,
addLesson,
updateLessonPreviewTitle,
updateLessonPreviewThumbnail,
updateLessonState,
} = lessonsSlice.actions;
export const { lessons } = lessonsSlice.selectors;

View File

@ -7,12 +7,23 @@ import MyEmpty from "shared/components/MyEmpty";
import MyCenteredSpin from "shared/components/MyCenteredSpin"; import MyCenteredSpin from "shared/components/MyCenteredSpin";
import { Role } from "core/types/organization"; import { Role } from "core/types/organization";
import { useGetRolesQuery } from "core/services/organization"; import { useGetRolesQuery } from "core/services/organization";
import { useEffect } from "react";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
export default function Roles() { export default function Roles() {
const { data, error, isLoading } = useGetRolesQuery(undefined, { const { data, error, isLoading, refetch } = useGetRolesQuery(undefined, {
refetchOnMountOrArgChange: true, refetchOnMountOrArgChange: true,
}); });
useEffect(() => {
addWebSocketReconnectListener(refetch);
return () => removeWebSocketReconnectListener(refetch);
}, []);
return ( return (
<> <>
<MyBanner title="Roles" subtitle="MANAGE" headerBar={<HeaderBar />} /> <MyBanner title="Roles" subtitle="MANAGE" headerBar={<HeaderBar />} />

View File

@ -26,6 +26,10 @@ import {
import MyMiddleCard from "shared/components/MyMiddleCard"; import MyMiddleCard from "shared/components/MyMiddleCard";
import { OrganizationSettings } from "core/types/organization"; import { OrganizationSettings } from "core/types/organization";
import { useMessage } from "core/context/MessageContext"; import { useMessage } from "core/context/MessageContext";
import {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
type GeneralFieldType = { type GeneralFieldType = {
primaryColor: string | AggregationColor; primaryColor: string | AggregationColor;
@ -33,13 +37,19 @@ type GeneralFieldType = {
}; };
export default function Settings() { export default function Settings() {
const { data, error, isLoading } = useGetOrganizationSettingsQuery( const { data, error, isLoading, refetch } = useGetOrganizationSettingsQuery(
undefined, undefined,
{ {
refetchOnMountOrArgChange: true, refetchOnMountOrArgChange: true,
} }
); );
useEffect(() => {
addWebSocketReconnectListener(refetch);
return () => removeWebSocketReconnectListener(refetch);
}, []);
if (error) return <MyErrorResult />; if (error) return <MyErrorResult />;
return ( return (