fetching lessons, update lesson preview and page editor drag and drop
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 13 KiB |
|
@ -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 |
|
@ -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 |
After Width: | Height: | Size: 17 KiB |
|
@ -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 |
After Width: | Height: | Size: 3.2 KiB |
|
@ -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 (
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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,35 +219,130 @@ export function SideMenuEditorContent() {
|
|||
style={{ paddingBottom: 16 }}
|
||||
/>
|
||||
|
||||
{componentsGroups.map((group, i) => (
|
||||
<div key={i}>
|
||||
<span>{group.category}</span>
|
||||
<DndContext
|
||||
onDragStart={(event) => {
|
||||
console.log("drag start", event.active.id);
|
||||
|
||||
<Flex gap={16} wrap style={{ paddingTop: 16 }}>
|
||||
{group.components.map((component, i) => (
|
||||
<Flex
|
||||
key={i}
|
||||
vertical
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
backgroundColor: "#EBEBEB",
|
||||
height: 80,
|
||||
width: 80,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log("insert component", component.type);
|
||||
dispatch(addLessonContent(component.type));
|
||||
}}
|
||||
>
|
||||
<div>Thumbnail</div>
|
||||
<div>{component.name}</div>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
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
|
||||
vertical
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
backgroundColor: !isDarkMode ? "#f0f2f5" : "#1f1f1f",
|
||||
height: 80,
|
||||
width: 80,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log("insert component", component.type);
|
||||
dispatch(addLessonContent(component.type));
|
||||
}}
|
||||
>
|
||||
{component.thumbnail ? (
|
||||
<div>
|
||||
<img
|
||||
src={component.thumbnail}
|
||||
style={{
|
||||
width: 40,
|
||||
filter:
|
||||
isDarkMode && component.invertThumbnailAtDarkmode
|
||||
? "invert(1)"
|
||||
: "invert(0)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Typography.Text style={{ fontSize: 12 }}>
|
||||
{component.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
//console.log("insert component", component.type);
|
||||
//dispatch(addLessonContent(component.type));
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 />}>
|
||||
|
|
Before Width: | Height: | Size: 47 KiB |
|
@ -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;
|
|
@ -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;
|
|
@ -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 vertical={false} style={{ gap: 16 }}>
|
||||
<img src={img} alt="img1" style={{ height: 140 }} />
|
||||
<Flex
|
||||
justify="center"
|
||||
vertical
|
||||
gap={16}
|
||||
className={styles.cardContainer}
|
||||
>
|
||||
<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",
|
||||
|
|
|
@ -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 } =
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
.cardContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 750px) {
|
||||
.cardContainer {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
|
@ -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 };
|
|
@ -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";
|
||||
|
||||
const CreateLessonButton: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [createLesson, { isLoading }] = useCreateLessonMutation();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 }} />
|
||||
|
||||
<Flex vertical justify="center">
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 36,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div>
|
||||
<CommentOutlined /> 12 comments
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Link>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateLesson}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
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..."
|
||||
|
@ -85,14 +105,11 @@ export default function Lessons() {
|
|||
allowClear
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
|
||||
<Flex vertical gap={16}>
|
||||
{data.map((item, index) => (
|
||||
<ListItem key={index} img={item.img} title={item.title} />
|
||||
))}
|
||||
<LessonList />
|
||||
</Flex>
|
||||
|
||||
</MyContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 62 KiB |
|
@ -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,
|
||||
|
|
Before Width: | Height: | Size: 124 KiB |
|
@ -0,0 +1,5 @@
|
|||
import { Empty } from "antd";
|
||||
|
||||
export default function MyEmpty() {
|
||||
return <Empty />
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
|
|