diff --git a/package-lock.json b/package-lock.json index 5baac9c..7a03a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 7661372..4ef4101 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/editor/thumbnails/component_thumbnail_banner.png b/public/editor/thumbnails/component_thumbnail_banner.png new file mode 100644 index 0000000..e9b8cce Binary files /dev/null and b/public/editor/thumbnails/component_thumbnail_banner.png differ diff --git a/public/editor/thumbnails/component_thumbnail_header.svg b/public/editor/thumbnails/component_thumbnail_header.svg new file mode 100644 index 0000000..028689c --- /dev/null +++ b/public/editor/thumbnails/component_thumbnail_header.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/editor/thumbnails/component_thumbnail_iframe.svg b/public/editor/thumbnails/component_thumbnail_iframe.svg new file mode 100644 index 0000000..043eeec --- /dev/null +++ b/public/editor/thumbnails/component_thumbnail_iframe.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/editor/thumbnails/component_thumbnail_image.png b/public/editor/thumbnails/component_thumbnail_image.png new file mode 100644 index 0000000..8deae56 Binary files /dev/null and b/public/editor/thumbnails/component_thumbnail_image.png differ diff --git a/public/editor/thumbnails/component_thumbnail_text.svg b/public/editor/thumbnails/component_thumbnail_text.svg new file mode 100644 index 0000000..c53997d --- /dev/null +++ b/public/editor/thumbnails/component_thumbnail_text.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/editor/thumbnails/component_thumbnail_youtube.png b/public/editor/thumbnails/component_thumbnail_youtube.png new file mode 100644 index 0000000..ca31703 Binary files /dev/null and b/public/editor/thumbnails/component_thumbnail_youtube.png differ diff --git a/src/App.tsx b/src/App.tsx index 8d01df5..ae8672c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( diff --git a/src/core/components/DashboardLayout/MyDndContext.tsx b/src/core/components/DashboardLayout/MyDndContext.tsx new file mode 100644 index 0000000..f216127 --- /dev/null +++ b/src/core/components/DashboardLayout/MyDndContext.tsx @@ -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 {children} + ; +} + + +export default MyDndContext; \ No newline at end of file diff --git a/src/core/components/DashboardLayout/index.tsx b/src/core/components/DashboardLayout/index.tsx index 2f9d104..4505bcf 100644 --- a/src/core/components/DashboardLayout/index.tsx +++ b/src/core/components/DashboardLayout/index.tsx @@ -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 ( + @@ -51,5 +54,6 @@ export default function DashboardLayout() { + ); } diff --git a/src/core/components/Header/index.tsx b/src/core/components/Header/index.tsx index f6a2531..6c4f6a9 100644 --- a/src/core/components/Header/index.tsx +++ b/src/core/components/Header/index.tsx @@ -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 = { diff --git a/src/core/components/SideMenu/index.tsx b/src/core/components/SideMenu/index.tsx index 44e6564..e285d27 100644 --- a/src/core/components/SideMenu/index.tsx +++ b/src/core/components/SideMenu/index.tsx @@ -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(null); return ( @@ -236,35 +219,130 @@ export function SideMenuEditorContent() { style={{ paddingBottom: 16 }} /> - {componentsGroups.map((group, i) => ( -
- {group.category} + { + console.log("drag start", event.active.id); - - {group.components.map((component, i) => ( - { - console.log("insert component", component.type); - dispatch(addLessonContent(component.type)); - }} - > -
Thumbnail
-
{component.name}
-
- ))} -
-
- ))} + setIsDragging(event.active.id.toString()); + }} + onDragEnd={(evnet) => { + console.log("drag end", evnet.active.id); + setIsDragging(null); + }} + > + {componentsGroups.map((group, i) => ( +
+ {group.category} + + + {group.components.map((component, i) => ( + + ))} + +
+ ))} + {createPortal( + + {isDragging + ? (() => { + const comp = componentsGroups + .flatMap((group) => group.components) + .find((comp) => "draggable_" + comp.name === isDragging); + console.log("dragging", comp); + if (!comp) { + return null; + } + + return ( +
+ +
+ ); + })() + : null} +
, + document.body + )} +
); } + +export function DraggableCreateComponent({ + component, +}: { + component: Component; +}) { + const dispatch = useDispatch(); + + const { attributes, listeners, setNodeRef, transform, isDragging, active } = + useDraggable({ + id: "draggable_" + component.name, + }); + + return ( + <> +
{ + console.log("insert component", component.type); + dispatch(addLessonContent(component.type)); + }} + > + +
+ + ); +} + +function CreateComponent({ component }: { component: Component }) { + const dispatch = useDispatch(); + const isDarkMode = useSelector(darkMode); + + return ( + { + console.log("insert component", component.type); + dispatch(addLessonContent(component.type)); + }} + > + {component.thumbnail ? ( +
+ +
+ ) : null} + + {component.name} + +
+ ); +} + +//console.log("insert component", component.type); +//dispatch(addLessonContent(component.type)); diff --git a/src/core/helper/api.ts b/src/core/helper/api.ts new file mode 100644 index 0000000..f9a6e1b --- /dev/null +++ b/src/core/helper/api.ts @@ -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; +}; diff --git a/src/core/store/appSlice.tsx b/src/core/reducers/appSlice.tsx similarity index 100% rename from src/core/store/appSlice.tsx rename to src/core/reducers/appSlice.tsx diff --git a/src/core/services/lessons.ts b/src/core/services/lessons.ts new file mode 100644 index 0000000..631f860 --- /dev/null +++ b/src/core/services/lessons.ts @@ -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({ + query: () => ({ + url: "lessons", + method: "GET", + }), + }), + createLesson: builder.mutation({ + query: () => ({ + url: "lessons", + method: "POST", + }), + }), + getLessonPreview: builder.query({ + query: (lessonId) => ({ + url: `lessons/${lessonId}/preview`, + method: "GET", + }), + }), + getLessonContents: builder.query({ + 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; diff --git a/src/core/services/websocketService.ts b/src/core/services/websocketService.ts new file mode 100644 index 0000000..f94fa81 --- /dev/null +++ b/src/core/services/websocketService.ts @@ -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 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; diff --git a/src/core/store/store.tsx b/src/core/store/store.tsx index 08eefd7..b246658 100644 --- a/src/core/store/store.tsx +++ b/src/core/store/store.tsx @@ -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); diff --git a/src/core/types/lesson.ts b/src/core/types/lesson.ts new file mode 100644 index 0000000..64b3e0c --- /dev/null +++ b/src/core/types/lesson.ts @@ -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; +} diff --git a/src/core/utils/utils.tsx b/src/core/utils/utils.tsx index 908ea56..b8d8fb9 100644 --- a/src/core/utils/utils.tsx +++ b/src/core/utils/utils.tsx @@ -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 { url?: string; diff --git a/src/features/Auth/SignIn/index.tsx b/src/features/Auth/SignIn/index.tsx index 38f3644..5ab2cfa 100644 --- a/src/features/Auth/SignIn/index.tsx +++ b/src/features/Auth/SignIn/index.tsx @@ -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; diff --git a/src/features/Lessons/LessonPage/index.tsx b/src/features/Lessons/LessonPage/index.tsx index 9c45e39..4410e07 100644 --- a/src/features/Lessons/LessonPage/index.tsx +++ b/src/features/Lessons/LessonPage/index.tsx @@ -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 ; + if (error) return ; + + if (!data || data.length === 0) return ; + + return ( + <> + {data.map((lessonContent) => ( +
+ +
+ ))} + + ); +}; + export default function LessonPage() { - const location = useLocation() - const navigate = useNavigate() + const location = useLocation(); + const navigate = useNavigate(); return ( <> - navigate(`${location.pathname}/editor`)} + onEdit={() => navigate(`${location.pathname}/editor`)} /> @@ -129,11 +158,7 @@ export default function LessonPage() { maxWidth: 800, }} > - {LessonContents.map((lessonContent) => ( -
- -
- ))} + ); -} +}; -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 ; + if (error) return ; + + if (!data || data.length === 0) return ; + + return ( + <> + {data.map((item, index) => ( + + ))} + + ); +}; export default function Lessons() { const onSearch: SearchProps["onSearch"] = (value, _e, info) => @@ -76,7 +96,7 @@ export default function Lessons() { ]} /> - + - + - {data.map((item, index) => ( - - ))} + -
); -} \ No newline at end of file +} diff --git a/src/features/Lessons/pexels-photo-1181625.webp b/src/features/Lessons/pexels-photo-1181625.webp deleted file mode 100644 index 92653a4..0000000 Binary files a/src/features/Lessons/pexels-photo-1181625.webp and /dev/null differ diff --git a/src/features/Lessons/pexels-photo-302894.webp b/src/features/Lessons/pexels-photo-302894.webp deleted file mode 100644 index 454ef35..0000000 Binary files a/src/features/Lessons/pexels-photo-302894.webp and /dev/null differ diff --git a/src/shared/components/MyBanner/index.tsx b/src/shared/components/MyBanner/index.tsx index a8620f5..989a136 100644 --- a/src/shared/components/MyBanner/index.tsx +++ b/src/shared/components/MyBanner/index.tsx @@ -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({ }} > banner +} \ No newline at end of file diff --git a/src/shared/components/MyLessonPreviewCard/index.tsx b/src/shared/components/MyLessonPreviewCard/index.tsx new file mode 100644 index 0000000..beb1906 --- /dev/null +++ b/src/shared/components/MyLessonPreviewCard/index.tsx @@ -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 ( + + {children} + + ); + }; + + const UploadWrapper = ({ children }: { children: React.ReactNode }) => { + if (mode === "view") return <>{children}; + + return ( + { + if (info.file.status === "done") { + onThumbnailChanged?.(); + } + }} + imgCropProps={{ + aspect: 5 / 4, + children: <>, + }} + > + {children} + + ); + }; + + return ( + + +
+ + lesson thumbnail + + + + {mode === "view" ? ( +
+
{lessonPreview.Title}
+ 12 comments +
+ ) : ( + onEditTitle?.(event), + }} + style={{ + width: "100%", + }} + > + {lessonPreview.Title} + + )} +
+
+
+
+ ); +} diff --git a/src/shared/components/MyLessonPreviewCard/styles.module.css b/src/shared/components/MyLessonPreviewCard/styles.module.css new file mode 100644 index 0000000..33f3a65 --- /dev/null +++ b/src/shared/components/MyLessonPreviewCard/styles.module.css @@ -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; + } +} diff --git a/src/shared/components/MyResult/index.tsx b/src/shared/components/MyResult/index.tsx new file mode 100644 index 0000000..07e18b1 --- /dev/null +++ b/src/shared/components/MyResult/index.tsx @@ -0,0 +1,11 @@ +import { Result } from "antd"; + +export default function MyErrorResult() { + return ( + + ); +} diff --git a/src/shared/components/MyUpload/index.tsx b/src/shared/components/MyUpload/index.tsx new file mode 100644 index 0000000..7bd45a1 --- /dev/null +++ b/src/shared/components/MyUpload/index.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/tsconfig.json b/tsconfig.json index a273b0c..545c655 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }