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,129 +1,118 @@
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";
onView?: () => void;
onEdit?: () => void;
backTo?: string;
theme?: 'light' | 'dark';
onView?: () => void;
onEdit?: () => void;
backTo?: string;
};
export default function HeaderBar(props: HeaderBarProps = { theme: "light" }) {
const dispatch = useDispatch();
export default function HeaderBar(props: HeaderBarProps = { theme: 'light' }) {
const dispatch = useDispatch();
const isCollpased = useSelector(isSideMenuCollapsed);
const isDarkMode = useSelector(darkMode);
const isCollpased = useSelector(isSideMenuCollapsed);
const isDarkMode = useSelector(darkMode);
return (
<Flex
justify="space-between"
align="center"
style={{
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
>
<Flex align="center" gap={16}>
<div
className={
props.theme === "light"
? styles.containerLight
: styles.containerDark
}
style={{ borderRadius: 28, padding: 4 }}
const navigate = useNavigate();
return (
<Flex
justify="space-between"
align="center"
style={{
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
>
{isCollpased ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(false))}
>
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(true))}
>
<MenuFoldOutlined className={styles.icon} />
</div>
)}
</div>
<Flex align="center" gap={16}>
<div className={props.theme === 'light' ? styles.containerLight : styles.containerDark} style={{ borderRadius: 28, padding: 4 }}>
{isCollpased ? (
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(false))}>
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<div className={styles.iconContainer} onClick={() => dispatch(setIsSideMenuCollapsed(true))}>
<MenuFoldOutlined className={styles.icon} />
</div>
)}
</div>
{props.backTo && (
<Link to={props.backTo}>
<Flex gap={4}>
<LeftOutlined />
<span>Back</span>
{props.backTo && (
<Link to={props.backTo}>
<Flex gap={4}>
<LeftOutlined />
<span>Back</span>
</Flex>
</Link>
)}
</Flex>
</Link>
)}
</Flex>
<Flex
align="center"
className={
props.theme === "light" ? styles.containerLight : styles.containerDark
}
style={{
borderRadius: 28,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
}}
gap={8}
>
{props.onView && (
<div className={styles.iconContainer} onClick={props.onView}>
<EyeOutlined className={styles.icon} />
</div>
)}
<Flex
align="center"
className={props.theme === 'light' ? styles.containerLight : styles.containerDark}
style={{
borderRadius: 28,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
}}
gap={8}
>
{props.onView && (
<div className={styles.iconContainer} onClick={props.onView}>
<EyeOutlined className={styles.icon} />
</div>
)}
{props.onEdit && (
<div className={styles.iconContainer} onClick={props.onEdit}>
<EditOutlined className={styles.icon} />
</div>
)}
{props.onEdit && (
<div className={styles.iconContainer} onClick={props.onEdit}>
<EditOutlined className={styles.icon} />
</div>
)}
{isDarkMode ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(false))}
>
<SunOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setDarkMode(true))}
>
<MoonOutlined className={styles.icon} />
</div>
)}
<Avatar size="default" icon={<UserOutlined />} />
</Flex>
</Flex>
);
{isDarkMode ? (
<div className={styles.iconContainer} onClick={() => dispatch(setDarkMode(false))}>
<SunOutlined className={styles.icon} />
</div>
) : (
<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>
);
/* return (
/* return (
<Header
style={{
position: "sticky",

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,30 +1,26 @@
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({
reducer: {
app: appSlice.reducer,
sideMenu: sideMenuSlice.reducer,
lessonPageEditor: lessonPageEditorSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer,
[teamApi.reducerPath]: teamApi.reducer,
},
// preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
lessonsApi.middleware,
teamApi.middleware
),
});
const store = configureStore({
reducer: {
app: appSlice.reducer,
sideMenu: sideMenuSlice.reducer,
lessonPageEditor: lessonPageEditorSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer,
[organizationApi.reducerPath]: organizationApi.reducer,
},
// preloadedState,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(lessonsApi.middleware, organizationApi.middleware),
});
setupListeners(store.dispatch);
return store;
setupListeners(store.dispatch);
return store;
};
export const store = makeStore();

View File

@ -1,34 +1,55 @@
export interface Lesson {
Id: string;
State: number;
Title: string;
ThumbnailUrl: string;
CreatorUserId: string;
CreatedAt: string;
Id: string;
State: number;
Title: string;
ThumbnailUrl: string;
CreatorUserId: string;
CreatedAt: string;
}
export enum LessonState {
Published = 1,
Draft = 2,
Published = 1,
Draft = 2,
}
// used for the preview card on /lessions page and on the lesson editor
export interface LessonSettings {
Title: string;
ThumbnailUrl: string;
State?: LessonState;
Title: string;
ThumbnailUrl: string;
State?: LessonState;
}
// used on lesson page and on the lesson editor
export interface LessonContent {
Id: string;
Page: number;
Position: number;
Type: number;
Data: string;
Id: string;
Page: number;
Position: number;
Type: number;
Data: string;
}
export interface UpdateLessonPreviewThumbnail {
lessonId: string;
formData: FormData;
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,48 +1,44 @@
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`,
WS_ADDRESS: `${wssProtocol}${window.location.hostname}/apiws`,
STATIC_CONTENT_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/static`,
ROUTE_PATHS: {
LESSIONS: {
ROOT: "/lessons",
PAGE: "/lessons/:lessonId",
PAGE_EDITOR: "/lessons/:lessonId/editor",
API_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/v1`,
WS_ADDRESS: `${wssProtocol}${window.location.hostname}/apiws`,
STATIC_CONTENT_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/static`,
ROUTE_PATHS: {
LESSIONS: {
ROOT: '/lessons',
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",
},
MAX_IMAGE_SIZE: 25 * 1024 * 1024, // 25MB
ACCEPTED_IMAGE_FILE_TYPES: [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
],
STYLES: {
BLACK: '#000',
},
MAX_IMAGE_SIZE: 25 * 1024 * 1024, // 25MB
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
export const BreakpointLgWidth = 992;
export function GetUuid() {
return uuidv4();
return uuidv4();
}
export function getImageUrl(imageName: string) {
return `${Constants.STATIC_CONTENT_ADDRESS}/${imageName}`;
return `${Constants.STATIC_CONTENT_ADDRESS}/${imageName}`;
}
// needed for a user who uses multiple tabs in the browser
@ -53,140 +49,140 @@ 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 = {
JSON: 0,
FORM_DATA: 1,
JSON: 0,
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";
fetchUrl?: string;
ignoreUnauthorized?: boolean;
notificationApi?: any; // Passen Sie dies je nach Bedarf an
t?: any; // Passen Sie dies je nach Bedarf an
url?: string;
method?: Method;
body?: TRequest | null;
headers?: Record<string, string>;
contentType?: 'JSON' | 'FORM_DATA';
fetchUrl?: string;
ignoreUnauthorized?: boolean;
notificationApi?: any; // Passen Sie dies je nach Bedarf an
t?: any; // Passen Sie dies je nach Bedarf an
}
export function myFetch<TRequest = any, TResponse = any>({
url = "",
method = "GET",
body = null,
headers = {},
contentType = "JSON",
fetchUrl = Constants.API_ADDRESS,
ignoreUnauthorized = false,
notificationApi = null,
t = null,
url = '',
method = 'GET',
body = null,
headers = {},
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";
const getContentType = () => {
if (contentType === 'JSON') return 'application/json';
return "multipart/form-data";
};
return 'multipart/form-data';
};
const getBody = () => {
if (!body) return null;
const getBody = () => {
if (!body) return null;
if (contentType === "JSON") return JSON.stringify(body);
if (contentType === 'JSON') return JSON.stringify(body);
return body;
};
return body;
};
// abort fetch if it takes to long
const controller = new AbortController();
const signal = controller.signal;
// abort fetch if it takes to long
const controller = new AbortController();
const signal = controller.signal;
setTimeout(() => controller.abort(), 30000); // 30 seconds
setTimeout(() => controller.abort(), 30000); // 30 seconds
const requestOptions = {
method: method,
headers: {
"X-Authorization": getUserSessionFromLocalStorage() || "",
"Content-Type": getContentType(),
...headers,
},
body: getBody(),
signal: signal,
};
const requestOptions = {
method: method,
headers: {
'X-Authorization': getUserSessionFromLocalStorage() || '',
'Content-Type': getContentType(),
...headers,
},
body: getBody(),
signal: signal,
};
if (fetchUrl === "") {
fetchUrl = Constants.API_ADDRESS;
}
if (fetchUrl === '') {
fetchUrl = Constants.API_ADDRESS;
}
return fetch(`${fetchUrl}${url}`, requestOptions as RequestInit)
.then(async (response) => {
// if status is not in range 200-299
if (!response.ok) {
if (!ignoreUnauthorized && response.status === 401) {
console.error("Unauthorized");
// TODO: check here
//setUserSessionToLocalStorage("");
//window.location.href = "/";
}
return fetch(`${fetchUrl}${url}`, requestOptions as RequestInit)
.then(async (response) => {
// if status is not in range 200-299
if (!response.ok) {
if (!ignoreUnauthorized && response.status === 401) {
console.error('Unauthorized');
// TODO: check here
//setUserSessionToLocalStorage("");
//window.location.href = "/";
}
throw response.status;
}
throw response.status;
}
// check if response is json
if (response.headers.get("content-type")?.includes("application/json")) {
return response.json();
}
// check if response is json
if (response.headers.get('content-type')?.includes('application/json')) {
return response.json();
}
return response.text();
})
.catch(async (error) => {
console.error("Error", error);
return response.text();
})
.catch(async (error) => {
console.error('Error', error);
// ignore errors here as they are handled in the components
if (error === 400) throw error;
// ignore errors here as they are handled in the components
if (error === 400) throw error;
if (error === 401) {
if (ignoreUnauthorized) {
throw error;
}
if (error === 401) {
if (ignoreUnauthorized) {
throw error;
}
handleLogout();
return;
}
handleLogout();
return;
}
// show error notification for all other errors
// show error notification for all other errors
if (notificationApi !== null && t !== null) {
if (error === 500) {
/* notificationApi["error"]({
if (notificationApi !== null && t !== null) {
if (error === 500) {
/* notificationApi["error"]({
message: t("common.request.failed.title"),
description: t("common.request.failed.description"),
}); */
} else {
// other errors
/* notificationApi["error"]({
} else {
// other errors
/* notificationApi["error"]({
message: t("common.request.failedInternetProblem.title"),
description: t("common.request.failedInternetProblem.description"),
}); */
}
}
}
}
throw error;
});
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 />}
/>
export default function AccountSettings({ isAdmin }: { isAdmin?: boolean }) {
function AdminWrapper({ children }: { children: React.ReactNode }) {
if (!isAdmin) {
return <>{children}</>;
}
<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>
</>
);
}
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>
);
}
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,63 +1,63 @@
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 { lessonId } = useParams();
const { data, error, isLoading } = useGetLessonContentsQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
const { data, error, isLoading } = useGetLessonContentsQuery(lessonId as string, {
refetchOnMountOrArgChange: true,
});
if (isLoading) return <MySpin />;
if (error) return <MyErrorResult />;
if (isLoading) return <MySpin />;
if (error) return <MyErrorResult />;
if (!data || data.length === 0) return <MyEmpty />;
if (!data || data.length === 0) return <MyEmpty />;
return (
<>
{data.map((lessonContent) => (
<div key={lessonContent.Id} style={{ paddingBottom: 6 }}>
<Converter mode="view" lessonContent={lessonContent} />
</div>
))}
</>
);
return (
<>
{data.map((lessonContent) => (
<div key={lessonContent.Id} style={{ paddingBottom: 6 }}>
<Converter mode="view" lessonContent={lessonContent} />
</div>
))}
</>
);
};
export default function LessonPage() {
const location = useLocation();
const navigate = useNavigate();
const location = useLocation();
const navigate = useNavigate();
return (
<>
<HeaderBar
theme="light"
backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT}
onEdit={() => navigate(`${location.pathname}/editor`)}
/>
return (
<>
<HeaderBar theme="light" backTo={Constants.ROUTE_PATHS.LESSIONS.ROOT} onEdit={() => navigate(`${location.pathname}/editor`)} />
<MyMiddleCard>
<LessonContents />
<MyMiddleCard
outOfCardChildren={
<div style={{ marginTop: 24 }}>
<Questions lessionID={'lessionID'} />
</div>
}
>
<LessonContents />
<Flex justify="right">
<Button type="primary" icon={<CheckOutlined />}>
Finish lesson
</Button>
</Flex>
</MyMiddleCard>
</>
);
<Flex justify="right">
<Button type="primary" icon={<CheckOutlined />}>
Finish lesson
</Button>
</Flex>
</MyMiddleCard>
</>
);
}

View File

@ -1,76 +1,76 @@
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 { setNodeRef } = useDroppable({ id: droppableID });
const currentLnId = useSelector(currentLessonId);
const droppableID = 'editorComponentArea';
const { setNodeRef } = useDroppable({ id: droppableID });
const currentLnId = useSelector(currentLessonId);
const [reqUpdateLessonContentPosition] =
useUpdateLessonContentPositionMutation();
const [reqUpdateLessonContentPosition] = useUpdateLessonContentPositionMutation();
const itemIDs = items.map((item) => item.Id);
const [isDragging, setIsDragging] = React.useState(false);
const handleDragEnd = (event: DragEndEvent) => {
console.log("drag end", event);
const itemIDs = items.map((item) => item.Id);
if (!event.over) return;
const handleDragEnd = (event: DragEndEvent) => {
console.log('drag end', event);
setIsDragging(false);
const activeId = event.active.id;
const overId = event.over.id;
if (!event.over) return;
if (activeId === overId) return;
const activeId = event.active.id;
const overId = event.over.id;
let oldIndex = itemIDs.findIndex((item) => item === activeId);
let newIndex = itemIDs.findIndex((item) => item === overId);
if (activeId === overId) return;
// store.dispatch(onDragHandler({ activeId, overId }));
let oldIndex = itemIDs.findIndex((item) => item === activeId);
let newIndex = itemIDs.findIndex((item) => item === overId);
store.dispatch(onDragHandler({ oldIndex, newIndex }));
// store.dispatch(onDragHandler({ activeId, overId }));
try {
reqUpdateLessonContentPosition({
lessonId: currentLnId,
contentId: activeId,
newPosition: newIndex + 1,
});
} catch (err) {
console.error(err);
}
};
store.dispatch(onDragHandler({ oldIndex, newIndex }));
return (
<DndContext
modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}
onDragEnd={handleDragEnd}
>
<SortableContext
id={droppableID}
items={itemIDs}
strategy={verticalListSortingStrategy}
>
<div ref={setNodeRef}>
{items.map((item) => (
<SortableEditorItem key={`${droppableID}_${item.Id}`} item={item} />
))}
</div>
</SortableContext>
</DndContext>
);
try {
reqUpdateLessonContentPosition({
lessonId: currentLnId,
contentId: activeId,
newPosition: newIndex + 1,
});
} catch (err) {
console.error(err);
}
};
return (
<DndContext
modifiers={[snapCenterToCursor]}
collisionDetection={closestCorners}
onDragStart={() => {
setIsDragging(true);
}}
onDragEnd={handleDragEnd}
>
<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>
);
};
/*
function handleDragEnd(event: DragEndEvent) {

View File

@ -1,110 +1,107 @@
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 lnContent = props.item;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: lnContent.Id });
const dispatch = useDispatch();
const dispatch = useDispatch();
const component = getComponentByType(lnContent.Type);
const component = getComponentByType(lnContent.Type);
const [reqDeleteLessonContent] = useDeleteLessonContentMutation();
const currentLnId = useSelector(currentLessonId);
const [reqDeleteLessonContent] = useDeleteLessonContentMutation();
const currentLnId = useSelector(currentLessonId);
if (!component) {
return null;
}
if (!component) {
return null;
}
return (
<div
style={{
transform: CSS.Translate.toString(transform),
transition,
}}
ref={setNodeRef}
{...attributes}
>
<Flex key={lnContent.Id}>
<HolderOutlined
style={{
paddingLeft: 8,
paddingRight: 8,
touchAction: "none",
cursor: "move",
}}
{...listeners}
/>
<Converter
mode="edititable"
lessonContent={lnContent}
onEdit={(data) => {
console.log("edit", lnContent.Id, data);
return (
<div
style={{
transform: CSS.Translate.toString(transform),
transition,
}}
ref={setNodeRef}
{...attributes}
>
<Flex key={lnContent.Id}>
<Flex>
<HolderOutlined
style={{
paddingLeft: 8,
paddingRight: 8,
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);
dispatch(
updateLessonContent({
id: lnContent.Id,
data: data,
})
);
}}
/>
dispatch(
updateLessonContent({
id: lnContent.Id,
data: data,
})
);
}}
/>
</Flex>
<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));
<Flex vertical justify="center" style={{ paddingLeft: 12 }}>
<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>
</div>
);
try {
reqDeleteLessonContent({
lessonId: currentLnId,
contentId: lnContent.Id,
});
} catch (err) {
console.error(err);
}
}}
/>
</div>
</Flex>
</Flex>
</div>
);
};
/*
{component.uploadImage ? (
<div className="EditorActionIcon">
<CameraOutlined />
</div>
) : null}
{component.uploadFileTypes ? (
<div className="EditorActionIcon">
<FolderOpenOutlined className="EditorActionIcon" />{' '}
</div>
) : null}
*/
export default SortableEditorItem;

View File

@ -1,156 +1,120 @@
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 dispatch = useDispatch();
const { lessonId } = useParams();
const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
const { data, error, isLoading, refetch } = useGetLessonSettingsQuery(lessonId as string, {
refetchOnMountOrArgChange: true,
});
const [updateLessonPreviewTitle] = useUpdateLessonPreviewTitleMutation();
const [updateLessonPreviewTitle] = useUpdateLessonPreviewTitleMutation();
useEffect(() => {
if (data?.State) dispatch(setLessonState(data.State));
}, [data]);
useEffect(() => {
if (data?.State) dispatch(setLessonState(data.State));
}, [data]);
if (error) return <MyErrorResult />;
if (error) return <MyErrorResult />;
return (
<LessonPreviewCard
mode="editable"
lessonId={lessonId as string}
loading={isLoading}
lessonSettings={{
Title: data?.Title || "",
ThumbnailUrl: data?.ThumbnailUrl || "",
}}
onEditTitle={async (newTitle) => {
try {
const res = await updateLessonPreviewTitle({
lessonId: lessonId as string,
newTitle: newTitle,
}).unwrap();
return (
<LessonPreviewCard
mode="editable"
lessonId={lessonId as string}
loading={isLoading}
lessonSettings={{
Title: data?.Title || '',
ThumbnailUrl: data?.ThumbnailUrl || '',
}}
onEditTitle={async (newTitle) => {
try {
const res = await updateLessonPreviewTitle({
lessonId: lessonId as string,
newTitle: newTitle,
}).unwrap();
if (res) {
refetch();
}
} catch (err) {
console.error(err);
}
}}
onThumbnailChanged={refetch}
/>
);
if (res) {
refetch();
}
} catch (err) {
console.error(err);
}
}}
onThumbnailChanged={refetch}
/>
);
};
const LessonContentComponents: React.FC = () => {
const { lessonId } = useParams();
const dispatch = useDispatch();
const { lessonId } = useParams();
const dispatch = useDispatch();
const { data, error, isLoading } = useGetLessonContentsQuery(
lessonId as string,
{
refetchOnMountOrArgChange: true,
}
);
const { data, error, isLoading } = useGetLessonContentsQuery(lessonId as string, {
refetchOnMountOrArgChange: true,
});
const lnContents = useSelector(lessonContents);
const lnContents = useSelector(lessonContents);
useEffect(() => {
if (!data) return;
useEffect(() => {
if (!data) return;
dispatch(setLessonContents(data));
}, [data]);
dispatch(setLessonContents(data));
}, [data]);
if (error) return <MyErrorResult />;
if (error) return <MyErrorResult />;
return (
<Card loading={isLoading}>
<Flex vertical gap={16}>
{!lnContents || lnContents.length == 0 ? (
<MyEmpty />
) : (
<Droppable items={lnContents} />
)}
</Flex>
</Card>
);
return (
<Card loading={isLoading}>
<Flex vertical gap={16}>
{!lnContents || lnContents.length == 0 ? <MyEmpty /> : <Droppable items={lnContents} />}
</Flex>
</Card>
);
};
export default function LessonPageEditor() {
const { lessonId } = useParams();
const navigate = useNavigate();
const { lessonId } = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const lnContents = useSelector(lessonContents);
const lnThumbnail = useSelector(lessonThumbnail);
const dispatch = useDispatch();
const lnContents = useSelector(lessonContents);
const lnThumbnail = useSelector(lessonThumbnail);
useEffect(() => {
dispatch(setEditorActive(true));
dispatch(setCurrentLessonId(lessonId as string));
useEffect(() => {
dispatch(setEditorActive(true));
dispatch(setCurrentLessonId(lessonId as string));
return () => {
dispatch(setEditorActive(false));
};
}, []);
return () => {
dispatch(setEditorActive(false));
};
}, []);
return (
<>
<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
)
)
}
/>
return (
<>
<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))}
/>
<Flex justify="center" style={{ paddingTop: 24 }}>
<Flex
justify="center"
vertical
gap={16}
className={styles.cardContainer}
>
<PreviewCard />
<Flex justify="center" style={{ paddingTop: 24 }}>
<Flex justify="center" vertical gap={16} className={styles.cardContainer}>
<PreviewCard />
<LessonContentComponents />
</Flex>
</Flex>
</>
);
<LessonContentComponents />
</Flex>
</Flex>
</>
);
}

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

@ -1,64 +1,65 @@
// Desc: This file contains the list of components that are used in the Lessons
type ComponentGroup = {
category: string;
components: Component[];
category: string;
components: Component[];
};
export type Component = {
type: number;
name: string;
thumbnail?: string;
invertThumbnailAtDarkmode?: boolean;
uploadFileTypes?: string[];
uploadImage?: boolean;
defaultData?: string;
type: number;
name: string;
thumbnail?: string;
invertThumbnailAtDarkmode?: boolean;
uploadFileTypes?: string[];
uploadImage?: boolean;
defaultData?: string;
};
const componentsGroups: ComponentGroup[] = [
{
category: "Common",
components: [
{
type: 0,
name: "Header",
thumbnail: "/editor/thumbnails/component_thumbnail_header.svg",
invertThumbnailAtDarkmode: true,
defaultData: "Header",
},
{
type: 1,
name: "Text",
thumbnail: "/editor/thumbnails/component_thumbnail_text.svg",
invertThumbnailAtDarkmode: true,
defaultData: "Text",
},
],
},
{
category: "Media",
components: [
{
type: 2,
name: "Image",
thumbnail: "/editor/thumbnails/component_thumbnail_image.png",
uploadImage: true,
uploadFileTypes: ["image/*"],
},
{
type: 3,
name: "YouTube",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true,
},
{
type: 4,
name: "Video",
thumbnail: "/editor/thumbnails/component_thumbnail_youtube.png",
invertThumbnailAtDarkmode: true,
},
],
},
{
category: 'Common',
components: [
{
type: 0,
name: 'Header',
thumbnail: '/editor/thumbnails/component_thumbnail_header.svg',
invertThumbnailAtDarkmode: true,
defaultData: 'Header',
},
{
type: 1,
name: 'Text',
thumbnail: '/editor/thumbnails/component_thumbnail_text.svg',
invertThumbnailAtDarkmode: true,
defaultData: 'Text',
},
],
},
{
category: 'Media',
components: [
{
type: 2,
name: 'Image',
thumbnail: '/editor/thumbnails/component_thumbnail_image.png',
uploadImage: true,
uploadFileTypes: ['image/*'],
},
{
type: 3,
name: 'YouTube',
thumbnail: '/editor/thumbnails/component_thumbnail_youtube.png',
invertThumbnailAtDarkmode: true,
},
{
type: 4,
name: 'Video',
thumbnail: '/editor/thumbnails/component_thumbnail_youtube.png',
invertThumbnailAtDarkmode: true,
},
],
},
/*
{
category: "HTML",
components: [
@ -79,34 +80,34 @@ const componentsGroups: ComponentGroup[] = [
thumbnail: "/editor/thumbnails/component_thumbnail_banner.png",
},
],
},
}, */
];
const componentsMap: { [key: string]: Component } = (() => {
const map: { [key: string]: Component } = {};
const map: { [key: string]: Component } = {};
for (const group of componentsGroups) {
for (const component of group.components) {
map[component.name] = component;
for (const group of componentsGroups) {
for (const component of group.components) {
map[component.name] = component;
}
}
}
return map;
return map;
})();
export function getTypeByName(name: string): number {
const component = componentsMap[name];
const component = componentsMap[name];
return component ? component.type : -1;
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;
for (const component of Object.values(componentsMap)) {
if (component.type === type) {
return component;
}
}
}
return null;
return null;
}
export { componentsGroups };

View File

@ -1,183 +1,341 @@
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 lessonId = useSelector(currentLessonId);
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;
};
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edititable'; lessonContent: LessonContent; onEdit?: (newData: string) => void }) {
const lessonId = useSelector(currentLessonId);
const debounceRef = useRef<null | NodeJS.Timeout>(null);
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
switch (lessonContent.Type) {
case getTypeByName("Header"):
if (mode === "view") {
return (
<div
style={{ fontWeight: "bold", fontSize: 24, wordBreak: "break-all" }}
>
{lessonContent.Data}
</div>
);
}
const debounceRef = useRef<null | NodeJS.Timeout>(null);
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);
switch (lessonContent.Type) {
case getTypeByName('Header'):
if (mode === 'view') {
return <div style={{ fontWeight: 'bold', fontSize: 24, wordBreak: 'break-all' }}>{lessonContent.Data}</div>;
}
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);
return (
<Typography.Title
editable={{
triggerType: 'text' as any,
onChange: (event) => {
onEdit?.(event);
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>
);
}
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') {
const formattedText = lessonContent.Data.split('\n').map((line, index) => <li key={index}>{line || '\u00A0'}</li>);
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>
return <ul style={{ fontSize: 16, wordBreak: 'break-all', padding: 0, margin: 0, listStyleType: 'none' }}>{formattedText}</ul>;
}
<MyUpload>
<Button type="link">Gallery</Button>
</MyUpload>
</div>
</div>
);
}
return (
<Input.TextArea
autoSize
variant="borderless"
placeholder="Input text here"
style={{ width: '100%', padding: 0, paddingTop: 4 }}
value={lessonContent.Data}
onChange={(event) => {
console.log('edit');
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>;
onEdit?.(event.target.value);
default:
return <div>Unknown type</div>;
}
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>
);
}
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',
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>
<GalleryUpload />
</div>
</div>
);
}
return (
<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'):
const videoId = extractVideoId(lessonContent.Data);
console.log('videoId', videoId);
return (
<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>
);
}
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,37 +6,70 @@ 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 }}>
<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 }} />
<Form form={form} onFinish={handleSave}>
<Card
loading={isLoading}
styles={{
body: {
padding: 16,
},
}}
title="Branding"
extra={
<Button
icon={<SaveOutlined />}
type="text"
shape="circle"
size="large"
htmlType="submit"
/>
}
>
<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>
</Flex>
</Card>
</Form>
</Flex>
</MyContainer>
</>

View File

@ -1,102 +1,100 @@
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, {
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,
});
const { data, error, isLoading } = useGetTeamQuery(undefined, {
refetchOnMountOrArgChange: true,
});
return items;
}
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',
},
];
if (error) return <MyErrorResult />;
return items;
};
return (
<MyTable loading={isLoading} columns={getTableContent()} dataSource={getTableItems()} pagination={false} />
);
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() {
return (
<>
<MyBanner title="Team" subtitle="MANAGE" headerBar={<HeaderBar />} />
return (
<>
<MyBanner title="Team" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyContainer
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>
<MyContainer
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>
</>
);
<TeamList />
</MyContainer>
</>
);
}

View File

@ -1,26 +1,27 @@
import { Card, CardProps, Flex } from "antd";
import { MyContainer } from "../MyContainer";
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>
);
interface MyMiddleCardProps extends CardProps {
outOfCardChildren?: React.ReactNode;
}
export default MyMiddleCard;
const MyMiddleCard: React.FC<MyMiddleCardProps> = ({ children, outOfCardChildren, ...props }) => {
return (
<MyContainer>
<Flex justify="center">
<Card
style={{
width: 800,
maxWidth: 800,
}}
{...props}
>
{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(","),
maxCount = 1,
showUploadList = false,
headers = getApiHeader(),
action,
onChange,
children,
imgCropProps,
accept = Constants.ACCEPTED_IMAGE_FILE_TYPES,
maxCount = 1,
showUploadList = false,
headers = getApiHeader(),
action,
onChange,
fileType = 'image',
}: {
children: React.ReactNode;
imgCropProps?: ImgCropProps;
accept?: string;
maxCount?: number;
showUploadList?: boolean;
headers?: any;
action?: string;
onChange?: (info: any) => void;
children: React.ReactNode;
imgCropProps?: ImgCropProps;
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!");
return false;
const beforeUpload = (file: File) => {
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!');
return false;
}
return true;
};
const acceptFileTypes = Array.isArray(accept) ? accept.join(',') : (accept as string);
const MyUpload = () => (
<Upload
accept={acceptFileTypes}
maxCount={maxCount}
showUploadList={showUploadList}
headers={headers}
action={`${Constants.API_ADDRESS}${action}`}
onChange={onChange}
beforeUpload={beforeUpload}
>
{children}
</Upload>
);
if (fileType === 'video') {
return <MyUpload />;
}
if (file.size > Constants.MAX_IMAGE_SIZE) {
console.error("Image is to large!");
return false;
}
return true;
};
return (
<ImgCrop
{...imgCropProps}
rotationSlider
>
<Upload
accept={accept}
maxCount={maxCount}
showUploadList={showUploadList}
headers={headers}
action={`${Constants.API_ADDRESS}${action}`}
onChange={onChange}
beforeUpload={beforeUpload}
>
{children}
</Upload>
</ImgCrop>
);
return (
<ImgCrop {...imgCropProps} rotationSlider>
<MyUpload />
</ImgCrop>
);
}