fetching lessons, update lesson preview and page editor drag and drop

main
alex 2024-08-31 23:05:23 +02:00
parent 12467edda7
commit 23b9a7e249
40 changed files with 1094 additions and 213 deletions

96
package-lock.json generated
View File

@ -10,6 +10,8 @@
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@reduxjs/toolkit": "^2.2.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@ -19,6 +21,7 @@
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"antd": "^5.20.3",
"antd-img-crop": "^4.23.0",
"buffer": "^6.0.3",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
@ -29,7 +32,11 @@
"react-router-dom": "^6.19.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^10.0.0",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/uuid": "^10.0.0"
}
},
"node_modules/@adobe/css-tools": {
@ -2584,6 +2591,34 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz",
"integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
@ -4925,6 +4960,13 @@
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
@ -5646,6 +5688,21 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/antd-img-crop": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/antd-img-crop/-/antd-img-crop-4.23.0.tgz",
"integrity": "sha512-JtQoUmR3GqXoG+hsYXRxCBC60AgUKbbvArbnd8/5UmmuyVcQzBnumfoQTdC9wczWQuxRIpkPwsdOge6CCeepqg==",
"license": "MIT",
"dependencies": {
"react-easy-crop": "^5.0.8",
"tslib": "^2.6.3"
},
"peerDependencies": {
"antd": ">=4.0.0",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -14140,6 +14197,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@ -17107,6 +17170,20 @@
"react": "^18.3.1"
}
},
"node_modules/react-easy-crop": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.8.tgz",
"integrity": "sha512-KjulxXhR5iM7+ATN2sGCum/IyDxGw7xT0dFoGcqUP+ysaPU5Ka7gnrDa2tUHFHUoMNyPrVZ05QA+uvMgC5ym/g==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-error-overlay": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
@ -18262,6 +18339,15 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@ -19786,9 +19872,13 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"

View File

@ -5,6 +5,8 @@
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@reduxjs/toolkit": "^2.2.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@ -14,6 +16,7 @@
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"antd": "^5.20.3",
"antd-img-crop": "^4.23.0",
"buffer": "^6.0.3",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
@ -24,6 +27,7 @@
"react-router-dom": "^6.19.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^10.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {
@ -49,5 +53,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/uuid": "^10.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="30" viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.71804 29.5V0.40909H4.24077V13.3636H19.7521V0.40909H23.2749V29.5H19.7521V16.4886H4.24077V29.5H0.71804Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@ -0,0 +1,3 @@
<svg width="58" height="31" viewBox="0 0 58 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 4.5C0 2.29086 1.79086 0.5 4 0.5H54C56.2091 0.5 58 2.29086 58 4.5V26.5C58 28.7091 56.2091 30.5 54 30.5H4C1.79086 30.5 0 28.7091 0 26.5V4.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,3 @@
<svg width="22" height="30" viewBox="0 0 22 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.099787 3.53409V0.40909H21.918V3.53409H12.7702V29.5H9.24751V3.53409H0.099787Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -4,12 +4,13 @@ import {
darkMode,
setUserAuthenticated,
userAuthenticated,
} from "./core/store/appSlice";
} from "./core/reducers/appSlice";
import { useDispatch, useSelector } from "react-redux";
import SignIn from "./features/Auth/SignIn";
import { useEffect } from "react";
import { myFetch } from "./core/utils/utils";
import MyCenteredSpin from "./shared/components/MyCenteredSpin";
import webSocketService from "core/services/websocketService";
const { defaultAlgorithm, darkAlgorithm } = theme;
@ -44,6 +45,12 @@ function App() {
}
} catch (error) {}
})();
webSocketService.connect();
return () => {
webSocketService.disconnect();
};
}, []);
return (

View File

@ -0,0 +1,11 @@
import { closestCenter, DndContext, DragOverlay } from "@dnd-kit/core";
import { DraggableCreateComponent } from "../SideMenu";
import { componentsGroups } from "features/Lessons/components";
function MyDndContext({ children }: { children: React.ReactNode }) {
return <DndContext collisionDetection={closestCenter}>{children}
</DndContext>;
}
export default MyDndContext;

View File

@ -7,6 +7,8 @@ import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setIsSideMenuCollapsed } from "../SideMenu/sideMenuSlice";
import { editorActive } from "../../../features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import MyDndContext from "./MyDndContext";
const { useBreakpoint } = Grid;
@ -44,6 +46,7 @@ export function SideMenu() {
export default function DashboardLayout() {
return (
<MyDndContext>
<Layout style={{ minHeight: "100vh" }}>
<Layout>
<SideMenu />
@ -51,5 +54,6 @@ export default function DashboardLayout() {
<PageContent />
</Layout>
</Layout>
</MyDndContext>
);
}

View File

@ -15,7 +15,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { Link } from "react-router-dom";
import { darkMode, setDarkMode } from "../../store/appSlice";
import { darkMode, setDarkMode } from "../../reducers/appSlice";
import styles from "./styles.module.css";
type HeaderBarProps = {

View File

@ -7,7 +7,7 @@ import {
TeamOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { Divider, Flex, Menu } from "antd";
import { Divider, Flex, Menu, Typography } from "antd";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
@ -17,10 +17,19 @@ import {
sideMenuComponentFirstRender,
} from "./sideMenuSlice";
import { ItemType, MenuItemType } from "antd/es/menu/interface";
import { BreakpointLgWidth, Constants } from "../../utils/utils";
import { BreakpointLgWidth, Constants } from "core/utils/utils";
import Search from "antd/es/input/Search";
import { MyContainer } from "../../../shared/components/MyContainer";
import { addLessonContent } from "../../../features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { MyContainer } from "shared/components/MyContainer";
import { addLessonContent } from "features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { Component, componentsGroups } from "features/Lessons/components";
import { darkMode } from "core/reducers/appSlice";
import { DndContext, DragOverlay, useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { snapCenterToCursor } from "@dnd-kit/modifiers";
import { createPortal } from "react-dom";
export function SideMenuContent() {
const location = useLocation();
@ -199,35 +208,9 @@ export function SideMenuContent() {
);
}
type ComponentGroup = {
category: string;
components: Component[];
};
type Component = {
type: number;
name: string;
thumbnail?: string;
};
const componentsGroups: ComponentGroup[] = [
{
category: "General",
components: [
{
type: 0,
name: "Header",
},
{
type: 1,
name: "Text",
},
],
},
];
export function SideMenuEditorContent() {
const dispatch = useDispatch();
// create is dragging useState
const [isDragging, setIsDragging] = useState<String | null>(null);
return (
<MyContainer>
@ -236,19 +219,101 @@ export function SideMenuEditorContent() {
style={{ paddingBottom: 16 }}
/>
<DndContext
onDragStart={(event) => {
console.log("drag start", event.active.id);
setIsDragging(event.active.id.toString());
}}
onDragEnd={(evnet) => {
console.log("drag end", evnet.active.id);
setIsDragging(null);
}}
>
{componentsGroups.map((group, i) => (
<div key={i}>
<span>{group.category}</span>
<Flex gap={16} wrap style={{ paddingTop: 16 }}>
{group.components.map((component, i) => (
<DraggableCreateComponent key={i} component={component} />
))}
</Flex>
</div>
))}
{createPortal(
<DragOverlay>
{isDragging
? (() => {
const comp = componentsGroups
.flatMap((group) => group.components)
.find((comp) => "draggable_" + comp.name === isDragging);
console.log("dragging", comp);
if (!comp) {
return null;
}
return (
<div style={{}}>
<CreateComponent component={comp} />
</div>
);
})()
: null}
</DragOverlay>,
document.body
)}
</DndContext>
</MyContainer>
);
}
export function DraggableCreateComponent({
component,
}: {
component: Component;
}) {
const dispatch = useDispatch();
const { attributes, listeners, setNodeRef, transform, isDragging, active } =
useDraggable({
id: "draggable_" + component.name,
});
return (
<>
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
transition: !isDragging ? "all 0.3s ease-out" : "none",
boxShadow: isDragging ? "0px 0px 10px 0px #00000040" : "none",
opacity: isDragging ? 0.25 : 1,
}}
onClick={() => {
console.log("insert component", component.type);
dispatch(addLessonContent(component.type));
}}
>
<CreateComponent component={component} />
</div>
</>
);
}
function CreateComponent({ component }: { component: Component }) {
const dispatch = useDispatch();
const isDarkMode = useSelector(darkMode);
return (
<Flex
key={i}
vertical
align="center"
justify="center"
style={{
backgroundColor: "#EBEBEB",
backgroundColor: !isDarkMode ? "#f0f2f5" : "#1f1f1f",
height: 80,
width: 80,
cursor: "pointer",
@ -258,13 +323,26 @@ export function SideMenuEditorContent() {
dispatch(addLessonContent(component.type));
}}
>
<div>Thumbnail</div>
<div>{component.name}</div>
</Flex>
))}
</Flex>
{component.thumbnail ? (
<div>
<img
src={component.thumbnail}
style={{
width: 40,
filter:
isDarkMode && component.invertThumbnailAtDarkmode
? "invert(1)"
: "invert(0)",
}}
/>
</div>
))}
</MyContainer>
) : null}
<Typography.Text style={{ fontSize: 12 }}>
{component.name}
</Typography.Text>
</Flex>
);
}
//console.log("insert component", component.type);
//dispatch(addLessonContent(component.type));

30
src/core/helper/api.ts Normal file
View File

@ -0,0 +1,30 @@
import { FetchArgs, fetchBaseQuery } from "@reduxjs/toolkit/query";
import { Constants, handleLogout } from "core/utils/utils";
export function getApiHeader() {
return {
"X-Authorization": localStorage.getItem("session") || "",
};
}
const baseQuery = fetchBaseQuery({
baseUrl: Constants.API_ADDRESS,
prepareHeaders: (headers) => {
headers.set("X-Authorization", localStorage.getItem("session") || "");
return headers;
},
});
export const baseQueryWithErrorHandling = async (
args: string | FetchArgs,
api: any,
extraOptions: any
) => {
const result = await baseQuery(args, api, extraOptions);
if (result.error && result.error.status === 401) {
console.error("Unauthorized. Please log in again.");
handleLogout();
}
return result;
};

View File

@ -0,0 +1,65 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithErrorHandling } from "core/helper/api";
import {
Lesson,
LessonPreview,
UpdateLessonPreviewThumbnail,
} from "core/types/lesson";
import { LessonContent } from "features/Lessons/LessonPage";
export const lessonsApi = createApi({
reducerPath: "lessonsApi",
baseQuery: baseQueryWithErrorHandling,
endpoints: (builder) => ({
getLessons: builder.query<Lesson[], undefined>({
query: () => ({
url: "lessons",
method: "GET",
}),
}),
createLesson: builder.mutation({
query: () => ({
url: "lessons",
method: "POST",
}),
}),
getLessonPreview: builder.query<LessonPreview, string>({
query: (lessonId) => ({
url: `lessons/${lessonId}/preview`,
method: "GET",
}),
}),
getLessonContents: builder.query<LessonContent[], string>({
query: (lessonId) => ({
url: `lessons/${lessonId}/contents`,
method: "GET",
}),
}),
updateLessonPreviewTitle: builder.mutation({
query: ({ lessonId, newTitle }) => ({
url: `lessons/${lessonId}/preview/title`,
method: "PATCH",
body: { Title: newTitle },
}),
}),
updateLessonPreviewThumbnail: builder.mutation<
void,
UpdateLessonPreviewThumbnail
>({
query: ({ lessonId, formData }) => ({
url: `lessons/${lessonId}/preview/thumbnail`,
method: "PATCH",
body: formData,
}),
}),
}),
});
export const {
useGetLessonsQuery,
useCreateLessonMutation,
useGetLessonPreviewQuery,
useGetLessonContentsQuery,
useUpdateLessonPreviewTitleMutation,
useUpdateLessonPreviewThumbnailMutation,
} = lessonsApi;

View File

@ -0,0 +1,68 @@
import { BrowserTabSession, Constants } from "core/utils/utils";
interface WebSocketMessage {
type: string;
payload: any;
}
class WebSocketService {
private url: string;
private socket: WebSocket | null = null;
private reconnectInterval: number = 10000; // 5 Sekunden
private handlers: Record<string, (payload: any) => void> = {};
constructor(url: string) {
this.url = `${url}?auth=${localStorage.getItem(
"session"
)}&bts=${BrowserTabSession}`;
}
public connect(): void {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
console.log("WebSocket connected");
};
this.socket.onmessage = (event: MessageEvent) => {
const data: WebSocketMessage = JSON.parse(event.data);
this.handleMessage(data);
};
this.socket.onclose = () => {
console.log("WebSocket disconnected. Reconnecting...");
setTimeout(() => this.connect(), this.reconnectInterval);
};
this.socket.onerror = (error: Event) => {
console.error("WebSocket error:", error);
};
}
private handleMessage(data: WebSocketMessage): void {
const { type, payload } = data;
if (this.handlers[type]) {
this.handlers[type](payload);
}
}
public onMessage(type: string, handler: (payload: any) => void): void {
this.handlers[type] = handler;
}
public send(type: string, payload: any): void {
const message: WebSocketMessage = { type, payload };
if (this.socket) {
this.socket.send(JSON.stringify(message));
}
}
public disconnect(): void {
if (this.socket) {
this.socket.close();
}
}
}
const webSocketService = new WebSocketService(Constants.WS_ADDRESS);
export default webSocketService;

View File

@ -2,17 +2,22 @@ import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { sideMenuSlice } from "../components/SideMenu/sideMenuSlice";
import { lessonPageEditorSlice } from "../../features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { appSlice } from "./appSlice";
import { appSlice } from "../reducers/appSlice";
import { lessonsApi } from "core/services/lessons";
export const makeStore = (/* preloadedState */) => {
const makeStore = (/* preloadedState */) => {
const store = configureStore({
reducer: {
app: appSlice.reducer,
sideMenu: sideMenuSlice.reducer,
lessonPageEditor: lessonPageEditorSlice.reducer,
// counter: counterSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer,
},
// preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
lessonsApi.middleware
),
});
setupListeners(store.dispatch);

25
src/core/types/lesson.ts Normal file
View File

@ -0,0 +1,25 @@
export interface Lesson {
Id: string;
State: number;
Title: string;
ThumbnailUrl: string;
CreatorUserId: string;
CreatedAt: string;
}
// used for the preview card on /lessions page and on the lesson editor
export interface LessonPreview {
Title: string;
ThumbnailUrl: string;
}
// used on lesson page and on the lesson editor
export interface LessonContent {
Id: string;
Title: string;
}
export interface UpdateLessonPreviewThumbnail {
lessonId: string;
formData: FormData;
}

View File

@ -1,7 +1,12 @@
import { Buffer } from "buffer";
import { v4 as uuidv4 } from "uuid";
const wssProtocol = window.location.protocol === "https:" ? "wss://" : "ws://";
export const Constants = {
API_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/v1`,
WS_ADDRESS: `${wssProtocol}${window.location.hostname}/apiws`,
STATIC_CONTENT_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/static`,
ROUTE_PATHS: {
LESSIONS: {
ROOT: "/lessons",
@ -18,11 +23,33 @@ export const Constants = {
STYLES: {
BLACK: "#000",
},
MAX_IMAGE_SIZE: 25 * 1024 * 1024, // 25MB
ACCEPTED_IMAGE_FILE_TYPES: [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
],
};
// used for sideMenu
export const BreakpointLgWidth = 992;
export function GetUuid() {
return uuidv4();
}
export function getImageUrl(imageName: string) {
return `${Constants.STATIC_CONTENT_ADDRESS}/${imageName}`;
}
// needed for a user who uses multiple tabs in the browser
// with the same session id because otherwise the last browser
// tab would subscribe to the topic and the other tabs would
// not receive any messages
// used for topic subscription
export const BrowserTabSession = GetUuid();
export function getUserSessionFromLocalStorage() {
return localStorage.getItem("session");
}
@ -45,8 +72,7 @@ export const myFetchContentType = {
FORM_DATA: 1,
};
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
interface MyFetchOptions<TRequest = any, TResponse = any> {
url?: string;

View File

@ -8,7 +8,7 @@ import {
} from "../../../core/utils/utils";
import { useState } from "react";
import { useDispatch } from "react-redux";
import { setUserAuthenticated } from "../../../core/store/appSlice";
import { setUserAuthenticated } from "../../../core/reducers/appSlice";
type FieldType = {
email: string;

View File

@ -1,11 +1,14 @@
import { Button, Card, Flex, Typography } from "antd";
import img from "./pexels-photo-302902.webp";
import { MyContainer } from "../../../shared/components/MyContainer";
import { CheckOutlined } from "@ant-design/icons";
import HeaderBar from "../../../core/components/Header";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { Constants } from "../../../core/utils/utils";
import React from "react";
import MySpin from "shared/components/MySpin";
import MyErrorResult from "shared/components/MyResult";
import MyEmpty from "shared/components/MyEmpty";
import { useGetLessonContentsQuery } from "core/services/lessons";
export type LessonContent = {
id: string;
@ -14,6 +17,7 @@ export type LessonContent = {
data: string;
};
/*
const LessonContents = [
{
id: "0",
@ -39,7 +43,7 @@ const LessonContents = [
type: 1,
data: "Think a moment in silence. What makes you really happy? Are you the only one with this? Probably you could sell this knowledge to others! Think about it",
},
] as LessonContent[];
] as LessonContent[]; */
export function Converter({
mode,
@ -108,17 +112,42 @@ export function Converter({
}
}
const LessonContents: React.FC = () => {
const { lessonId } = useParams();
const { data, error, isLoading } = useGetLessonContentsQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
if (isLoading) return <MySpin />;
if (error) return <MyErrorResult />;
if (!data || data.length === 0) return <MyEmpty />;
return (
<>
{data.map((lessonContent) => (
<div key={lessonContent.id} style={{ paddingBottom: 6 }}>
<Converter mode="view" lessonContent={lessonContent} />
</div>
))}
</>
);
};
export default function LessonPage() {
const location = useLocation()
const navigate = useNavigate()
const location = useLocation();
const navigate = useNavigate();
return (
<>
<HeaderBar
theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
onEdit={() =>
navigate(`${location.pathname}/editor`)}
onEdit={() => navigate(`${location.pathname}/editor`)}
/>
<MyContainer>
@ -129,11 +158,7 @@ export default function LessonPage() {
maxWidth: 800,
}}
>
{LessonContents.map((lessonContent) => (
<div key={lessonContent.id} style={{ paddingBottom: 6 }}>
<Converter mode="view" lessonContent={lessonContent} />
</div>
))}
<LessonContents />
<Flex justify="right">
<Button type="primary" icon={<CheckOutlined />}>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,52 @@
import { DndContext, DragEndEvent, useDroppable } from "@dnd-kit/core";
import { verticalListSortingStrategy, SortableContext } from "@dnd-kit/sortable";
import SortableEditorItem from "./SortableEditorItem";
import React from "react";
import { LessonContent } from "../LessonPage";
import { store } from "core/store/store";
import {
restrictToVerticalAxis,
restrictToWindowEdges,
} from "@dnd-kit/modifiers";
import { lessonContents, onDragHandler } from "./lessonPageEditorSlice";
const Droppable = ({ items }: { items: LessonContent[] }) => {
const droppableID = "editorComponentArea";
const { setNodeRef } = useDroppable({ id: droppableID });
const itemIDs = items.map((item) => item.id);
return (
<DndContext modifiers={[restrictToVerticalAxis, restrictToWindowEdges]} onDragEnd={handleDragEnd}>
<SortableContext
id={droppableID}
items={itemIDs}
strategy={verticalListSortingStrategy}
>
<div ref={setNodeRef} >
{items.map((item) => (
<SortableEditorItem key={`${droppableID}_${item.id}`} item={item} />
))}
</div>
</SortableContext>
</DndContext>
);
};
function handleDragEnd(event: DragEndEvent) {
console.log("drag end",event);
if(!event.over) return;
const activeId = event.active.id;
const overId = event.over.id;
store.dispatch(onDragHandler({activeId, overId}));
}
export default Droppable;

View File

@ -0,0 +1,57 @@
import React from "react";
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Converter, LessonContent } from "../LessonPage";
import { HolderOutlined, DeleteOutlined } from "@ant-design/icons";
import { Flex } from "antd";
import { setLessonContent, deleteLessonContent } from "./lessonPageEditorSlice";
import { useDispatch } from "react-redux";
const animateLayoutChanges = (args: any) =>
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true;
const SortableEditorItem = (props: {item:LessonContent}) => {
const lnContent = props.item;
const {
attributes,
listeners,
setNodeRef,
transform,
transition
} = useSortable({ id: lnContent.id, animateLayoutChanges });
const dispatch = useDispatch();
return (
<div style={{
transform: CSS.Translate.toString(transform),
transition
}} ref={setNodeRef} {...attributes} >
<Flex key={lnContent.id} >
<HolderOutlined style={{paddingLeft:8,paddingRight:8, touchAction: "none", cursor: "move"}}{...listeners}/>
<Converter
mode="edititable"
lessonContent={lnContent}
onEdit={(data) =>
dispatch(
setLessonContent({
id: lnContent.id,
data: data,
})
)
}
/>
<DeleteOutlined
onClick={() => {
console.log("delete", lnContent.id);
dispatch(deleteLessonContent(lnContent.id));
}}
/>
</Flex>
</div>
);
};
export default SortableEditorItem;

View File

@ -1,20 +1,65 @@
import { useNavigate, useParams } from "react-router-dom";
import {
deleteLessonContent,
lessonContents,
lessonThumbnail,
setEditorActive,
setLessonContent,
setLessonThumbnailTitle,
} from "./lessonPageEditorSlice";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Converter, LessonContent } from "../LessonPage";
import { Card, Flex, Typography } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { LessonContent } from "../LessonPage";
import { Card, Flex } from "antd";
import { Constants } from "../../../core/utils/utils";
import HeaderBar from "../../../core/components/Header";
import img from "../LessonPage/pexels-photo-302902.webp";
import Droppable from "./Droppable";
import LessonPreviewCard from "../../../shared/components/MyLessonPreviewCard";
import {
useGetLessonPreviewQuery,
useUpdateLessonPreviewTitleMutation,
} from "core/services/lessons";
import MyErrorResult from "shared/components/MyResult";
import styles from "./styles.module.css";
const PreviewCard: React.FC = () => {
const { lessonId } = useParams();
const { data, error, isLoading, refetch } = useGetLessonPreviewQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
const [updateLessonPreviewTitle, {}] = useUpdateLessonPreviewTitleMutation();
if (error) return <MyErrorResult />;
return (
<LessonPreviewCard
mode="editable"
lessonId={lessonId as string}
loading={isLoading}
lessonPreview={{
Title: data?.Title || "",
ThumbnailUrl: data?.ThumbnailUrl || "",
}}
onEditTitle={async (newTitle) => {
try {
const res = await updateLessonPreviewTitle({
lessonId: lessonId as string,
newTitle: newTitle,
}).unwrap();
if (res) {
refetch();
}
} catch (err) {
console.error(err);
}
}}
onThumbnailChanged={refetch}
/>
);
};
export default function LessonPageEditor() {
const { lessonId } = useParams();
@ -51,65 +96,17 @@ export default function LessonPageEditor() {
/>
<Flex justify="center" style={{ paddingTop: 24 }}>
<Flex justify="center" vertical gap={16}>
<Card
style={{
width: 800,
maxWidth: 800,
}}
<Flex
justify="center"
vertical
gap={16}
className={styles.cardContainer}
>
<Flex vertical={false} style={{ gap: 16 }}>
<img src={img} alt="img1" style={{ height: 140 }} />
<PreviewCard />
<Flex align="center" style={{ width: "100%" }}>
<Typography.Title
level={2}
editable={{
triggerType: "text" as any,
tooltip: "click to edit text",
onChange: (event) =>
dispatch(setLessonThumbnailTitle(event)),
}}
style={{
width: "100%",
}}
>
{lnThumbnail.title}
</Typography.Title>
</Flex>
</Flex>
</Card>
<Card
style={{
width: 800,
maxWidth: 800,
}}
>
<Card>
<Flex vertical gap={16}>
{lnContents.map((lnContent) => (
<Flex key={lnContent.id}>
<Converter
mode="edititable"
lessonContent={lnContent}
onEdit={(data) =>
dispatch(
setLessonContent({
id: lnContent.id,
data: data,
})
)
}
/>
<DeleteOutlined
onClick={() => {
console.log("delete", lnContent.id);
dispatch(deleteLessonContent(lnContent.id));
}}
/>
</Flex>
))}
<Droppable items={lnContents} />
</Flex>
</Card>
</Flex>
@ -120,7 +117,7 @@ export default function LessonPageEditor() {
export function StandardEditorCompontent(type: number): LessonContent {
return {
id: Math.floor(Math.random() * 100).toString(),
id: Math.floor(Math.random() * 10000).toString(),
position: 1,
type: type,
data: "Some data",

View File

@ -34,6 +34,27 @@ export const lessonPageEditorSlice = createSlice({
setLessonThumbnailTitle: (state, action) => {
state.lessonThumbnail.title = action.payload;
},
onDragHandler: (state, action) => {
const { activeId, overId } = action.payload as {
activeId: string;
overId: string;
};
if (activeId !== overId) {
let oldIndex = state.lessonContents.findIndex(
(item) => item.id === activeId
);
let newIndex = state.lessonContents.findIndex(
(item) => item.id === overId
);
state.lessonContents.splice(
newIndex,
0,
state.lessonContents.splice(oldIndex, 1)[0]
);
}
},
},
selectors: {
editorActive: (state) => state.editorActive,
@ -48,6 +69,7 @@ export const {
deleteLessonContent,
setLessonContent,
setLessonThumbnailTitle,
onDragHandler,
} = lessonPageEditorSlice.actions;
export const { editorActive, lessonContents, lessonThumbnail } =

View File

@ -0,0 +1,10 @@
.cardContainer {
width: 100%;
}
@media screen and (min-width: 750px) {
.cardContainer {
width: 100%;
max-width: 800px;
}
}

View File

@ -0,0 +1,78 @@
// Desc: This file contains the list of components that are used in the Lessons
type ComponentGroup = {
category: string;
components: Component[];
};
export type Component = {
type: number;
name: string;
thumbnail?: string;
invertThumbnailAtDarkmode?: boolean;
};
const componentsGroups: ComponentGroup[] = [
{
category: "Common",
components: [
{
type: 0,
name: "Header",
thumbnail: "/editor/thumbnails/component_thumbnail_header.svg",
invertThumbnailAtDarkmode: true,
},
{
type: 1,
name: "Text",
thumbnail: "/editor/thumbnails/component_thumbnail_text.svg",
invertThumbnailAtDarkmode: true,
},
],
},
{
category: "Media",
components: [
{
type: 0,
name: "Image",
thumbnail: "/editor/thumbnails/component_thumbnail_image.png",
},
{
type: 1,
name: "YouTube",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true,
},
{
type: 1,
name: "Video",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true,
},
],
},
{
category: "HTML",
components: [
{
type: 0,
name: "Iframe",
thumbnail: "/editor/thumbnails/component_thumbnail_iframe.svg",
invertThumbnailAtDarkmode: true,
},
],
},
{
category: "Special",
components: [
{
type: 0,
name: "Banner",
thumbnail: "/editor/thumbnails/component_thumbnail_banner.png",
},
],
},
];
export { componentsGroups };

View File

@ -1,63 +1,83 @@
import { Button, Card, Flex, Segmented } from "antd";
import { Button, Flex, Segmented } from "antd";
import MyBanner from "../../shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer";
import img1 from "./pexels-photo-1181625.webp";
import img2 from "./pexels-photo-302894.webp";
import {
AppstoreOutlined,
BarsOutlined,
CommentOutlined,
PlusOutlined,
} from "@ant-design/icons";
import Search, { SearchProps } from "antd/es/input/Search";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import HeaderBar from "../../core/components/Header";
import {
useCreateLessonMutation,
useGetLessonsQuery,
} from "core/services/lessons";
import MySpin from "shared/components/MySpin";
import { Constants } from "core/utils/utils";
import MyEmpty from "shared/components/MyEmpty";
import MyErrorResult from "shared/components/MyResult";
import LessonPreviewCard from "../../shared/components/MyLessonPreviewCard";
function ListItem({ img, title }: { img: string; title: string }) {
return (
<Link to={"/lessons/aksdmaskdmsad"}>
<Card>
<Flex vertical={false} style={{ gap: 16 }}>
<img src={img} alt="img1" style={{ height: 140 }} />
const CreateLessonButton: React.FC = () => {
const navigate = useNavigate();
const [createLesson, { isLoading }] = useCreateLessonMutation();
<Flex vertical justify="center">
<div
style={{
fontWeight: "bold",
fontSize: 36,
}}
>
{title}
</div>
<div>
<CommentOutlined /> 12 comments
</div>
</Flex>
</Flex>
</Card>
</Link>
const handleCreateLesson = async () => {
try {
const res = await createLesson({}).unwrap();
if (res && res.Id) {
navigate(
Constants.ROUTE_PATHS.LESSIONS.PAGE_EDITOR.replace(
":lessonId",
res.Id
)
);
}
} catch (err) {
console.error(err);
}
};
const data = [
{
img: img1,
title: "How to clean the coffee machine",
},
{
img: img2,
title: "How to clean the coffee machine",
},
{
img: img1,
title: "How to clean the coffee machine",
},
{
img: img2,
title: "How to clean the coffee machine",
},
];
return (
<Button
icon={<PlusOutlined />}
onClick={handleCreateLesson}
loading={isLoading}
>
Create
</Button>
);
};
const LessonList: React.FC = () => {
const { data, error, isLoading } = useGetLessonsQuery(undefined, {
refetchOnMountOrArgChange: true,
});
if (isLoading) return <MySpin />;
if (error) return <MyErrorResult />;
if (!data || data.length === 0) return <MyEmpty />;
return (
<>
{data.map((item, index) => (
<LessonPreviewCard
key={index}
mode="view"
lessonId={item.Id}
loading={false}
lessonPreview={{
Title: item.Title,
ThumbnailUrl: item.ThumbnailUrl,
}}
/>
))}
</>
);
};
export default function Lessons() {
const onSearch: SearchProps["onSearch"] = (value, _e, info) =>
@ -76,7 +96,7 @@ export default function Lessons() {
]}
/>
<Button icon={<PlusOutlined />}>Create</Button>
<CreateLessonButton />
<Search
placeholder="Search..."
@ -87,11 +107,8 @@ export default function Lessons() {
</Flex>
<Flex vertical gap={16}>
{data.map((item, index) => (
<ListItem key={index} img={item.img} title={item.title} />
))}
<LessonList />
</Flex>
</MyContainer>
</>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1,4 +1,4 @@
import img from "./pexels-photo-269077.jpeg";
import { Constants } from "core/utils/utils";
import styles from "./styles.module.css";
export default function MyBanner({
@ -17,7 +17,7 @@ export default function MyBanner({
}}
>
<img
src={img}
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/organization_banner.jpeg`}
alt="banner"
style={{
height: 228,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

@ -0,0 +1,5 @@
import { Empty } from "antd";
export default function MyEmpty() {
return <Empty />
}

View File

@ -0,0 +1,99 @@
import { CommentOutlined } from "@ant-design/icons";
import { Card, Flex, Typography } from "antd";
import { LessonPreview } from "core/types/lesson";
import { Constants, getImageUrl } from "core/utils/utils";
import { Link } from "react-router-dom";
import MyUpload from "shared/components/MyUpload";
import styles from "./styles.module.css";
export default function MyLessonPreviewCard({
mode = "view",
lessonId,
loading = false,
lessonPreview,
onEditTitle,
onThumbnailChanged,
}: {
mode: "view" | "editable";
lessonId: string;
loading?: boolean;
lessonPreview: LessonPreview;
onEditTitle?: (newTitle: string) => void;
onThumbnailChanged?: () => void;
}) {
const LinkWrapper = ({ children }: { children: React.ReactNode }) => {
if (mode === "editable") return <>{children}</>;
return (
<Link
to={Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(":lessonId", lessonId)}
>
{children}
</Link>
);
};
const UploadWrapper = ({ children }: { children: React.ReactNode }) => {
if (mode === "view") return <>{children}</>;
return (
<MyUpload
action={`/lessons/${lessonId}/preview/thumbnail`}
onChange={(info) => {
if (info.file.status === "done") {
onThumbnailChanged?.();
}
}}
imgCropProps={{
aspect: 5 / 4,
children: <></>,
}}
>
{children}
</MyUpload>
);
};
return (
<LinkWrapper>
<Card loading={loading}>
<div className={styles.card}>
<UploadWrapper>
<img
src={
lessonPreview.ThumbnailUrl === ""
? `${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`
: getImageUrl(lessonPreview.ThumbnailUrl)
}
alt="lesson thumbnail"
className={styles.img}
/>
</UploadWrapper>
<Flex vertical justify="center" style={{ width: "100%" }}>
{mode === "view" ? (
<div>
<div className={styles.title}>{lessonPreview.Title}</div>
<CommentOutlined /> 12 comments
</div>
) : (
<Typography.Title
level={2}
editable={{
triggerType: "text" as any,
tooltip: "click to edit text",
onChange: (event) => onEditTitle?.(event),
}}
style={{
width: "100%",
}}
>
{lessonPreview.Title}
</Typography.Title>
)}
</Flex>
</div>
</Card>
</LinkWrapper>
);
}

View File

@ -0,0 +1,31 @@
.card {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.img {
height: 240px;
object-fit: cover;
}
.title {
font-weight: bold;
font-size: 20px;
word-break: break-all;
}
@media screen and (min-width: 750px) {
.card {
flex-direction: row;
}
.img {
height: 140px;
}
.title {
font-size: 36px;
}
}

View File

@ -0,0 +1,11 @@
import { Result } from "antd";
export default function MyErrorResult() {
return (
<Result
status="error"
title="Something went wrong"
subTitle="Please try again later."
/>
);
}

View File

@ -0,0 +1,57 @@
import ImgCrop, { ImgCropProps } from "antd-img-crop";
import Upload from "antd/es/upload/Upload";
import { getApiHeader } from "core/helper/api";
import { Constants } from "core/utils/utils";
export default function MyUpload({
children,
imgCropProps,
accept = Constants.ACCEPTED_IMAGE_FILE_TYPES.join(","),
maxCount = 1,
showUploadList = false,
headers = getApiHeader(),
action,
onChange,
}: {
children: React.ReactNode;
imgCropProps?: ImgCropProps;
accept?: string;
maxCount?: number;
showUploadList?: boolean;
headers?: any;
action?: string;
onChange?: (info: any) => void;
}) {
const beforeUpload = (file: File) => {
if (!Constants.ACCEPTED_IMAGE_FILE_TYPES.includes(file.type)) {
console.error("File typ not allowed!");
return false;
}
if (file.size > Constants.MAX_IMAGE_SIZE) {
console.error("Image is to large!");
return false;
}
return true;
};
return (
<ImgCrop
{...imgCropProps}
rotationSlider
>
<Upload
accept={accept}
maxCount={maxCount}
showUploadList={showUploadList}
headers={headers}
action={`${Constants.API_ADDRESS}${action}`}
onChange={onChange}
beforeUpload={beforeUpload}
>
{children}
</Upload>
</ImgCrop>
);
}

View File

@ -1,11 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@ -20,7 +17,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"include": ["src"]
}