comments and youtube and videos on editor

main
alex 2024-09-03 23:44:30 +02:00
parent 00dee1ba9e
commit e6b9a6d0e8
24 changed files with 1690 additions and 1140 deletions

52
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@types/node": "^16.18.106",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@vidstack/react": "^1.12.9",
"antd": "^5.20.3",
"antd-img-crop": "^4.23.0",
"buffer": "^6.0.3",
@ -2744,6 +2745,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.7.tgz",
"integrity": "sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.7"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.10",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.10.tgz",
"integrity": "sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.7"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz",
"integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@ -5226,6 +5252,23 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"license": "ISC"
},
"node_modules/@vidstack/react": {
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@vidstack/react/-/react-1.12.9.tgz",
"integrity": "sha512-2YBkMN590u20P9JVw6EoaAegVz4YP7utxeRXuDkzvn60UG8Ky6v4CdywFaBAHBrxyRefiCJTLB5noDmIRyVplg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"media-captions": "^1.0.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": "^18.0.0",
"react": "^18.0.0"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz",
@ -13878,6 +13921,15 @@
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==",
"license": "CC0-1.0"
},
"node_modules/media-captions": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz",
"integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",

View File

@ -15,6 +15,7 @@
"@types/node": "^16.18.106",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@vidstack/react": "^1.12.9",
"antd": "^5.20.3",
"antd-img-crop": "^4.23.0",
"buffer": "^6.0.3",

View File

@ -1,36 +1,27 @@
import { Avatar, Flex } from "antd";
import {
isSideMenuCollapsed,
setIsSideMenuCollapsed,
} from "../SideMenu/sideMenuSlice";
import { useDispatch, useSelector } from "react-redux";
import {
EditOutlined,
EyeOutlined,
LeftOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MoonOutlined,
SunOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Link } from "react-router-dom";
import { darkMode, setDarkMode } from "../../reducers/appSlice";
import styles from "./styles.module.css";
import { Avatar, Dropdown, Flex } from 'antd';
import { isSideMenuCollapsed, setIsSideMenuCollapsed } from '../SideMenu/sideMenuSlice';
import { useDispatch, useSelector } from 'react-redux';
import { EditOutlined, EyeOutlined, LeftOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined, UserOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { darkMode, setDarkMode } from '../../reducers/appSlice';
import styles from './styles.module.css';
import { Constants } from 'core/utils/utils';
type HeaderBarProps = {
theme?: "light" | "dark";
theme?: 'light' | 'dark';
onView?: () => void;
onEdit?: () => void;
backTo?: string;
};
export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
const dispatch = useDispatch();
const isCollpased = useSelector(isSideMenuCollapsed);
const isDarkMode = useSelector(darkMode);
const navigate = useNavigate();
return (
<Flex
justify="space-between"
@ -42,26 +33,13 @@ export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
}}
>
<Flex align="center" gap={16}>
<div
className={
props.theme === "light"
? styles.containerLight
: styles.containerDark
}
style={{ borderRadius: 28, padding: 4 }}
>
<div className={props.theme === 'light' ? styles.containerLight : styles.containerDark} style={{ borderRadius: 28, padding: 4 }}>
{isCollpased ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(false))}
>
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(false))}>
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(true))}
>
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(true))}>
<MenuFoldOutlined className={styles.icon} />
</div>
)}
@ -79,9 +57,7 @@ export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
<Flex
align="center"
className={
props.theme === "light" ? styles.containerLight : styles.containerDark
}
className={props.theme === 'light' ? styles.containerLight : styles.containerDark}
style={{
borderRadius: 28,
paddingLeft: 6,
@ -104,21 +80,34 @@ export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
)}
{isDarkMode ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(false))}
>
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(false))}>
<SunOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(true))}
>
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(true))}>
<MoonOutlined className={styles.icon} />
</div>
)}
<Dropdown
menu={{
items: [
{
key: '1',
label: 'Profile',
icon: <UserOutlined />,
onClick: () => navigate(Constants.ROUTE_PATHS.ACCOUNT_SETTINGS),
},
{
key: '2',
label: 'Logout',
icon: <LogoutOutlined />,
danger: true,
},
],
}}
>
<Avatar size="default" icon={<UserOutlined />} />
</Dropdown>
</Flex>
</Flex>
);

View File

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

View File

@ -1,18 +0,0 @@
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

@ -1,10 +1,10 @@
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 "../reducers/appSlice";
import { lessonsApi } from "core/services/lessons";
import { teamApi } from "core/services/team";
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 '../reducers/appSlice';
import { lessonsApi } from 'core/services/lessons';
import { organizationApi } from 'core/services/organization';
const makeStore = (/* preloadedState */) => {
const store = configureStore({
@ -13,14 +13,10 @@ const makeStore = (/* preloadedState */) => {
sideMenu: sideMenuSlice.reducer,
lessonPageEditor: lessonPageEditorSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer,
[teamApi.reducerPath]: teamApi.reducer,
[organizationApi.reducerPath]: organizationApi.reducer,
},
// preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
lessonsApi.middleware,
teamApi.middleware
),
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(lessonsApi.middleware, organizationApi.middleware),
});
setupListeners(store.dispatch);

View File

@ -32,3 +32,24 @@ export interface UpdateLessonPreviewThumbnail {
lessonId: string;
formData: FormData;
}
export interface LessonQuestion {
Id: string;
LessionId: string;
Question: string;
Likes: number;
CreatorUserId: string;
CreatedAt: string;
UpdatedAt: string;
}
export interface LessonQuestionReply {
Id: string;
QuestionId: string;
ReplyId?: string;
Reply: string;
Likes: number;
CreatorUserId: string;
CreatedAt: string;
UpdatedAt: string;
}

View File

@ -0,0 +1,16 @@
export interface TeamMember {
Id: string;
FirstName: string;
LastName: string;
Email: string;
Role: string;
Status: string;
}
export interface OrganizationSettings {
Subdomain: string;
CompanyName: string;
PrimaryColor: string;
LogoUrl: string;
BannerUrl: string;
}

View File

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

View File

@ -1,7 +1,7 @@
import { Buffer } from "buffer";
import { v4 as uuidv4 } from "uuid";
import { Buffer } from 'buffer';
import { v4 as uuidv4 } from 'uuid';
const wssProtocol = window.location.protocol === "https:" ? "wss://" : "ws://";
const wssProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
export const Constants = {
API_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/v1`,
@ -9,29 +9,25 @@ export const Constants = {
STATIC_CONTENT_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/static`,
ROUTE_PATHS: {
LESSIONS: {
ROOT: "/lessons",
PAGE: "/lessons/:lessonId",
PAGE_EDITOR: "/lessons/:lessonId/editor",
ROOT: '/lessons',
PAGE: '/lessons/:lessonId',
PAGE_EDITOR: '/lessons/:lessonId/editor',
},
ORGANIZATION_TEAM: "/team",
ORGANIZATION_TEAM_CREATE_USER: "/team/create-user",
ORGANIZATION_ROLES: "/roles",
ORGANIZATION_SETTINGS: "/organization",
ACCOUNT_SETTINGS: "/account",
WHATS_NEW: "/whats-new",
SUGGEST_FEATURE: "/suggest-feature",
CONTACT_SUPPORT: "/contact-support",
ORGANIZATION_TEAM: '/team',
ORGANIZATION_TEAM_CREATE_USER: '/team/create-user',
ORGANIZATION_ROLES: '/roles',
ORGANIZATION_SETTINGS: '/organization',
ACCOUNT_SETTINGS: '/account',
WHATS_NEW: '/whats-new',
SUGGEST_FEATURE: '/suggest-feature',
CONTACT_SUPPORT: '/contact-support',
},
STYLES: {
BLACK: "#000",
BLACK: '#000',
},
MAX_IMAGE_SIZE: 25 * 1024 * 1024, // 25MB
ACCEPTED_IMAGE_FILE_TYPES: [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
],
ACCEPTED_IMAGE_FILE_TYPES: ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'],
ACCEPTED_VIDEO_FILE_TYPES: ['video/mp4', 'video/webm', 'video/mkv'],
};
// used for sideMenu
@ -53,20 +49,20 @@ export function getImageUrl(imageName: string) {
export const BrowserTabSession = GetUuid();
export function getUserSessionFromLocalStorage() {
return localStorage.getItem("session");
return localStorage.getItem('session');
}
export function EncodeStringToBase64(value: string) {
return Buffer.from(value).toString("base64");
return Buffer.from(value).toString('base64');
}
export function DecodedBase64ToString(value: string) {
return Buffer.from(value, "base64").toString();
return Buffer.from(value, 'base64').toString();
}
export function handleLogout() {
localStorage.removeItem("session");
window.location.href = "/";
localStorage.removeItem('session');
window.location.href = '/';
}
export const myFetchContentType = {
@ -74,14 +70,14 @@ 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;
method?: Method;
body?: TRequest | null;
headers?: Record<string, string>;
contentType?: "JSON" | "FORM_DATA";
contentType?: 'JSON' | 'FORM_DATA';
fetchUrl?: string;
ignoreUnauthorized?: boolean;
notificationApi?: any; // Passen Sie dies je nach Bedarf an
@ -89,26 +85,26 @@ interface MyFetchOptions<TRequest = any, TResponse = any> {
}
export function myFetch<TRequest = any, TResponse = any>({
url = "",
method = "GET",
url = '',
method = 'GET',
body = null,
headers = {},
contentType = "JSON",
contentType = 'JSON',
fetchUrl = Constants.API_ADDRESS,
ignoreUnauthorized = false,
notificationApi = null,
t = null,
}: MyFetchOptions<TRequest, TResponse>): Promise<TResponse> {
const getContentType = () => {
if (contentType === "JSON") return "application/json";
if (contentType === 'JSON') return 'application/json';
return "multipart/form-data";
return 'multipart/form-data';
};
const getBody = () => {
if (!body) return null;
if (contentType === "JSON") return JSON.stringify(body);
if (contentType === 'JSON') return JSON.stringify(body);
return body;
};
@ -122,15 +118,15 @@ export function myFetch<TRequest = any, TResponse = any>({
const requestOptions = {
method: method,
headers: {
"X-Authorization": getUserSessionFromLocalStorage() || "",
"Content-Type": getContentType(),
'X-Authorization': getUserSessionFromLocalStorage() || '',
'Content-Type': getContentType(),
...headers,
},
body: getBody(),
signal: signal,
};
if (fetchUrl === "") {
if (fetchUrl === '') {
fetchUrl = Constants.API_ADDRESS;
}
@ -139,7 +135,7 @@ export function myFetch<TRequest = any, TResponse = any>({
// if status is not in range 200-299
if (!response.ok) {
if (!ignoreUnauthorized && response.status === 401) {
console.error("Unauthorized");
console.error('Unauthorized');
// TODO: check here
//setUserSessionToLocalStorage("");
//window.location.href = "/";
@ -149,14 +145,14 @@ export function myFetch<TRequest = any, TResponse = any>({
}
// check if response is json
if (response.headers.get("content-type")?.includes("application/json")) {
if (response.headers.get('content-type')?.includes('application/json')) {
return response.json();
}
return response.text();
})
.catch(async (error) => {
console.error("Error", error);
console.error('Error', error);
// ignore errors here as they are handled in the components
if (error === 400) throw error;

View File

@ -2,6 +2,7 @@ import {
Avatar,
Button,
Card,
Descriptions,
Divider,
Flex,
Form,
@ -18,78 +19,31 @@ 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}
/>
export default function AccountSettings({ isAdmin }: { isAdmin?: boolean }) {
function AdminWrapper({ children }: { children: React.ReactNode }) {
if (!isAdmin) {
return <>{children}</>;
}
return (
<Form layout="vertical" style={{ marginTop: 24 }}>
{children}
</Form>
);
}
function TextItem({ value, name }: { value: string; name: string }) {
if (!isAdmin) {
return <>{value}</>;
}
return (
<Form.Item name={name} style={{ width: "100%" }} required>
<Input defaultValue={value} />
</Form.Item>
);
}
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
@ -118,7 +72,7 @@ export default function AccountSettings() {
description="Lead"
/>
</Card>
<Card
{/*<Card
styles={{
body: {
padding: 16,
@ -144,6 +98,37 @@ export default function AccountSettings() {
</Flex>
</Flex>
</Flex>
</Card>*/}
<Card
styles={{
body: {
padding: 16,
},
}}
>
<AdminWrapper>
<Descriptions
title="Personal Information"
layout="vertical"
items={[
{
key: "1",
label: "First name",
children: <TextItem value="Jorg" name="firstName" />,
},
{
key: "2",
label: "Last name",
children: <TextItem value="Kreth" name="lastName" />,
},
{
key: "3",
label: "Email",
children: <TextItem value="julian@xx.com" name="email" />,
},
]}
/>
</AdminWrapper>
</Card>
</Flex>
</MyMiddleCard>

View File

@ -20,7 +20,7 @@ interface SignInFetchResponse {
}
export default function SignIn() {
const [form] = useForm();
const [form] = useForm<FieldType>();
const dispatch = useDispatch();

View File

@ -1,25 +1,23 @@
import { Button, Flex } from "antd";
import { CheckOutlined } from "@ant-design/icons";
import HeaderBar from "../../../core/components/Header";
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";
import MyMiddleCard from "shared/components/MyMiddleCard";
import { Converter } from "../converter";
import { Button, Flex } from 'antd';
import { CheckOutlined } from '@ant-design/icons';
import HeaderBar from '../../../core/components/Header';
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';
import MyMiddleCard from 'shared/components/MyMiddleCard';
import { Converter } from '../converter';
import Questions from '../Questions';
const LessonContents: React.FC = () => {
const { lessonId } = useParams();
const { data, error, isLoading } = useGetLessonContentsQuery(
lessonId as string,
{
const { data, error, isLoading } = useGetLessonContentsQuery(lessonId as string, {
refetchOnMountOrArgChange: true,
}
);
});
if (isLoading) return <MySpin />;
if (error) return <MyErrorResult />;
@ -43,13 +41,15 @@ export default function LessonPage() {
return (
<>
<HeaderBar
theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
onEdit={() => navigate(`${location.pathname}/editor`)}
/>
<HeaderBar theme="light" backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT} onEdit={() => navigate(`${location.pathname}/editor`)} />
<MyMiddleCard>
<MyMiddleCard
outOfCardChildren={
<div style={{ marginTop: 24 }}>
<Questions lessionID={'lessionID'} />
</div>
}
>
<LessonContents />
<Flex justify="right">

View File

@ -1,32 +1,31 @@
import { DndContext, DragEndEvent, useDroppable } from "@dnd-kit/core";
import {
verticalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import SortableEditorItem from "./SortableEditorItem";
import { store } from "core/store/store";
import { closestCenter, closestCorners, DndContext, DragEndEvent, DragOverlay, MeasuringStrategy, rectIntersection, useDroppable } from '@dnd-kit/core';
import { verticalListSortingStrategy, SortableContext, rectSwappingStrategy, rectSortingStrategy } from '@dnd-kit/sortable';
import SortableEditorItem from './SortableEditorItem';
import { store } from 'core/store/store';
import {
restrictToVerticalAxis,
restrictToWindowEdges,
} from "@dnd-kit/modifiers";
import { currentLessonId, onDragHandler } from "./lessonPageEditorSlice";
import { LessonContent } from "core/types/lesson";
import { useUpdateLessonContentPositionMutation } from "core/services/lessons";
import { useSelector } from "react-redux";
import { restrictToVerticalAxis, restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers';
import { currentLessonId, onDragHandler } from './lessonPageEditorSlice';
import { LessonContent } from 'core/types/lesson';
import { useUpdateLessonContentPositionMutation } from 'core/services/lessons';
import { useSelector } from 'react-redux';
import React from 'react';
import { Typography } from 'antd';
import { HolderOutlined } from '@ant-design/icons';
const Droppable = ({ items }: { items: LessonContent[] }) => {
const droppableID = "editorComponentArea";
const droppableID = 'editorComponentArea';
const { setNodeRef } = useDroppable({ id: droppableID });
const currentLnId = useSelector(currentLessonId);
const [reqUpdateLessonContentPosition] =
useUpdateLessonContentPositionMutation();
const [reqUpdateLessonContentPosition] = useUpdateLessonContentPositionMutation();
const [isDragging, setIsDragging] = React.useState(false);
const itemIDs = items.map((item) => item.Id);
const handleDragEnd = (event: DragEndEvent) => {
console.log("drag end", event);
console.log('drag end', event);
setIsDragging(false);
if (!event.over) return;
@ -55,20 +54,21 @@ const Droppable = ({ items }: { items: LessonContent[] }) => {
return (
<DndContext
modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}
modifiers={[snapCenterToCursor]}
collisionDetection={closestCorners}
onDragStart={() => {
setIsDragging(true);
}}
onDragEnd={handleDragEnd}
>
<SortableContext
id={droppableID}
items={itemIDs}
strategy={verticalListSortingStrategy}
>
<SortableContext id={droppableID} items={itemIDs} strategy={verticalListSortingStrategy}>
<div ref={setNodeRef}>
{items.map((item) => (
<SortableEditorItem key={`${droppableID}_${item.Id}`} item={item} />
))}
</div>
</SortableContext>
<DragOverlay>{isDragging ? <HolderOutlined style={{ cursor: 'grabbing' }} /> : null}</DragOverlay>
</DndContext>
);
};

View File

@ -1,31 +1,20 @@
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
HolderOutlined,
DeleteOutlined,
CameraOutlined,
FolderOpenOutlined,
} from "@ant-design/icons";
import { Flex } from "antd";
import {
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";
import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { HolderOutlined, DeleteOutlined, CameraOutlined, FolderOpenOutlined } from '@ant-design/icons';
import { Flex } from 'antd';
import { currentLessonId, deleteLessonContent, updateLessonContent } from './lessonPageEditorSlice';
import { useDispatch, useSelector } from 'react-redux';
import { getComponentByType } from '../components';
import { LessonContent } from 'core/types/lesson';
import './styles.module.css';
import { Converter } from '../converter';
import { useDeleteLessonContentMutation } from 'core/services/lessons';
const animateLayoutChanges = (args: any) =>
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true;
const animateLayoutChanges = (args: any) => (args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : true);
const SortableEditorItem = (props: { item: LessonContent }) => {
const lnContent = props.item;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: lnContent.Id, animateLayoutChanges });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: lnContent.Id });
const dispatch = useDispatch();
@ -48,20 +37,24 @@ const SortableEditorItem = (props: { item: LessonContent }) => {
{...attributes}
>
<Flex key={lnContent.Id}>
<Flex>
<HolderOutlined
style={{
paddingLeft: 8,
paddingRight: 8,
touchAction: "none",
cursor: "move",
touchAction: 'none',
cursor: 'grab',
opacity: isDragging ? 0 : 1,
}}
{...listeners}
/>
</Flex>
<Flex style={{ overflow: 'hidden', width: '100%', transition: '', boxShadow: isDragging ? 'rgba(0, 0, 0, 0.35) 0px 5px 15px;' : '' }}>
<Converter
mode="edititable"
lessonContent={lnContent}
onEdit={(data) => {
console.log("edit", lnContent.Id, data);
console.log('edit', lnContent.Id, data);
dispatch(
updateLessonContent({
@ -71,23 +64,14 @@ const SortableEditorItem = (props: { item: LessonContent }) => {
);
}}
/>
</Flex>
<Flex vertical justify="center">
{component.uploadImage ? (
<div className="EditorActionIcon">
<CameraOutlined />
</div>
) : null}
{component.uploadFileTypes ? (
<div className="EditorActionIcon">
<FolderOpenOutlined className="EditorActionIcon" />{" "}
</div>
) : null}
<Flex vertical justify="center" style={{ paddingLeft: 12 }}>
<div className="EditorActionIcon">
<DeleteOutlined
className="EditorActionIcon"
onClick={() => {
console.log("delete", lnContent.Id);
console.log('delete', lnContent.Id);
dispatch(deleteLessonContent(lnContent.Id));
try {
@ -107,4 +91,17 @@ const SortableEditorItem = (props: { item: LessonContent }) => {
);
};
/*
{component.uploadImage ? (
<div className="EditorActionIcon">
<CameraOutlined />
</div>
) : null}
{component.uploadFileTypes ? (
<div className="EditorActionIcon">
<FolderOpenOutlined className="EditorActionIcon" />{' '}
</div>
) : null}
*/
export default SortableEditorItem;

View File

@ -1,38 +1,24 @@
import { useNavigate, useParams } from "react-router-dom";
import {
lessonContents,
lessonThumbnail,
setCurrentLessonId,
setEditorActive,
setLessonContents,
setLessonState,
} from "./lessonPageEditorSlice";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Card, Flex } from "antd";
import { Constants } from "core/utils/utils";
import HeaderBar from "core/components/Header";
import Droppable from "./Droppable";
import LessonPreviewCard from "shared/components/MyLessonPreviewCard";
import {
useGetLessonContentsQuery,
useGetLessonSettingsQuery,
useUpdateLessonPreviewTitleMutation,
} from "core/services/lessons";
import MyErrorResult from "shared/components/MyResult";
import styles from "./styles.module.css";
import MyEmpty from "shared/components/MyEmpty";
import { useNavigate, useParams } from 'react-router-dom';
import { lessonContents, lessonThumbnail, setCurrentLessonId, setEditorActive, setLessonContents, setLessonState } from './lessonPageEditorSlice';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Card, Flex } from 'antd';
import { Constants } from 'core/utils/utils';
import HeaderBar from 'core/components/Header';
import Droppable from './Droppable';
import LessonPreviewCard from 'shared/components/MyLessonPreviewCard';
import { useGetLessonContentsQuery, useGetLessonSettingsQuery, useUpdateLessonPreviewTitleMutation } from 'core/services/lessons';
import MyErrorResult from 'shared/components/MyResult';
import styles from './styles.module.css';
import MyEmpty from 'shared/components/MyEmpty';
const PreviewCard: React.FC = () => {
const dispatch = useDispatch();
const { lessonId } = useParams();
const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(
lessonId as string,
{
const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(lessonId as string, {
refetchOnMountOrArgChange: true,
}
);
});
const [updateLessonPreviewTitle] = useUpdateLessonPreviewTitleMutation();
@ -48,8 +34,8 @@ const PreviewCard: React.FC = () => {
lessonId={lessonId as string}
loading={isLoading}
lessonSettings={{
Title: data?.Title || "",
ThumbnailUrl: data?.ThumbnailUrl || "",
Title: data?.Title || '',
ThumbnailUrl: data?.ThumbnailUrl || '',
}}
onEditTitle={async (newTitle) => {
try {
@ -74,12 +60,9 @@ const LessonContentComponents: React.FC = () => {
const { lessonId } = useParams();
const dispatch = useDispatch();
const { data, error, isLoading } = useGetLessonContentsQuery(
lessonId as string,
{
const { data, error, isLoading } = useGetLessonContentsQuery(lessonId as string, {
refetchOnMountOrArgChange: true,
}
);
});
const lnContents = useSelector(lessonContents);
@ -94,11 +77,7 @@ const LessonContentComponents: React.FC = () => {
return (
<Card loading={isLoading}>
<Flex vertical gap={16}>
{!lnContents || lnContents.length == 0 ? (
<MyEmpty />
) : (
<Droppable items={lnContents} />
)}
{!lnContents || lnContents.length == 0 ? <MyEmpty /> : <Droppable items={lnContents} />}
</Flex>
</Card>
);
@ -125,27 +104,12 @@ export default function LessonPageEditor() {
<>
<HeaderBar
theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(
":lessonId",
lessonId as string
)}
onView={() =>
navigate(
Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(
":lessonId",
lessonId as string
)
)
}
backTo={Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(':lessonId', lessonId as string)}
onView={() => navigate(Constants.ROUTE_PATHS.LESSIONS.PAGE.replace(':lessonId', lessonId as string))}
/>
<Flex justify="center" style={{ paddingTop: 24 }}>
<Flex
justify="center"
vertical
gap={16}
className={styles.cardContainer}
>
<Flex justify="center" vertical gap={16} className={styles.cardContainer}>
<PreviewCard />
<LessonContentComponents />

View File

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

View File

@ -0,0 +1,34 @@
import { createSlice } from '@reduxjs/toolkit';
import { LessonContent, LessonState } from 'core/types/lesson';
interface AddLessonContentAction {
type: string;
payload: LessonContent;
}
/*
export const lessonPageEditorSlice = createSlice({
name: 'lessonQuestions',
initialState: {
editorActive: false,
currentLessonId: '', // required in sideMenu because has no access to useParams
lessonThumbnail: {
img: '',
title: 'Tesdt',
},
lessonContents: [] as LessonContent[],
lessonState: LessonState.Draft,
},
reducers: {
setEditorActive: (state, action) => {
state.editorActive = action.payload;
},
},
selectors: {
editorActive: (state) => state.editorActive,
},
});
export const { setEditorActive } = lessonPageEditorSlice.actions;
export const { editorActive } = lessonPageEditorSlice.selectors;
*/

View File

@ -17,48 +17,49 @@ export type Component = {
const componentsGroups: ComponentGroup[] = [
{
category: "Common",
category: 'Common',
components: [
{
type: 0,
name: "Header",
thumbnail: "/editor/thumbnails/component_thumbnail_header.svg",
name: 'Header',
thumbnail: '/editor/thumbnails/component_thumbnail_header.svg',
invertThumbnailAtDarkmode: true,
defaultData: "Header",
defaultData: 'Header',
},
{
type: 1,
name: "Text",
thumbnail: "/editor/thumbnails/component_thumbnail_text.svg",
name: 'Text',
thumbnail: '/editor/thumbnails/component_thumbnail_text.svg',
invertThumbnailAtDarkmode: true,
defaultData: "Text",
defaultData: 'Text',
},
],
},
{
category: "Media",
category: 'Media',
components: [
{
type: 2,
name: "Image",
thumbnail: "/editor/thumbnails/component_thumbnail_image.png",
name: 'Image',
thumbnail: '/editor/thumbnails/component_thumbnail_image.png',
uploadImage: true,
uploadFileTypes: ["image/*"],
uploadFileTypes: ['image/*'],
},
{
type: 3,
name: "YouTube",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
name: 'YouTube',
thumbnail: '/editor/thumbnails/component_thumbnail_youtube.png',
invertThumbnailAtDarkmode: true,
},
{
type: 4,
name: "Video",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
name: 'Video',
thumbnail: '/editor/thumbnails/component_thumbnail_youtube.png',
invertThumbnailAtDarkmode: true,
},
],
},
/*
{
category: "HTML",
components: [
@ -79,7 +80,7 @@ const componentsGroups: ComponentGroup[] = [
thumbnail: "/editor/thumbnails/component_thumbnail_banner.png",
},
],
},
}, */
];
const componentsMap: { [key: string]: Component } = (() => {

View File

@ -1,21 +1,25 @@
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";
import { LessonContent } from 'core/types/lesson';
import { getTypeByName } from './components';
import { Button, Input, Typography, Flex } from 'antd';
import { useUpdateLessonContentMutation } from 'core/services/lessons';
import { useSelector } from 'react-redux';
import { currentLessonId } from './LessonPageEditor/lessonPageEditorSlice';
import { useRef, useEffect } from 'react';
import MyUpload from 'shared/components/MyUpload';
import { Constants } from 'core/utils/utils';
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
export function Converter({
mode,
lessonContent,
onEdit,
}: {
mode: "view" | "edititable";
lessonContent: LessonContent;
onEdit?: (newData: string) => void;
}) {
const extractVideoId = (url: string) => {
// regex to extract video id from youtube url
const regex = /(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:watch\?v=|embed\/|v\/|.+\?v=|.+\/|.+v=)?([^"&?\/\s]{11})/;
const match = url.match(regex);
return match ? match[1] : url;
};
export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edititable'; lessonContent: LessonContent; onEdit?: (newData: string) => void }) {
const lessonId = useSelector(currentLessonId);
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
@ -23,21 +27,15 @@ export function Converter({
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>
);
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,
triggerType: 'text' as any,
onChange: (event) => {
onEdit?.(event);
@ -55,29 +53,28 @@ export function Converter({
level={1}
style={{
margin: 0,
width: "100%",
width: '100%',
}}
>
{lessonContent.Data}
</Typography.Title>
);
case getTypeByName("Text"):
if (mode === "view") {
return (
<div style={{ fontSize: 16, wordBreak: "break-all" }}>
{lessonContent.Data}
</div>
);
case getTypeByName('Text'):
if (mode === 'view') {
const formattedText = lessonContent.Data.split('\n').map((line, index) => <li key={index}>{line || '\u00A0'}</li>);
return <ul style={{ fontSize: 16, wordBreak: 'break-all', padding: 0, margin: 0, listStyleType: 'none' }}>{formattedText}</ul>;
}
return (
<Input.TextArea
autoSize
variant="borderless"
placeholder="Input text here"
style={{ width: "100%" }}
style={{ width: '100%', padding: 0, paddingTop: 4 }}
value={lessonContent.Data}
onChange={(event) => {
console.log("edit");
console.log('edit');
onEdit?.(event.target.value);
@ -99,26 +96,26 @@ export function Converter({
}}
/>
);
case getTypeByName("Image"):
console.log("image", lessonContent.Data);
case getTypeByName('Image'):
console.log('image', lessonContent.Data);
if (mode === "view" && lessonContent.Data === "") {
if (mode === 'view' && lessonContent.Data === '') {
return (
<div
style={{
position: "relative",
position: 'relative',
height: 120,
width: "100%",
backgroundColor: "#EBEBEB",
width: '100%',
backgroundColor: '#EBEBEB',
marginRight: 8,
}}
>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
No image provided
@ -127,55 +124,216 @@ export function Converter({
);
}
if (lessonContent.Data === "") {
const GalleryUpload = () => {
return (
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/image`}
onChange={(info) => {
if (info.file.status === 'done') {
console.log('done');
onEdit?.(info.file.response.Data);
}
}}
imgCropProps={{
aspect: 5 / 4,
children: <></>,
}}
>
<Button type="link">Gallery</Button>
</MyUpload>
);
};
if (lessonContent.Data === '') {
return (
<div
style={{
position: "relative",
position: 'relative',
height: 120,
width: "100%",
backgroundColor: "#EBEBEB",
margin: "12px 12px 12px 0",
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%)",
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>
<GalleryUpload />
</div>
</div>
);
}
return (
<>
<img src={lessonContent.Data} alt="img" style={{ width: "100%" }} />
</>
<Flex vertical style={{ width: '100%' }}>
<img src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`} alt="img" style={{ width: '100%' }} />
{mode === 'edititable' && (
<Flex style={{ width: '100%', backgroundColor: '#EBEBEB' }} justify="center">
<div>
<span>Choose another image from</span>
<GalleryUpload />
</div>
</Flex>
)}
</Flex>
);
case getTypeByName("YouTube"):
case getTypeByName('YouTube'):
const videoId = extractVideoId(lessonContent.Data);
console.log('videoId', videoId);
return (
<div style={{ fontWeight: "700", fontSize: 20, width: "100%" }}>
{lessonContent.Data}
<Flex vertical style={{ width: '100%', paddingBottom: 12 }}>
<iframe
width="100%"
height={mode === 'view' ? 422 : 390}
style={{ border: 0 }}
src={`https://www.youtube.com/embed/${videoId}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
></iframe>
{mode === 'edititable' && (
<>
<Typography.Text>Video ID</Typography.Text>
<Input
placeholder="https://www.youtube.com/watch?v=Robzc9p1l50 or Robzc9p1l50"
value={lessonContent.Data}
onChange={(event) => {
console.warn('edit', event.target.value, videoId);
onEdit?.(event.target.value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (event.target.value === '') {
return;
}
debounceRef.current = setTimeout(() => {
try {
reqUpdateLessonContent({
lessonId: lessonId,
contentId: lessonContent.Id,
data: extractVideoId(event.target.value),
});
} catch (err) {
console.error(err);
}
}, 1000);
}}
/>
</>
)}
</Flex>
);
case getTypeByName('Video'):
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 video provided
</div>
</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>;
}
const VideoUpload = () => {
return (
<MyUpload
action={`/lessons/${lessonId}/contents/${lessonContent.Id}/file/video`}
onChange={(info) => {
if (info.file.status === 'done') {
console.log('done');
onEdit?.(info.file.response.Data);
}
}}
accept={Constants.ACCEPTED_VIDEO_FILE_TYPES}
>
<Button type="link">Video</Button>
</MyUpload>
);
};
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 video from</span>
<VideoUpload />
</div>
</div>
);
}
return (
<Flex vertical style={{ width: '100%' }}>
<MediaPlayer load="idle" src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`}>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
{mode === 'edititable' && (
<Flex style={{ width: '100%', backgroundColor: '#EBEBEB', height: 48 }} justify="center" align="center">
<div>
<span>Choose another video from</span>
<VideoUpload />
</div>
</Flex>
)}
</Flex>
);
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

@ -6,27 +6,60 @@ 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 { useGetOrganizationSettingsQuery } from "core/services/organization";
import MyErrorResult from "shared/components/MyResult";
import { useForm } from "antd/es/form/Form";
import { useEffect } from "react";
import { AggregationColor } from "antd/es/color-picker/color";
type FieldType = {
primaryColor: string;
companyName: string;
subdomain: string;
};
export default function Settings() {
const { data, error, isLoading } = useGetOrganizationSettingsQuery(
undefined,
{
refetchOnMountOrArgChange: true,
}
);
const [form] = useForm<FieldType>();
const handleSave = (values: FieldType) => {
console.log(values);
};
useEffect(() => {
if (data) {
form.setFieldsValue({
primaryColor: data.PrimaryColor,
companyName: data.CompanyName,
subdomain: data.Subdomain,
});
}
}, [data]);
if (error) return <MyErrorResult />;
return (
<>
<MyBanner title="Settings" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyContainer>
<Flex vertical gap={16} style={{ paddingBottom: 16 }}>
<Form form={form} onFinish={handleSave}>
<Card
loading={isLoading}
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>
title="Branding"
extra={
<Button
icon={<SaveOutlined />}
type="text"
@ -34,9 +67,9 @@ export default function Settings() {
size="large"
htmlType="submit"
/>
</Flex>
<Divider style={{ margin: 0, padding: 0 }} />
}
>
<Flex vertical gap={2}>
<Flex gap={32}>
<Flex vertical>
<Typography.Text style={{ fontSize: 16 }}>
@ -47,6 +80,7 @@ export default function Settings() {
defaultValue="#1677ff"
size="small"
showText
format="hex"
/>
</Form.Item>
</Flex>
@ -63,7 +97,11 @@ export default function Settings() {
Subdomain
</Typography.Text>
<Form.Item name="subdomain">
<Input addonBefore="https://" addonAfter=". jannex . de" defaultValue="mysite" />
<Input
addonBefore="https://"
addonAfter=". jannex.de"
defaultValue="mysite"
/>
</Form.Item>
</Flex>
<Flex vertical>
@ -128,9 +166,9 @@ export default function Settings() {
/>
</MyUpload>
</Flex>
</Form>
</Flex>
</Card>
</Form>
</Flex>
</MyContainer>
</>

View File

@ -1,13 +1,13 @@
import MyTable from "shared/components/MyTable";
import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner";
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";
import MyTable from 'shared/components/MyTable';
import HeaderBar from '../../core/components/Header';
import MyBanner from '../../shared/components/MyBanner';
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/organization';
import MyErrorResult from 'shared/components/MyResult';
const TeamList: React.FC = () => {
const { data, error, isLoading } = useGetTeamQuery(undefined, {
@ -17,34 +17,34 @@ const TeamList: React.FC = () => {
const getTableContent = () => {
let items = [
{
title: "First name",
dataIndex: "firstName",
key: "firstName",
title: 'First name',
dataIndex: 'firstName',
key: 'firstName',
},
{
title: "Last name",
dataIndex: "lastName",
key: "lastName",
title: 'Last name',
dataIndex: 'lastName',
key: 'lastName',
},
{
title: "Email",
dataIndex: "email",
key: "email",
title: 'Email',
dataIndex: 'email',
key: 'email',
},
{
title: "Role",
dataIndex: "role",
key: "role",
title: 'Role',
dataIndex: 'role',
key: 'role',
},
{
title: "Status",
dataIndex: "status",
key: "status",
title: 'Status',
dataIndex: 'status',
key: 'status',
},
{
title: "Actions",
dataIndex: "actions",
key: "actions",
title: 'Actions',
dataIndex: 'actions',
key: 'actions',
},
];
@ -68,13 +68,11 @@ const TeamList: React.FC = () => {
});
return items;
}
};
if (error) return <MyErrorResult />;
return (
<MyTable loading={isLoading} columns={getTableContent()} dataSource={getTableItems()} pagination={false} />
);
return <MyTable loading={isLoading} columns={getTableContent()} dataSource={getTableItems()} pagination={false} />;
};
export default function Team() {
@ -84,8 +82,8 @@ export default function Team() {
<MyContainer
style={{
display: "flex",
flexDirection: "column",
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>

View File

@ -1,11 +1,11 @@
import { Card, CardProps, Flex } from "antd";
import { MyContainer } from "../MyContainer";
import { Card, CardProps, Flex } from 'antd';
import { MyContainer } from '../MyContainer';
interface MyMiddleCardProps extends CardProps {}
interface MyMiddleCardProps extends CardProps {
outOfCardChildren?: React.ReactNode;
}
const MyMiddleCard: React.FC<MyMiddleCardProps> = ({ children,
...props
}) => {
const MyMiddleCard: React.FC<MyMiddleCardProps> = ({ children, outOfCardChildren, ...props }) => {
return (
<MyContainer>
<Flex justify="center">
@ -19,8 +19,9 @@ const MyMiddleCard: React.FC<MyMiddleCardProps> = ({ children,
{children}
</Card>
</Flex>
{outOfCardChildren}
</MyContainer>
);
}
};
export default MyMiddleCard;

View File

@ -1,58 +1,67 @@
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";
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';
import { Fragment } from 'react';
export default function MyUpload({
children,
imgCropProps,
accept = Constants.ACCEPTED_IMAGE_FILE_TYPES.join(","),
accept = Constants.ACCEPTED_IMAGE_FILE_TYPES,
maxCount = 1,
showUploadList = false,
headers = getApiHeader(),
action,
onChange,
fileType = 'image',
}: {
children: React.ReactNode;
imgCropProps?: ImgCropProps;
accept?: string;
accept?: string | string[];
maxCount?: number;
showUploadList?: boolean;
headers?: any;
action?: string;
onChange?: (info: any) => void;
fileType?: 'image' | 'video';
}) {
const beforeUpload = (file: File) => {
if (!Constants.ACCEPTED_IMAGE_FILE_TYPES.includes(file.type)) {
console.error("File typ not allowed!");
if (!accept.includes(file.type)) {
console.error('File typ not allowed!');
return false;
}
if (file.size > Constants.MAX_IMAGE_SIZE) {
console.error("Image is to large!");
console.error('Image is to large!');
return false;
}
return true;
};
return (
<ImgCrop
{...imgCropProps}
rotationSlider
>
const acceptFileTypes = Array.isArray(accept) ? accept.join(',') : (accept as string);
const MyUpload = () => (
<Upload
accept={accept}
accept={acceptFileTypes}
maxCount={maxCount}
showUploadList={showUploadList}
headers={headers}
action={`${Constants.API_ADDRESS}${action}`}
onChange={onChange}
beforeUpload={beforeUpload}
>
{children}
</Upload>
);
if (fileType === 'video') {
return <MyUpload />;
}
return (
<ImgCrop {...imgCropProps} rotationSlider>
<MyUpload />
</ImgCrop>
);
}