editor, drag and drop, settings organization and account

main
alex 2024-09-01 23:39:28 +02:00
parent 23b9a7e249
commit 00dee1ba9e
27 changed files with 1305 additions and 347 deletions

View File

@ -11,6 +11,8 @@ import Settings from "../../../features/Settings";
import PageNotFound from "../../../features/PageNotFound"; import PageNotFound from "../../../features/PageNotFound";
import LessonPage from "../../../features/Lessons/LessonPage"; import LessonPage from "../../../features/Lessons/LessonPage";
import LessonPageEditor from "../../../features/Lessons/LessonPageEditor"; import LessonPageEditor from "../../../features/Lessons/LessonPageEditor";
import TeamCreateUser from "features/Team/CreateUser";
import AccountSettings from "features/AccountSettings";
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
@ -51,6 +53,15 @@ export default function AppRoutes() {
} }
/> />
<Route
path={Constants.ROUTE_PATHS.ORGANIZATION_TEAM_CREATE_USER}
element={
<MySupsenseFallback>
<TeamCreateUser />
</MySupsenseFallback>
}
/>
<Route <Route
path={Constants.ROUTE_PATHS.ORGANIZATION_ROLES} path={Constants.ROUTE_PATHS.ORGANIZATION_ROLES}
element={ element={
@ -60,7 +71,7 @@ export default function AppRoutes() {
} }
/> />
<Route <Route
path={Constants.ROUTE_PATHS.ORGANIZATION_SETTINGS} path={Constants.ROUTE_PATHS.ORGANIZATION_SETTINGS}
element={ element={
<MySupsenseFallback> <MySupsenseFallback>
@ -69,6 +80,15 @@ export default function AppRoutes() {
} }
/> />
<Route
path={Constants.ROUTE_PATHS.ACCOUNT_SETTINGS}
element={
<MySupsenseFallback>
<AccountSettings />
</MySupsenseFallback>
}
/>
<Route <Route
path={Constants.ROUTE_PATHS.WHATS_NEW} path={Constants.ROUTE_PATHS.WHATS_NEW}
element={ element={

View File

@ -3,7 +3,7 @@ import { DraggableCreateComponent } from "../SideMenu";
import { componentsGroups } from "features/Lessons/components"; import { componentsGroups } from "features/Lessons/components";
function MyDndContext({ children }: { children: React.ReactNode }) { function MyDndContext({ children }: { children: React.ReactNode }) {
return <DndContext collisionDetection={closestCenter}>{children} return <DndContext >{children}
</DndContext>; </DndContext>;
} }

View File

@ -7,9 +7,9 @@ import {
TeamOutlined, TeamOutlined,
WalletOutlined, WalletOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Divider, Flex, Menu, Typography } from "antd"; import { Divider, Flex, Form, Menu, Select, Typography } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { import {
setIsSideMenuCollapsed, setIsSideMenuCollapsed,
@ -20,16 +20,21 @@ import { ItemType, MenuItemType } from "antd/es/menu/interface";
import { BreakpointLgWidth, Constants } from "core/utils/utils"; import { BreakpointLgWidth, Constants } from "core/utils/utils";
import Search from "antd/es/input/Search"; import Search from "antd/es/input/Search";
import { MyContainer } from "shared/components/MyContainer"; import { MyContainer } from "shared/components/MyContainer";
import { addLessonContent } from "features/Lessons/LessonPageEditor/lessonPageEditorSlice"; import {
addLessonContent,
currentLessonId,
lessonState,
} from "features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { Component, componentsGroups } from "features/Lessons/components"; import { Component, componentsGroups } from "features/Lessons/components";
import { darkMode } from "core/reducers/appSlice"; import { darkMode } from "core/reducers/appSlice";
import { DndContext, DragOverlay, useDraggable } from "@dnd-kit/core"; 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"; import { createPortal } from "react-dom";
import { LessonState } from "core/types/lesson";
import { useForm } from "antd/es/form/Form";
import {
useAddLessonContentMutation,
useUpdateLessonStateMutation,
} from "core/services/lessons";
export function SideMenuContent() { export function SideMenuContent() {
const location = useLocation(); const location = useLocation();
@ -211,8 +216,25 @@ export function SideMenuContent() {
export function SideMenuEditorContent() { export function SideMenuEditorContent() {
// create is dragging useState // create is dragging useState
const [isDragging, setIsDragging] = useState<String | null>(null); const [isDragging, setIsDragging] = useState<String | null>(null);
const { lessonId } = useParams();
const [form] = useForm();
const lnState = useSelector(lessonState);
const currentLnId = useSelector(currentLessonId);
const [updateLessonState] = useUpdateLessonStateMutation();
console.log("lesson state", lnState);
useEffect(() => {
form.setFieldsValue({
state: lnState,
});
}, [lnState]);
return ( return (
<Flex justify="space-between" vertical style={{ height: "100%" }}>
<MyContainer> <MyContainer>
<Search <Search
placeholder="What would you like to insert?" placeholder="What would you like to insert?"
@ -265,6 +287,34 @@ export function SideMenuEditorContent() {
)} )}
</DndContext> </DndContext>
</MyContainer> </MyContainer>
<div style={{ padding: 12 }}>
<Form form={form} layout="vertical">
<Form.Item label="Status" name="state" style={{ marginBottom: 0 }}>
<Select
style={{ width: "100%" }}
onChange={async (value) => {
console.log("state changed", value, lessonId);
try {
await updateLessonState({
lessonId: currentLnId,
newState: value,
}).unwrap();
} catch (err) {
console.log("error", err);
}
}}
>
<Select.Option value={LessonState.Published}>
Published
</Select.Option>
<Select.Option value={LessonState.Draft}>Draft</Select.Option>
</Select>
</Form.Item>
</Form>
</div>
</Flex>
); );
} }
@ -275,27 +325,24 @@ export function DraggableCreateComponent({
}) { }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { attributes, listeners, setNodeRef, transform, isDragging, active } = /*const { attributes, listeners, setNodeRef, transform, isDragging, active } =
useDraggable({ useDraggable({
id: "draggable_" + component.name, id: "draggable_" + component.name,
}); });*/
return ( return (
<> <>
<div <div
ref={setNodeRef} //ref={setNodeRef}
{...attributes} //{...attributes}
{...listeners} //{...listeners}
style={{ style={
transition: !isDragging ? "all 0.3s ease-out" : "none", {
//transition: !isDragging ? "all 0.3s ease-out" : "none",
boxShadow: isDragging ? "0px 0px 10px 0px #00000040" : "none", //boxShadow: isDragging ? "0px 0px 10px 0px #00000040" : "none",
opacity: isDragging ? 0.25 : 1, //opacity: isDragging ? 0.25 : 1,
}} }
onClick={() => { }
console.log("insert component", component.type);
dispatch(addLessonContent(component.type));
}}
> >
<CreateComponent component={component} /> <CreateComponent component={component} />
</div> </div>
@ -307,6 +354,10 @@ function CreateComponent({ component }: { component: Component }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isDarkMode = useSelector(darkMode); const isDarkMode = useSelector(darkMode);
const currentLnId = useSelector(currentLessonId);
const [reqAddLessonContent] = useAddLessonContentMutation();
return ( return (
<Flex <Flex
vertical vertical
@ -318,9 +369,30 @@ function CreateComponent({ component }: { component: Component }) {
width: 80, width: 80,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={() => { onClick={async () => {
console.log("insert component", component.type); console.log("insert component", component.type);
dispatch(addLessonContent(component.type));
try {
const res = await reqAddLessonContent({
lessonId: currentLnId,
type: component.type,
data: component.defaultData || "",
}).unwrap();
console.log("add content", component);
dispatch(
addLessonContent({
Id: res.Id,
Type: component.type,
Data: component.defaultData || "",
Page: 1,
Position: 1,
})
);
} catch (err) {
console.log("error", err);
}
}} }}
> >
{component.thumbnail ? ( {component.thumbnail ? (

View File

@ -2,10 +2,10 @@ import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithErrorHandling } from "core/helper/api"; import { baseQueryWithErrorHandling } from "core/helper/api";
import { import {
Lesson, Lesson,
LessonPreview, LessonContent,
LessonSettings,
UpdateLessonPreviewThumbnail, UpdateLessonPreviewThumbnail,
} from "core/types/lesson"; } from "core/types/lesson";
import { LessonContent } from "features/Lessons/LessonPage";
export const lessonsApi = createApi({ export const lessonsApi = createApi({
reducerPath: "lessonsApi", reducerPath: "lessonsApi",
@ -23,9 +23,9 @@ export const lessonsApi = createApi({
method: "POST", method: "POST",
}), }),
}), }),
getLessonPreview: builder.query<LessonPreview, string>({ getLessonSettings: builder.query<LessonSettings, string>({
query: (lessonId) => ({ query: (lessonId) => ({
url: `lessons/${lessonId}/preview`, url: `lessons/${lessonId}/settings`,
method: "GET", method: "GET",
}), }),
}), }),
@ -52,14 +52,60 @@ export const lessonsApi = createApi({
body: formData, body: formData,
}), }),
}), }),
updateLessonState: builder.mutation({
query: ({ lessonId, newState }) => ({
url: `lessons/${lessonId}/state`,
method: "PATCH",
body: { State: newState },
}),
}),
addLessonContent: builder.mutation({
query: ({ lessonId, type, data }) => ({
url: `lessons/${lessonId}/contents`,
method: "POST",
body: {
Type: type,
Data: data,
},
}),
}),
updateLessonContent: builder.mutation({
query: ({ lessonId, contentId, data }) => ({
url: `lessons/${lessonId}/contents/${contentId}`,
method: "PATCH",
body: {
Data: data,
},
}),
}),
updateLessonContentPosition: builder.mutation({
query: ({ lessonId, contentId, newPosition }) => ({
url: `lessons/${lessonId}/contents/${contentId}/position`,
method: "PATCH",
body: {
Position: newPosition,
},
}),
}),
deleteLessonContent: builder.mutation({
query: ({ lessonId, contentId }) => ({
url: `lessons/${lessonId}/contents/${contentId}`,
method: "DELETE",
}),
}),
}), }),
}); });
export const { export const {
useGetLessonsQuery, useGetLessonsQuery,
useCreateLessonMutation, useCreateLessonMutation,
useGetLessonPreviewQuery, useGetLessonSettingsQuery,
useGetLessonContentsQuery, useGetLessonContentsQuery,
useUpdateLessonPreviewTitleMutation, useUpdateLessonPreviewTitleMutation,
useUpdateLessonPreviewThumbnailMutation, useUpdateLessonPreviewThumbnailMutation,
useUpdateLessonStateMutation,
useAddLessonContentMutation,
useUpdateLessonContentMutation,
useUpdateLessonContentPositionMutation,
useDeleteLessonContentMutation,
} = lessonsApi; } = lessonsApi;

18
src/core/services/team.ts Normal file
View File

@ -0,0 +1,18 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithErrorHandling } from "core/helper/api";
import { TeamMember } from "core/types/team";
export const teamApi = createApi({
reducerPath: "teamApi",
baseQuery: baseQueryWithErrorHandling,
endpoints: (builder) => ({
getTeam: builder.query<TeamMember[], undefined>({
query: () => ({
url: "team/members",
method: "GET",
}),
}),
}),
});
export const { useGetTeamQuery } = teamApi;

View File

@ -4,6 +4,7 @@ import { sideMenuSlice } from "../components/SideMenu/sideMenuSlice";
import { lessonPageEditorSlice } from "../../features/Lessons/LessonPageEditor/lessonPageEditorSlice"; import { lessonPageEditorSlice } from "../../features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { appSlice } from "../reducers/appSlice"; import { appSlice } from "../reducers/appSlice";
import { lessonsApi } from "core/services/lessons"; import { lessonsApi } from "core/services/lessons";
import { teamApi } from "core/services/team";
const makeStore = (/* preloadedState */) => { const makeStore = (/* preloadedState */) => {
const store = configureStore({ const store = configureStore({
@ -12,11 +13,13 @@ const makeStore = (/* preloadedState */) => {
sideMenu: sideMenuSlice.reducer, sideMenu: sideMenuSlice.reducer,
lessonPageEditor: lessonPageEditorSlice.reducer, lessonPageEditor: lessonPageEditorSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer, [lessonsApi.reducerPath]: lessonsApi.reducer,
[teamApi.reducerPath]: teamApi.reducer,
}, },
// preloadedState, // preloadedState,
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat( getDefaultMiddleware().concat(
lessonsApi.middleware lessonsApi.middleware,
teamApi.middleware
), ),
}); });

View File

@ -7,16 +7,25 @@ export interface Lesson {
CreatedAt: string; CreatedAt: string;
} }
export enum LessonState {
Published = 1,
Draft = 2,
}
// used for the preview card on /lessions page and on the lesson editor // used for the preview card on /lessions page and on the lesson editor
export interface LessonPreview { export interface LessonSettings {
Title: string; Title: string;
ThumbnailUrl: string; ThumbnailUrl: string;
State?: LessonState;
} }
// used on lesson page and on the lesson editor // used on lesson page and on the lesson editor
export interface LessonContent { export interface LessonContent {
Id: string; Id: string;
Title: string; Page: number;
Position: number;
Type: number;
Data: string;
} }
export interface UpdateLessonPreviewThumbnail { export interface UpdateLessonPreviewThumbnail {

8
src/core/types/team.ts Normal file
View File

@ -0,0 +1,8 @@
export interface TeamMember {
Id: string;
FirstName: string;
LastName: string;
Email: string;
Role: string;
Status: string;
}

View File

@ -14,8 +14,10 @@ export const Constants = {
PAGE_EDITOR: "/lessons/:lessonId/editor", PAGE_EDITOR: "/lessons/:lessonId/editor",
}, },
ORGANIZATION_TEAM: "/team", ORGANIZATION_TEAM: "/team",
ORGANIZATION_TEAM_CREATE_USER: "/team/create-user",
ORGANIZATION_ROLES: "/roles", ORGANIZATION_ROLES: "/roles",
ORGANIZATION_SETTINGS: "/organization", ORGANIZATION_SETTINGS: "/organization",
ACCOUNT_SETTINGS: "/account",
WHATS_NEW: "/whats-new", WHATS_NEW: "/whats-new",
SUGGEST_FEATURE: "/suggest-feature", SUGGEST_FEATURE: "/suggest-feature",
CONTACT_SUPPORT: "/contact-support", CONTACT_SUPPORT: "/contact-support",

View File

@ -0,0 +1,179 @@
import {
Avatar,
Button,
Card,
Divider,
Flex,
Form,
Input,
Typography,
} from "antd";
import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer";
import { SaveOutlined } from "@ant-design/icons";
import MyUpload from "shared/components/MyUpload";
import { Constants } from "core/utils/utils";
import ColorPicker from "antd/es/color-picker";
import MyMiddleCard from "shared/components/MyMiddleCard";
import Meta from "antd/es/card/Meta";
export function AccountSettingsAdmin() {
return (
<>
<MyBanner
title="Account Settings"
subtitle="MANAGE"
headerBar={<HeaderBar />}
/>
<MyMiddleCard title="My Profile">
<Flex vertical gap={16} >
<Card
styles={{
body: {
padding: 16,
},
}}
>
<Meta
avatar={
<Avatar
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
size={56}
/>
}
title="Jorg Kreith"
description="Lead"
/>
</Card>
<Card
styles={{
body: {
padding: 16,
paddingBottom: 0,
},
}}
>
<Meta title="Personal Information" />
<Form layout="vertical" style={{ marginTop: 24 }}>
<Flex gap={16}>
<Flex flex={1}>
<Form.Item label="First name" name="firstName" style={{width: "100%"}}>
<Input defaultValue="Jorg" />
</Form.Item>
</Flex>
<Flex flex={1}>
<Form.Item label="Last name" name="lastName" style={{width: "100%"}}>
<Input defaultValue="Kreth" />
</Form.Item>
</Flex>
</Flex>
<Form.Item label="Email" name="email">
<Input defaultValue="julian@xx.com" />
</Form.Item>
<Form.Item style={{ textAlign: 'right' }}>
<Button
type="primary"
icon={<SaveOutlined />}
htmlType="submit"
>
Update
</Button>
</Form.Item>
</Form>
</Card>
</Flex>
</MyMiddleCard>
</>
);
}
export default function AccountSettings() {
return (
<>
<MyBanner
title="Account Settings"
subtitle="MANAGE"
headerBar={<HeaderBar />}
/>
<MyMiddleCard title="My Profile">
<Flex vertical gap={16} style={{ paddingBottom: 16 }}>
<Card
styles={{
body: {
padding: 16,
},
}}
>
<Meta
avatar={
<Avatar
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
size={56}
/>
}
title="Jorg Kreith"
description="Lead"
/>
</Card>
<Card
styles={{
body: {
padding: 16,
},
}}
>
<Meta title="Personal Information" />
<Flex gap={16} style={{ marginTop: 24 }}>
<Flex vertical gap={24} flex={1}>
<Flex gap={8} vertical>
<TitleText>First name</TitleText>
<ValueText>Jorg</ValueText>
</Flex>
<Flex gap={8} vertical>
<TitleText>Email</TitleText>
<ValueText>julian@xx.com</ValueText>
</Flex>
</Flex>
<Flex vertical gap={24} flex={1}>
<Flex gap={8} vertical>
<TitleText>Last name</TitleText>
<ValueText>Kreth</ValueText>
</Flex>
</Flex>
</Flex>
</Card>
</Flex>
</MyMiddleCard>
</>
);
}
/*
// TODO: sessions table
<Card
styles={{
body: {
padding: 16,
},
}}
>
<Meta title="Sessions" />
<Typography.Text>// Todoo</Typography.Text>
</Card>
*/
function TitleText({ children }: { children: React.ReactNode }) {
return (
<Typography.Text style={{ fontSize: 16, opacity: 0.4 }}>
{children}
</Typography.Text>
);
}
function ValueText({ children }: { children: React.ReactNode }) {
return <Typography.Text style={{ fontSize: 16 }}>{children}</Typography.Text>;
}

View File

@ -1,5 +1,4 @@
import { Button, Card, Flex, Typography } from "antd"; import { Button, Flex } from "antd";
import { MyContainer } from "../../../shared/components/MyContainer";
import { CheckOutlined } from "@ant-design/icons"; import { CheckOutlined } from "@ant-design/icons";
import HeaderBar from "../../../core/components/Header"; import HeaderBar from "../../../core/components/Header";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
@ -9,108 +8,8 @@ import MySpin from "shared/components/MySpin";
import MyErrorResult from "shared/components/MyResult"; import MyErrorResult from "shared/components/MyResult";
import MyEmpty from "shared/components/MyEmpty"; import MyEmpty from "shared/components/MyEmpty";
import { useGetLessonContentsQuery } from "core/services/lessons"; import { useGetLessonContentsQuery } from "core/services/lessons";
import MyMiddleCard from "shared/components/MyMiddleCard";
export type LessonContent = { import { Converter } from "../converter";
id: string;
position: number;
type: number;
data: string;
};
/*
const LessonContents = [
{
id: "0",
position: 1,
type: 0,
data: "How to clean the coffee machine",
},
{
id: "1",
position: 1,
type: 2,
data: img,
},
{
id: "2",
position: 2,
type: 1,
data: "The proper cleaning of the coffee machine",
},
{
id: "3",
position: 3,
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[]; */
export function Converter({
mode,
lessonContent,
onEdit,
}: {
mode: "view" | "edititable";
lessonContent: LessonContent;
onEdit?: (newData: string) => void;
}) {
// const dispatch = useDispatch();
// const contents = useSelector(lessonContents);
switch (lessonContent.type) {
case 0:
return mode === "view" ? (
<div style={{ fontWeight: "bold", fontSize: 24 }}>
{lessonContent.data}
</div>
) : (
<Typography.Title
editable={{
triggerType: "text" as any,
onChange: (event) => onEdit?.(event),
}}
level={1}
style={{
margin: 0,
width: "100%",
}}
>
{lessonContent.data}
</Typography.Title>
);
case 1:
return mode === "view" ? (
<div style={{ fontSize: 16 }}>{lessonContent.data}</div>
) : (
<Typography.Text
editable={{
triggerType: "text" as any,
onChange: (event) => onEdit?.(event),
}}
style={{
margin: 0,
width: "100%",
}}
>
{lessonContent.data}
</Typography.Text>
);
case 2:
return (
<img src={lessonContent.data} alt="img" style={{ width: "100%" }} />
);
case 3:
return (
<div style={{ fontWeight: "700", fontSize: 20 }}>
{lessonContent.data}
</div>
);
case 4:
return <div style={{ fontSize: 14 }}>{lessonContent.data}</div>;
default:
return <div>Unknown type</div>;
}
}
const LessonContents: React.FC = () => { const LessonContents: React.FC = () => {
const { lessonId } = useParams(); const { lessonId } = useParams();
@ -130,7 +29,7 @@ const LessonContents: React.FC = () => {
return ( return (
<> <>
{data.map((lessonContent) => ( {data.map((lessonContent) => (
<div key={lessonContent.id} style={{ paddingBottom: 6 }}> <div key={lessonContent.Id} style={{ paddingBottom: 6 }}>
<Converter mode="view" lessonContent={lessonContent} /> <Converter mode="view" lessonContent={lessonContent} />
</div> </div>
))} ))}
@ -150,14 +49,7 @@ export default function LessonPage() {
onEdit={() => navigate(`${location.pathname}/editor`)} onEdit={() => navigate(`${location.pathname}/editor`)}
/> />
<MyContainer> <MyMiddleCard>
<Flex justify="center">
<Card
style={{
width: 800,
maxWidth: 800,
}}
>
<LessonContents /> <LessonContents />
<Flex justify="right"> <Flex justify="right">
@ -165,9 +57,7 @@ export default function LessonPage() {
Finish lesson Finish lesson
</Button> </Button>
</Flex> </Flex>
</Card> </MyMiddleCard>
</Flex>
</MyContainer>
</> </>
); );
} }

View File

@ -1,52 +1,87 @@
import { DndContext, DragEndEvent, useDroppable } from "@dnd-kit/core"; import { DndContext, DragEndEvent, useDroppable } from "@dnd-kit/core";
import { verticalListSortingStrategy, SortableContext } from "@dnd-kit/sortable"; import {
verticalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import SortableEditorItem from "./SortableEditorItem"; import SortableEditorItem from "./SortableEditorItem";
import React from "react";
import { LessonContent } from "../LessonPage";
import { store } from "core/store/store"; import { store } from "core/store/store";
import { import {
restrictToVerticalAxis, restrictToVerticalAxis,
restrictToWindowEdges, restrictToWindowEdges,
} from "@dnd-kit/modifiers"; } from "@dnd-kit/modifiers";
import { lessonContents, onDragHandler } from "./lessonPageEditorSlice"; import { currentLessonId, onDragHandler } from "./lessonPageEditorSlice";
import { LessonContent } from "core/types/lesson";
import { useUpdateLessonContentPositionMutation } from "core/services/lessons";
import { useSelector } from "react-redux";
const Droppable = ({ items }: { items: LessonContent[] }) => { const Droppable = ({ items }: { items: LessonContent[] }) => {
const droppableID = "editorComponentArea"; const droppableID = "editorComponentArea";
const { setNodeRef } = useDroppable({ id: droppableID }); const { setNodeRef } = useDroppable({ id: droppableID });
const currentLnId = useSelector(currentLessonId);
const [reqUpdateLessonContentPosition] =
useUpdateLessonContentPositionMutation();
const itemIDs = items.map((item) => item.id); const itemIDs = items.map((item) => item.Id);
const handleDragEnd = (event: DragEndEvent) => {
console.log("drag end", event);
if (!event.over) return;
const activeId = event.active.id;
const overId = event.over.id;
if (activeId === overId) return;
let oldIndex = itemIDs.findIndex((item) => item === activeId);
let newIndex = itemIDs.findIndex((item) => item === overId);
// store.dispatch(onDragHandler({ activeId, overId }));
store.dispatch(onDragHandler({ oldIndex, newIndex }));
try {
reqUpdateLessonContentPosition({
lessonId: currentLnId,
contentId: activeId,
newPosition: newIndex + 1,
});
} catch (err) {
console.error(err);
}
};
return ( return (
<DndContext modifiers={[restrictToVerticalAxis, restrictToWindowEdges]} onDragEnd={handleDragEnd}> <DndContext
modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}
onDragEnd={handleDragEnd}
>
<SortableContext <SortableContext
id={droppableID} id={droppableID}
items={itemIDs} items={itemIDs}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div ref={setNodeRef} > <div ref={setNodeRef}>
{items.map((item) => ( {items.map((item) => (
<SortableEditorItem key={`${droppableID}_${item.id}`} item={item} /> <SortableEditorItem key={`${droppableID}_${item.Id}`} item={item} />
))} ))}
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); );
}; };
/*
function handleDragEnd(event: DragEndEvent) { function handleDragEnd(event: DragEndEvent) {
console.log("drag end",event); console.log("drag end", event);
if(!event.over) return; if (!event.over) return;
const activeId = event.active.id; const activeId = event.active.id;
const overId = event.over.id; const overId = event.over.id;
store.dispatch(onDragHandler({ activeId, overId }));
} */
store.dispatch(onDragHandler({activeId, overId}));
}
export default Droppable; export default Droppable;

View File

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

View File

@ -2,34 +2,43 @@ import { useNavigate, useParams } from "react-router-dom";
import { import {
lessonContents, lessonContents,
lessonThumbnail, lessonThumbnail,
setCurrentLessonId,
setEditorActive, setEditorActive,
setLessonContents,
setLessonState,
} from "./lessonPageEditorSlice"; } from "./lessonPageEditorSlice";
import { useEffect } from "react"; import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { LessonContent } from "../LessonPage";
import { Card, Flex } from "antd"; import { Card, Flex } from "antd";
import { Constants } from "../../../core/utils/utils"; import { Constants } from "core/utils/utils";
import HeaderBar from "../../../core/components/Header"; import HeaderBar from "core/components/Header";
import Droppable from "./Droppable"; import Droppable from "./Droppable";
import LessonPreviewCard from "../../../shared/components/MyLessonPreviewCard"; import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
import { import {
useGetLessonPreviewQuery, useGetLessonContentsQuery,
useGetLessonSettingsQuery,
useUpdateLessonPreviewTitleMutation, useUpdateLessonPreviewTitleMutation,
} from "core/services/lessons"; } from "core/services/lessons";
import MyErrorResult from "shared/components/MyResult"; import MyErrorResult from "shared/components/MyResult";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import MyEmpty from "shared/components/MyEmpty";
const PreviewCard: React.FC = () => { const PreviewCard: React.FC = () => {
const dispatch = useDispatch();
const { lessonId } = useParams(); const { lessonId } = useParams();
const { data, error, isLoading, refetch } = useGetLessonPreviewQuery( const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(
lessonId as string, lessonId as string,
{ {
refetchOnMountOrArgChange: true, refetchOnMountOrArgChange: true,
} }
); );
const [updateLessonPreviewTitle, {}] = useUpdateLessonPreviewTitleMutation(); const [updateLessonPreviewTitle] = useUpdateLessonPreviewTitleMutation();
useEffect(() => {
if (data?.State) dispatch(setLessonState(data.State));
}, [data]);
if (error) return <MyErrorResult />; if (error) return <MyErrorResult />;
@ -38,7 +47,7 @@ const PreviewCard: React.FC = () => {
mode="editable" mode="editable"
lessonId={lessonId as string} lessonId={lessonId as string}
loading={isLoading} loading={isLoading}
lessonPreview={{ lessonSettings={{
Title: data?.Title || "", Title: data?.Title || "",
ThumbnailUrl: data?.ThumbnailUrl || "", ThumbnailUrl: data?.ThumbnailUrl || "",
}} }}
@ -61,6 +70,40 @@ const PreviewCard: React.FC = () => {
); );
}; };
const LessonContentComponents: React.FC = () => {
const { lessonId } = useParams();
const dispatch = useDispatch();
const { data, error, isLoading } = useGetLessonContentsQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
const lnContents = useSelector(lessonContents);
useEffect(() => {
if (!data) return;
dispatch(setLessonContents(data));
}, [data]);
if (error) return <MyErrorResult />;
return (
<Card loading={isLoading}>
<Flex vertical gap={16}>
{!lnContents || lnContents.length == 0 ? (
<MyEmpty />
) : (
<Droppable items={lnContents} />
)}
</Flex>
</Card>
);
};
export default function LessonPageEditor() { export default function LessonPageEditor() {
const { lessonId } = useParams(); const { lessonId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -71,6 +114,7 @@ export default function LessonPageEditor() {
useEffect(() => { useEffect(() => {
dispatch(setEditorActive(true)); dispatch(setEditorActive(true));
dispatch(setCurrentLessonId(lessonId as string));
return () => { return () => {
dispatch(setEditorActive(false)); dispatch(setEditorActive(false));
@ -104,22 +148,9 @@ export default function LessonPageEditor() {
> >
<PreviewCard /> <PreviewCard />
<Card> <LessonContentComponents />
<Flex vertical gap={16}>
<Droppable items={lnContents} />
</Flex>
</Card>
</Flex> </Flex>
</Flex> </Flex>
</> </>
); );
} }
export function StandardEditorCompontent(type: number): LessonContent {
return {
id: Math.floor(Math.random() * 10000).toString(),
position: 1,
type: type,
data: "Some data",
};
}

View File

@ -1,76 +1,89 @@
import { createSlice } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit";
import { LessonContent } from "../LessonPage"; import { LessonContent, LessonState } from "core/types/lesson";
import { StandardEditorCompontent } from ".";
interface AddLessonContentAction {
type: string;
payload: LessonContent;
}
export const lessonPageEditorSlice = createSlice({ export const lessonPageEditorSlice = createSlice({
name: "lessonPageEditor", name: "lessonPageEditor",
initialState: { initialState: {
editorActive: false, editorActive: false,
currentLessonId: "", // required in sideMenu because has no access to useParams
lessonThumbnail: { lessonThumbnail: {
img: "", img: "",
title: "Test", title: "Test",
}, },
lessonContents: [] as LessonContent[], lessonContents: [] as LessonContent[],
lessonState: LessonState.Draft,
}, },
reducers: { reducers: {
setEditorActive: (state, action) => { setEditorActive: (state, action) => {
state.editorActive = action.payload; state.editorActive = action.payload;
}, },
addLessonContent: (state, action) => { setCurrentLessonId: (state, action) => {
state.lessonContents.push(StandardEditorCompontent(action.payload)); state.currentLessonId = action.payload;
},
addLessonContent: (state, action: AddLessonContentAction) => {
state.lessonContents.push(action.payload);
}, },
deleteLessonContent: (state, action) => { deleteLessonContent: (state, action) => {
state.lessonContents = state.lessonContents.filter( state.lessonContents = state.lessonContents.filter(
(content) => content.id !== action.payload (content) => content.Id !== action.payload
); );
}, },
setLessonContent: (state, action) => { setLessonContents: (state, action) => {
state.lessonContents = action.payload;
},
updateLessonContent: (state, action) => {
const index = state.lessonContents.findIndex( const index = state.lessonContents.findIndex(
(content) => content.id === action.payload.id (content) => content.Id === action.payload.id
); );
state.lessonContents[index].data = action.payload.data; if (index !== -1) {
state.lessonContents[index].Data = action.payload.data;
}
}, },
setLessonThumbnailTitle: (state, action) => { setLessonThumbnailTitle: (state, action) => {
state.lessonThumbnail.title = action.payload; state.lessonThumbnail.title = action.payload;
}, },
onDragHandler: (state, action) => { 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( state.lessonContents.splice(
newIndex, action.payload.newIndex,
0, 0,
state.lessonContents.splice(oldIndex, 1)[0] state.lessonContents.splice(action.payload.oldIndex, 1)[0]
); );
} },
setLessonState: (state, action) => {
state.lessonState = action.payload;
}, },
}, },
selectors: { selectors: {
editorActive: (state) => state.editorActive, editorActive: (state) => state.editorActive,
currentLessonId: (state) => state.currentLessonId,
lessonContents: (state) => state.lessonContents, lessonContents: (state) => state.lessonContents,
lessonThumbnail: (state) => state.lessonThumbnail, lessonThumbnail: (state) => state.lessonThumbnail,
lessonState: (state) => state.lessonState,
}, },
}); });
export const { export const {
setEditorActive, setEditorActive,
setCurrentLessonId,
addLessonContent, addLessonContent,
deleteLessonContent, deleteLessonContent,
setLessonContent, setLessonContents,
updateLessonContent,
setLessonThumbnailTitle, setLessonThumbnailTitle,
onDragHandler, onDragHandler,
setLessonState,
} = lessonPageEditorSlice.actions; } = lessonPageEditorSlice.actions;
export const { editorActive, lessonContents, lessonThumbnail } = export const {
lessonPageEditorSlice.selectors; editorActive,
currentLessonId,
lessonContents,
lessonThumbnail,
lessonState,
} = lessonPageEditorSlice.selectors;

View File

@ -8,3 +8,8 @@
max-width: 800px; max-width: 800px;
} }
} }
.EditorActionIcon {
cursor: pointer;
padding: 8px;
}

View File

@ -10,6 +10,9 @@ export type Component = {
name: string; name: string;
thumbnail?: string; thumbnail?: string;
invertThumbnailAtDarkmode?: boolean; invertThumbnailAtDarkmode?: boolean;
uploadFileTypes?: string[];
uploadImage?: boolean;
defaultData?: string;
}; };
const componentsGroups: ComponentGroup[] = [ const componentsGroups: ComponentGroup[] = [
@ -21,12 +24,14 @@ const componentsGroups: ComponentGroup[] = [
name: "Header", name: "Header",
thumbnail: "/editor/thumbnails/component_thumbnail_header.svg", thumbnail: "/editor/thumbnails/component_thumbnail_header.svg",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
defaultData: "Header",
}, },
{ {
type: 1, type: 1,
name: "Text", name: "Text",
thumbnail: "/editor/thumbnails/component_thumbnail_text.svg", thumbnail: "/editor/thumbnails/component_thumbnail_text.svg",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
defaultData: "Text",
}, },
], ],
}, },
@ -34,18 +39,20 @@ const componentsGroups: ComponentGroup[] = [
category: "Media", category: "Media",
components: [ components: [
{ {
type: 0, type: 2,
name: "Image", name: "Image",
thumbnail: "/editor/thumbnails/component_thumbnail_image.png", thumbnail: "/editor/thumbnails/component_thumbnail_image.png",
uploadImage: true,
uploadFileTypes: ["image/*"],
}, },
{ {
type: 1, type: 3,
name: "YouTube", name: "YouTube",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png", thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
}, },
{ {
type: 1, type: 4,
name: "Video", name: "Video",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png", thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
@ -56,7 +63,7 @@ const componentsGroups: ComponentGroup[] = [
category: "HTML", category: "HTML",
components: [ components: [
{ {
type: 0, type: 5,
name: "Iframe", name: "Iframe",
thumbnail: "/editor/thumbnails/component_thumbnail_iframe.svg", thumbnail: "/editor/thumbnails/component_thumbnail_iframe.svg",
invertThumbnailAtDarkmode: true, invertThumbnailAtDarkmode: true,
@ -67,7 +74,7 @@ const componentsGroups: ComponentGroup[] = [
category: "Special", category: "Special",
components: [ components: [
{ {
type: 0, type: 6,
name: "Banner", name: "Banner",
thumbnail: "/editor/thumbnails/component_thumbnail_banner.png", thumbnail: "/editor/thumbnails/component_thumbnail_banner.png",
}, },
@ -75,4 +82,31 @@ const componentsGroups: ComponentGroup[] = [
}, },
]; ];
const componentsMap: { [key: string]: Component } = (() => {
const map: { [key: string]: Component } = {};
for (const group of componentsGroups) {
for (const component of group.components) {
map[component.name] = component;
}
}
return map;
})();
export function getTypeByName(name: string): number {
const component = componentsMap[name];
return component ? component.type : -1;
}
export function getComponentByType(type: number): Component | null {
for (const component of Object.values(componentsMap)) {
if (component.type === type) {
return component;
}
}
return null;
}
export { componentsGroups }; export { componentsGroups };

View File

@ -0,0 +1,183 @@
import { LessonContent } from "core/types/lesson";
import { getTypeByName } from "./components";
import { Button, Input, Typography } from "antd";
import { useUpdateLessonContentMutation } from "core/services/lessons";
import { useSelector } from "react-redux";
import { currentLessonId } from "./LessonPageEditor/lessonPageEditorSlice";
import { useRef } from "react";
import MyUpload from "shared/components/MyUpload";
export function Converter({
mode,
lessonContent,
onEdit,
}: {
mode: "view" | "edititable";
lessonContent: LessonContent;
onEdit?: (newData: string) => void;
}) {
const lessonId = useSelector(currentLessonId);
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
const debounceRef = useRef<null | NodeJS.Timeout>(null);
switch (lessonContent.Type) {
case getTypeByName("Header"):
if (mode === "view") {
return (
<div
style={{ fontWeight: "bold", fontSize: 24, wordBreak: "break-all" }}
>
{lessonContent.Data}
</div>
);
}
return (
<Typography.Title
editable={{
triggerType: "text" as any,
onChange: (event) => {
onEdit?.(event);
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: event,
});
} catch (err) {
console.error(err);
}
},
}}
level={1}
style={{
margin: 0,
width: "100%",
}}
>
{lessonContent.Data}
</Typography.Title>
);
case getTypeByName("Text"):
if (mode === "view") {
return (
<div style={{ fontSize: 16, wordBreak: "break-all" }}>
{lessonContent.Data}
</div>
);
}
return (
<Input.TextArea
variant="borderless"
placeholder="Input text here"
style={{ width: "100%" }}
value={lessonContent.Data}
onChange={(event) => {
console.log("edit");
onEdit?.(event.target.value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: event.target.value,
});
} catch (err) {
console.error(err);
}
}, 1000);
}}
/>
);
case getTypeByName("Image"):
console.log("image", lessonContent.Data);
if (mode === "view" && lessonContent.Data === "") {
return (
<div
style={{
position: "relative",
height: 120,
width: "100%",
backgroundColor: "#EBEBEB",
marginRight: 8,
}}
>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
No image provided
</div>
</div>
);
}
if (lessonContent.Data === "") {
return (
<div
style={{
position: "relative",
height: 120,
width: "100%",
backgroundColor: "#EBEBEB",
margin: "12px 12px 12px 0",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<span>Choose image from</span>
<MyUpload>
<Button type="link">Gallery</Button>
</MyUpload>
</div>
</div>
);
}
return (
<>
<img src={lessonContent.Data} alt="img" style={{ width: "100%" }} />
</>
);
case getTypeByName("YouTube"):
return (
<div style={{ fontWeight: "700", fontSize: 20, width: "100%" }}>
{lessonContent.Data}
</div>
);
case getTypeByName("Video"):
return <div style={{ fontSize: 14, width: "100%" }}>Not implemented</div>;
case getTypeByName("Iframe"):
return <div style={{ fontSize: 14, width: "100%" }}>Not implemented</div>;
case getTypeByName("Banner"):
return <div style={{ fontSize: 14, width: "100%" }}>Not implemented</div>;
default:
return <div>Unknown type</div>;
}
}

View File

@ -1,6 +1,6 @@
import { Button, Flex, Segmented } from "antd"; import { Button, Divider, Flex, Segmented } from "antd";
import MyBanner from "../../shared/components/MyBanner"; import MyBanner from "shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer"; import { MyContainer } from "shared/components/MyContainer";
import { import {
AppstoreOutlined, AppstoreOutlined,
BarsOutlined, BarsOutlined,
@ -8,7 +8,7 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import Search, { SearchProps } from "antd/es/input/Search"; import Search, { SearchProps } from "antd/es/input/Search";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import HeaderBar from "../../core/components/Header"; import HeaderBar from "core/components/Header";
import { import {
useCreateLessonMutation, useCreateLessonMutation,
useGetLessonsQuery, useGetLessonsQuery,
@ -17,7 +17,7 @@ import MySpin from "shared/components/MySpin";
import { Constants } from "core/utils/utils"; import { Constants } from "core/utils/utils";
import MyEmpty from "shared/components/MyEmpty"; import MyEmpty from "shared/components/MyEmpty";
import MyErrorResult from "shared/components/MyResult"; import MyErrorResult from "shared/components/MyResult";
import LessonPreviewCard from "../../shared/components/MyLessonPreviewCard"; import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
const CreateLessonButton: React.FC = () => { const CreateLessonButton: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -61,21 +61,47 @@ const LessonList: React.FC = () => {
if (!data || data.length === 0) return <MyEmpty />; if (!data || data.length === 0) return <MyEmpty />;
const publishedItems = data.filter(item => item.State === 1);
const unpublishedItems = data.filter(item => item.State === 2);
return ( return (
<> <>
{data.map((item, index) => ( {publishedItems.length > 0 && (
<>
{publishedItems.map((item, index) => (
<LessonPreviewCard <LessonPreviewCard
key={index} key={index}
mode="view" mode="view"
lessonId={item.Id} lessonId={item.Id}
loading={false} loading={false}
lessonPreview={{ lessonSettings={{
Title: item.Title, Title: item.Title,
ThumbnailUrl: item.ThumbnailUrl, ThumbnailUrl: item.ThumbnailUrl,
}} }}
/> />
))} ))}
</> </>
)}
{unpublishedItems.length > 0 && (
<>
<Divider orientation="left" style={{marginBottom: 0}}>Unpublished</Divider>
{unpublishedItems.map((item, index) => (
<LessonPreviewCard
key={index}
mode="view"
lessonId={item.Id}
loading={false}
lessonSettings={{
Title: item.Title,
ThumbnailUrl: item.ThumbnailUrl,
}}
/>
))}
</>
)}
</>
); );
}; };

View File

@ -1,16 +1,137 @@
import { Button, Card, Divider, Flex, Form, Input, Typography } from "antd";
import HeaderBar from "../../core/components/Header"; import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner"; import MyBanner from "../../shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer"; import { MyContainer } from "../../shared/components/MyContainer";
import { SaveOutlined } from "@ant-design/icons";
import MyUpload from "shared/components/MyUpload";
import { Constants } from "core/utils/utils";
import ColorPicker from "antd/es/color-picker";
export default function Settings() { export default function Settings() {
return ( return (
<> <>
<MyBanner title="Settings" subtitle="MANAGE" headerBar={ <MyBanner title="Settings" subtitle="MANAGE" headerBar={<HeaderBar />} />
<HeaderBar />
} />
<MyContainer> <MyContainer>
<h1>Settings</h1> <Flex vertical gap={16} style={{ paddingBottom: 16 }}>
<Card
styles={{
body: {
padding: 16,
},
}}
>
<Flex vertical gap={2}>
<Form>
<Flex justify="space-between" align="center">
<Typography.Title level={5} style={{ margin: 0 }}>
Branding
</Typography.Title>
<Button
icon={<SaveOutlined />}
type="text"
shape="circle"
size="large"
htmlType="submit"
/>
</Flex>
<Divider style={{ margin: 0, padding: 0 }} />
<Flex gap={32}>
<Flex vertical>
<Typography.Text style={{ fontSize: 16 }}>
Primary Color
</Typography.Text>
<Form.Item name="primaryColor">
<ColorPicker
defaultValue="#1677ff"
size="small"
showText
/>
</Form.Item>
</Flex>
<Flex vertical>
<Typography.Text style={{ fontSize: 16 }}>
Company name
</Typography.Text>
<Form.Item name="companyName">
<Input defaultValue="Jannex" />
</Form.Item>
</Flex>
<Flex vertical>
<Typography.Text style={{ fontSize: 16 }}>
Subdomain
</Typography.Text>
<Form.Item name="subdomain">
<Input addonBefore="https://" addonAfter=". jannex . de" defaultValue="mysite" />
</Form.Item>
</Flex>
<Flex vertical>
<Typography.Text style={{ fontSize: 16 }}>
Logo
</Typography.Text>
<MyUpload
action={`/changeCompanyLogo`}
onChange={(info) => {
if (info.file.status === "done") {
//onThumbnailChanged?.();
console.log("done");
}
}}
imgCropProps={{
aspect: 1 / 1,
children: <></>,
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
alt="Company Logo"
style={{
width: 128,
maxHeight: 128,
padding: 4,
borderRadius: 4,
border: "1px solid #ddd",
}}
/>
</MyUpload>
</Flex>
</Flex>
<Flex vertical>
<Typography.Text style={{ fontSize: 16 }}>
Thumbnail
</Typography.Text>
<MyUpload
action={`/changeCompanyLogo`}
onChange={(info) => {
if (info.file.status === "done") {
//onThumbnailChanged?.();
console.log("done");
}
}}
imgCropProps={{
aspect: 22 / 9,
children: <></>,
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
alt="Thumbnail"
style={{
width: "100%",
height: 228,
objectFit: "cover",
padding: 4,
borderRadius: 4,
border: "1px solid #ddd",
}}
/>
</MyUpload>
</Flex>
</Form>
</Flex>
</Card>
</Flex>
</MyContainer> </MyContainer>
</> </>
); );

View File

@ -0,0 +1,83 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Flex, Form, Input } from "antd";
import HeaderBar from "core/components/Header";
import { Constants } from "core/utils/utils";
import MyMiddleCard from "shared/components/MyMiddleCard";
type FieldType = {
firstName: string;
lastName: string;
email: string;
changePasswordOnFirstLogin: boolean;
password?: string;
sendInvitationEmail: boolean;
};
export default function TeamCreateUser() {
return (
<>
<HeaderBar
theme="light"
backTo={Constants.ROUTE_PATHS.ORGANIZATION_TEAM}
/>
<MyMiddleCard title="Create User">
<Form layout="vertical" requiredMark={false}>
<Form.Item<FieldType>
label="First Name"
name="firstName"
rules={[{ required: true, message: "Please input first name!" }]}
>
<Input placeholder="First Name" />
</Form.Item>
<Form.Item<FieldType>
label="Last Name"
name="lastName"
rules={[{ required: true, message: "Please input last name!" }]}
>
<Input placeholder="Last Name" />
</Form.Item>
<Form.Item<FieldType>
label="Email"
name="email"
rules={[
{ required: true, message: "Please input email!", type: "email" },
]}
>
<Input placeholder="Email" />
</Form.Item>
<Form.Item<FieldType>
name="changePasswordOnFirstLogin"
valuePropName="checked"
>
<Checkbox>Change Password on First Login</Checkbox>
</Form.Item>
<Form.Item<FieldType>
label="Password"
name="password"
rules={[{ required: true, message: "Please input password!" }]}
>
<Input.Password placeholder="Password" />
</Form.Item>
<Form.Item<FieldType>
name="sendInvitationEmail"
valuePropName="checked"
>
<Checkbox>Send an invitation email to the user</Checkbox>
</Form.Item>
<Flex justify="end">
<Button type="primary" icon={<PlusOutlined />}>
Create User
</Button>
</Flex>
</Form>
</MyMiddleCard>
</>
);
}

View File

@ -1,16 +1,101 @@
import MyTable from "shared/components/MyTable";
import HeaderBar from "../../core/components/Header"; import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner"; import MyBanner from "../../shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer"; import { MyContainer } from "../../shared/components/MyContainer";
import { Button, Flex } from "antd";
import { UserAddOutlined } from "@ant-design/icons";
import { Link } from "react-router-dom";
import { Constants } from "core/utils/utils";
import { useGetTeamQuery } from "core/services/team";
import MyErrorResult from "shared/components/MyResult";
const TeamList: React.FC = () => {
const { data, error, isLoading } = useGetTeamQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const getTableContent = () => {
let items = [
{
title: "First name",
dataIndex: "firstName",
key: "firstName",
},
{
title: "Last name",
dataIndex: "lastName",
key: "lastName",
},
{
title: "Email",
dataIndex: "email",
key: "email",
},
{
title: "Role",
dataIndex: "role",
key: "role",
},
{
title: "Status",
dataIndex: "status",
key: "status",
},
{
title: "Actions",
dataIndex: "actions",
key: "actions",
},
];
return items;
};
const getTableItems = () => {
let items = [] as any[];
if (!data) return items;
data.forEach((item) => {
items.push({
key: item.Id,
firstName: item.FirstName,
lastName: item.LastName,
email: item.Email,
role: item.Role,
status: item.Status,
});
});
return items;
}
if (error) return <MyErrorResult />;
return (
<MyTable loading={isLoading} columns={getTableContent()} dataSource={getTableItems()} pagination={false} />
);
};
export default function Team() { export default function Team() {
return ( return (
<> <>
<MyBanner title="Team" subtitle="MANAGE" headerBar={ <MyBanner title="Team" subtitle="MANAGE" headerBar={<HeaderBar />} />
<HeaderBar />
} />
<MyContainer> <MyContainer
<h1>Team</h1> style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
<Flex justify="end">
<Link to={Constants.ROUTE_PATHS.ORGANIZATION_TEAM_CREATE_USER}>
<Button icon={<UserAddOutlined />}>Invite new member</Button>
</Link>
</Flex>
<TeamList />
</MyContainer> </MyContainer>
</> </>
); );

View File

@ -1,7 +1,13 @@
import { Content } from "antd/es/layout/layout"; import { Content } from "antd/es/layout/layout";
export function MyContainer({ children }: { children: React.ReactNode }) { export function MyContainer({
return <Content style={{ padding: 12 }}>{children}</Content>; children,
style = {},
}: {
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return <Content style={{ padding: 12, ...style }}>{children}</Content>;
} }
export function MyCenteredContainer({ export function MyCenteredContainer({

View File

@ -1,23 +1,23 @@
import { CommentOutlined } from "@ant-design/icons"; import { CommentOutlined } from "@ant-design/icons";
import { Card, Flex, Typography } from "antd"; import { Card, Flex, Typography } from "antd";
import { LessonPreview } from "core/types/lesson";
import { Constants, getImageUrl } from "core/utils/utils"; import { Constants, getImageUrl } from "core/utils/utils";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import MyUpload from "shared/components/MyUpload"; import MyUpload from "shared/components/MyUpload";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
import { LessonSettings } from "core/types/lesson";
export default function MyLessonPreviewCard({ export default function MyLessonPreviewCard({
mode = "view", mode = "view",
lessonId, lessonId,
loading = false, loading = false,
lessonPreview, lessonSettings,
onEditTitle, onEditTitle,
onThumbnailChanged, onThumbnailChanged,
}: { }: {
mode: "view" | "editable"; mode: "view" | "editable";
lessonId: string; lessonId: string;
loading?: boolean; loading?: boolean;
lessonPreview: LessonPreview; lessonSettings: LessonSettings;
onEditTitle?: (newTitle: string) => void; onEditTitle?: (newTitle: string) => void;
onThumbnailChanged?: () => void; onThumbnailChanged?: () => void;
}) { }) {
@ -61,9 +61,9 @@ export default function MyLessonPreviewCard({
<UploadWrapper> <UploadWrapper>
<img <img
src={ src={
lessonPreview.ThumbnailUrl === "" lessonSettings.ThumbnailUrl === ""
? `${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp` ? `${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`
: getImageUrl(lessonPreview.ThumbnailUrl) : getImageUrl(lessonSettings.ThumbnailUrl)
} }
alt="lesson thumbnail" alt="lesson thumbnail"
className={styles.img} className={styles.img}
@ -73,7 +73,7 @@ export default function MyLessonPreviewCard({
<Flex vertical justify="center" style={{ width: "100%" }}> <Flex vertical justify="center" style={{ width: "100%" }}>
{mode === "view" ? ( {mode === "view" ? (
<div> <div>
<div className={styles.title}>{lessonPreview.Title}</div> <div className={styles.title}>{lessonSettings.Title}</div>
<CommentOutlined /> 12 comments <CommentOutlined /> 12 comments
</div> </div>
) : ( ) : (
@ -88,7 +88,7 @@ export default function MyLessonPreviewCard({
width: "100%", width: "100%",
}} }}
> >
{lessonPreview.Title} {lessonSettings.Title}
</Typography.Title> </Typography.Title>
)} )}
</Flex> </Flex>

View File

@ -0,0 +1,26 @@
import { Card, CardProps, Flex } from "antd";
import { MyContainer } from "../MyContainer";
interface MyMiddleCardProps extends CardProps {}
const MyMiddleCard: React.FC<MyMiddleCardProps> = ({ children,
...props
}) => {
return (
<MyContainer>
<Flex justify="center">
<Card
style={{
width: 800,
maxWidth: 800,
}}
{...props}
>
{children}
</Card>
</Flex>
</MyContainer>
);
}
export default MyMiddleCard;

View File

@ -0,0 +1,9 @@
import { Table, TableProps } from "antd";
interface MyTableProps extends TableProps<any> {}
const MyTable: React.FC<MyTableProps> = (props) => {
return <Table {...props} scroll={{ x: "max-content" }} />;
};
export default MyTable;

View File

@ -49,6 +49,7 @@ export default function MyUpload({
action={`${Constants.API_ADDRESS}${action}`} action={`${Constants.API_ADDRESS}${action}`}
onChange={onChange} onChange={onChange}
beforeUpload={beforeUpload} beforeUpload={beforeUpload}
> >
{children} {children}
</Upload> </Upload>