ai chat and websocket

main
alex 2024-09-08 00:31:44 +02:00
parent 9176e80363
commit 73aad4727d
35 changed files with 22448 additions and 21488 deletions

42048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +1,60 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@reduxjs/toolkit": "^2.2.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@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",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^13.5.0",
"react-redux": "^9.1.2",
"react-router-dom": "^6.19.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^10.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "BROWSER=none PORT=50261 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/uuid": "^10.0.0"
}
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@reduxjs/toolkit": "^2.2.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.106",
"@vidstack/react": "^1.12.9",
"antd": "^5.20.3",
"antd-img-crop": "^4.23.0",
"buffer": "^6.0.3",
"deep-chat-react": "^2.0.1",
"i18next": "^23.7.6",
"i18next-browser-languagedetector": "^7.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^13.5.0",
"react-redux": "^9.1.2",
"react-router-dom": "^6.19.0",
"react-scripts": "^5.0.1",
"uuid": "^10.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "BROWSER=none PORT=50261 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/uuid": "^10.0.0"
}
}

View File

@ -14,11 +14,14 @@ import SignIn from "./features/Auth/SignIn";
import { useEffect } from "react";
import { myFetch } from "./core/utils/utils";
import MyCenteredSpin from "./shared/components/MyCenteredSpin";
import webSocketService from "core/services/websocketService";
import webSocketService, {
WebSocketMessageHandler,
} from "core/services/websocketService";
import { MessageProvider } from "core/context/MessageContext";
const { defaultAlgorithm, darkAlgorithm } = theme;
function App() {
export default function App() {
const dispatch = useDispatch();
const isDarkMode = useSelector(darkMode);
@ -33,33 +36,41 @@ function App() {
);
useEffect(() => {
if (uAuthenticated) {
(async () => {
try {
const response = await myFetch({
url: "/app",
method: "GET",
});
if (response) {
dispatch(setUserAuthenticated(true));
dispatch(setPrimaryColor(`#${response.Organization.PrimaryColor}`));
dispatch(setLogoUrl(response.Organization.LogoUrl));
dispatch(setBannerUrl(response.Organization.BannerUrl));
}
} catch (error) {}
})();
webSocketService.connect();
webSocketService.setHandler(WebSocketMessageHandler, dispatch);
return () => {
webSocketService.disconnect();
};
}
}, [uAuthenticated]);
useEffect(() => {
console.log("App mounted");
if (!localStorage.getItem("session")) {
dispatch(setUserAuthenticated(false));
return;
} else {
dispatch(setUserAuthenticated(true));
}
(async () => {
try {
const response = await myFetch({
url: "/app",
method: "GET",
});
if (response) {
dispatch(setUserAuthenticated(true));
dispatch(setPrimaryColor(`#${response.Organization.PrimaryColor}`));
dispatch(setLogoUrl(response.Organization.LogoUrl));
dispatch(setBannerUrl(response.Organization.BannerUrl));
}
} catch (error) {}
})();
webSocketService.connect();
return () => {
webSocketService.disconnect();
};
}, []);
}, [dispatch]);
return (
<Layout style={{ minHeight: "100vh" }}>
@ -71,16 +82,16 @@ function App() {
},
}}
>
{uAuthenticated == null ? (
<MyCenteredSpin />
) : uAuthenticated ? (
<DashboardLayout />
) : (
<SignIn />
)}
<MessageProvider>
{uAuthenticated == null ? (
<MyCenteredSpin />
) : uAuthenticated ? (
<DashboardLayout />
) : (
<SignIn />
)}
</MessageProvider>
</ConfigProvider>
</Layout>
);
}
export default App;

View File

@ -13,10 +13,20 @@ import LessonPage from "../../../features/Lessons/LessonPage";
import LessonPageEditor from "../../../features/Lessons/LessonPageEditor";
import TeamCreateUser from "features/Team/CreateUser";
import AccountSettings from "features/AccountSettings";
import Board from "features/Board";
export default function AppRoutes() {
return (
<Routes>
<Route
path={Constants.ROUTE_PATHS.BOARD}
element={
<MySupsenseFallback>
<Board />
</MySupsenseFallback>
}
/>
<Route
path={Constants.ROUTE_PATHS.LESSIONS.ROOT}
element={
@ -71,7 +81,7 @@ export default function AppRoutes() {
}
/>
<Route
<Route
path={Constants.ROUTE_PATHS.ORGANIZATION_SETTINGS}
element={
<MySupsenseFallback>
@ -80,7 +90,7 @@ export default function AppRoutes() {
}
/>
<Route
<Route
path={Constants.ROUTE_PATHS.ACCOUNT_SETTINGS}
element={
<MySupsenseFallback>

View File

@ -1,59 +1,60 @@
import { Grid, Layout } from "antd";
import PageContent from "../PageContent";
import SideMenuDesktop from "../SideMenu/Desktop";
import SideMenuMobile from "../SideMenu/Mobile";
import { SideMenuContent, SideMenuEditorContent } from "../SideMenu";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setIsSideMenuCollapsed } from "../SideMenu/sideMenuSlice";
import { editorActive } from "../../../features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import MyDndContext from "./MyDndContext";
import { Grid, Layout } from 'antd';
import PageContent from '../PageContent';
import SideMenuDesktop from '../SideMenu/Desktop';
import SideMenuMobile from '../SideMenu/Mobile';
import { SideMenuContent, SideMenuEditorContent } from '../SideMenu';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setIsSideMenuCollapsed } from '../SideMenu/sideMenuSlice';
import { editorActive } from '../../../features/Lessons/LessonPageEditor/lessonPageEditorSlice';
import MyDndContext from './MyDndContext';
import AiChat from 'features/AiChat';
const { useBreakpoint } = Grid;
export function SideMenu() {
const screenBreakpoint = useBreakpoint();
const screenBreakpoint = useBreakpoint();
const dispatch = useDispatch();
const dispatch = useDispatch();
const isEditorActive = useSelector(editorActive);
const isEditorActive = useSelector(editorActive);
console.log("isEditorActive", isEditorActive);
console.log('isEditorActive', isEditorActive);
const Content = () => {
return isEditorActive ? <SideMenuEditorContent /> : <SideMenuContent />;
};
const Content = () => {
return isEditorActive ? <SideMenuEditorContent /> : <SideMenuContent />;
};
useEffect(() => {
dispatch(setIsSideMenuCollapsed(!screenBreakpoint.lg));
}, [screenBreakpoint]);
useEffect(() => {
dispatch(setIsSideMenuCollapsed(!screenBreakpoint.lg));
}, [screenBreakpoint]);
return (
<>
{screenBreakpoint.lg ? (
<SideMenuDesktop>
<Content />
</SideMenuDesktop>
) : (
<SideMenuMobile>
<Content />
</SideMenuMobile>
)}
</>
);
return (
<>
<AiChat />
{screenBreakpoint.lg ? (
<SideMenuDesktop>
<Content />
</SideMenuDesktop>
) : (
<SideMenuMobile>
<Content />
</SideMenuMobile>
)}
</>
);
}
export default function DashboardLayout() {
return (
<MyDndContext>
<Layout style={{ minHeight: "100vh" }}>
<Layout>
<SideMenu />
return (
<MyDndContext>
<Layout style={{ minHeight: '100vh' }}>
<Layout>
<SideMenu />
<PageContent />
</Layout>
</Layout>
</MyDndContext>
);
<PageContent />
</Layout>
</Layout>
</MyDndContext>
);
}

View File

@ -1,118 +1,168 @@
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';
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,
setUserAuthenticated,
} from "../../reducers/appSlice";
import styles from "./styles.module.css";
import { Constants } from "core/utils/utils";
import webSocketService from "core/services/websocketService";
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);
const navigate = useNavigate();
const navigate = useNavigate();
return (
<Flex
justify="space-between"
align="center"
style={{
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
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 }}
>
<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>
</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}
{isCollpased ? (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(false))}
>
{props.onView && (
<div className={styles.iconContainer} onClick={props.onView}>
<EyeOutlined className={styles.icon} />
</div>
)}
<MenuUnfoldOutlined className={styles.icon} />
</div>
) : (
<div
className={styles.iconContainer}
onClick={() => dispatch(setIsSideMenuCollapsed(true))}
>
<MenuFoldOutlined className={styles.icon} />
</div>
)}
</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>
)}
<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>
{props.backTo && (
<Link to={props.backTo}>
<Flex gap={4}>
<LeftOutlined />
<span>Back</span>
</Flex>
</Flex>
);
</Link>
)}
</Flex>
/* return (
<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>
)}
{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
overlayStyle={{ minWidth: 150 }}
trigger={["click"]}
menu={{
items: [
{
key: "1",
label: "Profile",
icon: <UserOutlined />,
onClick: () => navigate(Constants.ROUTE_PATHS.ACCOUNT_SETTINGS),
},
{
key: "2",
label: "Logout",
icon: <LogoutOutlined />,
danger: true,
onClick: () => {
webSocketService.disconnect();
window.localStorage.removeItem("session");
window.location.href = "/";
},
},
],
}}
>
<Avatar
size="default"
icon={<UserOutlined />}
style={{ cursor: "pointer" }}
/>
</Dropdown>
</Flex>
</Flex>
);
/* return (
<Header
style={{
position: "sticky",

View File

@ -1,5 +1,6 @@
import {
ControlOutlined,
FundProjectionScreenOutlined,
MessageOutlined,
QuestionCircleOutlined,
SettingOutlined,
@ -17,7 +18,11 @@ import {
sideMenuComponentFirstRender,
} from "./sideMenuSlice";
import { ItemType, MenuItemType } from "antd/es/menu/interface";
import { BreakpointLgWidth, Constants } from "core/utils/utils";
import {
BreakpointLgWidth,
BrowserTabSession,
Constants,
} from "core/utils/utils";
import Search from "antd/es/input/Search";
import { MyContainer } from "shared/components/MyContainer";
import {
@ -27,7 +32,7 @@ import {
} from "features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { Component, componentsGroups } from "features/Lessons/components";
import { darkMode, logoUrl } from "core/reducers/appSlice";
import { DndContext, DragOverlay, useDraggable } from "@dnd-kit/core";
import { DndContext, DragOverlay } from "@dnd-kit/core";
import { createPortal } from "react-dom";
import { LessonState } from "core/types/lesson";
import { useForm } from "antd/es/form/Form";
@ -35,6 +40,11 @@ import {
useAddLessonContentMutation,
useUpdateLessonStateMutation,
} from "core/services/lessons";
import webSocketService, {
addWebSocketReconnectListener,
removeWebSocketReconnectListener,
} from "core/services/websocketService";
import { WebSocketSendMessagesCmds } from "core/utils/webSocket";
export function SideMenuContent() {
const location = useLocation();
@ -60,6 +70,12 @@ export function SideMenuContent() {
};
if (overviewGroup.children) {
overviewGroup.children.push({
key: Constants.ROUTE_PATHS.BOARD,
label: "Board",
icon: <FundProjectionScreenOutlined />,
});
overviewGroup.children.push({
key: Constants.ROUTE_PATHS.LESSIONS.ROOT,
label: "Lessons",
@ -138,6 +154,20 @@ export function SideMenuContent() {
setSelectedKeys(pathname);
const subscribeTopicMessage = () => {
webSocketService.send({
Cmd: WebSocketSendMessagesCmds.SubscribeToTopic,
Body: {
topic: pathname,
browserTabSession: BrowserTabSession,
},
});
};
subscribeTopicMessage();
addWebSocketReconnectListener(subscribeTopicMessage);
let path = pathname.split("/");
if (path.length > 2) {
@ -152,6 +182,8 @@ export function SideMenuContent() {
} else if (document.body.clientWidth < BreakpointLgWidth) {
dispatch(setIsSideMenuCollapsed(true));
}
return () => removeWebSocketReconnectListener(subscribeTopicMessage);
}, [location.pathname]);
return (
@ -183,7 +215,9 @@ export function SideMenuContent() {
<div>
<Flex justify="center" style={{ paddingBottom: 24, width: "100%" }}>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}${appLogoUrl}`}
src={`${Constants.STATIC_CONTENT_ADDRESS}${
appLogoUrl || Constants.DEMO_LOGO_URL
}`}
alt="logo"
style={{ height: 80 }}
/>
@ -234,8 +268,6 @@ export function SideMenuEditorContent() {
const [updateLessonState] = useUpdateLessonStateMutation();
console.log("lesson state", lnState);
useEffect(() => {
form.setFieldsValue({
state: lnState,

View File

@ -0,0 +1,50 @@
import React, { createContext, useContext } from "react";
import { message } from "antd";
interface MessageContextType {
info: (content: string) => void;
success: (content: string) => void;
error: (content: string) => void;
warning: (content: string) => void;
}
const MessageContext = createContext<MessageContextType | null>(null);
export const MessageProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [messageApi, contextHolder] = message.useMessage();
const info = (content: string) => {
messageApi.info(content);
};
const success = (content: string) => {
messageApi.success(content);
};
const error = (content: string) => {
messageApi.error(content);
};
const warning = (content: string) => {
messageApi.warning(content);
};
return (
<MessageContext.Provider value={{ info, success, error, warning }}>
{contextHolder}
{children}
</MessageContext.Provider>
);
};
export const useMessage = () => {
const context = useContext(MessageContext);
if (!context) {
throw new Error("useMessage must be used within a MessageProvider");
}
return context;
};

View File

@ -1,9 +1,10 @@
import { FetchArgs, fetchBaseQuery } from "@reduxjs/toolkit/query";
import { Constants, handleLogout } from "core/utils/utils";
import { BrowserTabSession, Constants, handleLogout } from "core/utils/utils";
export function getApiHeader() {
return {
"X-Authorization": localStorage.getItem("session") || "",
"Browser-Tab-Session": BrowserTabSession,
};
}
@ -11,6 +12,7 @@ const baseQuery = fetchBaseQuery({
baseUrl: Constants.API_ADDRESS,
prepareHeaders: (headers) => {
headers.set("X-Authorization", localStorage.getItem("session") || "");
headers.set("Browser-Tab-Session", BrowserTabSession);
return headers;
},
});

View File

@ -5,7 +5,7 @@ export const appSlice = createSlice({
initialState: {
darkMode: false,
userAuthenticated: null,
primaryColor: "#1677FF",
primaryColor: "#111",
logoUrl: "",
bannerUrl: "",
},

View File

@ -3,7 +3,7 @@ import { baseQueryWithErrorHandling } from "core/helper/api";
import {
TeamMember,
OrganizationSettings,
IsSubdomainAvailableResponse,
Roles,
} from "core/types/organization";
export const organizationApi = createApi({
@ -16,6 +16,19 @@ export const organizationApi = createApi({
method: "GET",
}),
}),
createTeamMember: builder.mutation({
query: ({ firstName, lastName, email, roleId, password }) => ({
url: "organization/team/members",
method: "POST",
body: {
FirstName: firstName,
LastName: lastName,
Email: email,
RoleId: roleId,
Password: password,
},
}),
}),
getOrganizationSettings: builder.query<OrganizationSettings, undefined>({
query: () => ({
url: "organization/settings",
@ -41,13 +54,31 @@ export const organizationApi = createApi({
method: "PATCH",
}),
}),
getRoles: builder.query<Roles, undefined>({
query: () => ({
url: "organization/roles",
method: "GET",
}),
}),
/* createRole: builder.mutation({
query: (name) => ({
url: "organization/roles",
method: "POST",
body: {
Name: name,
},
}),
}), */
}),
});
export const {
useGetTeamQuery,
useCreateTeamMemberMutation,
useGetOrganizationSettingsQuery,
useUpdateOrganizationSettingsMutation,
useIsSubdomainAvailableMutation,
useUpdateSubdomainMutation,
useGetRolesQuery,
// useCreateRoleMutation,
} = organizationApi;

View File

@ -1,32 +1,68 @@
import { Dispatch } from "@reduxjs/toolkit";
import {
setBannerUrl,
setLogoUrl,
setPrimaryColor,
} from "core/reducers/appSlice";
import { BrowserTabSession, Constants } from "core/utils/utils";
import { WebSocketReceivedMessagesCmds } from "core/utils/webSocket";
import { addTeamMember } from "features/Team/teamSlice";
import { useDispatch } from "react-redux";
interface WebSocketMessage {
type: string;
payload: any;
Cmd: number;
Body: any;
}
class WebSocketService {
private url: string;
private socket: WebSocket | null = null;
private reconnectInterval: number = 10000; // 5 Sekunden
private handlers: Record<string, (payload: any) => void> = {};
private reconnectInterval: number = 1000; // in ms
private offlineQueue: WebSocketMessage[] = [];
private firstConnect: boolean = true;
private messageHandler:
| ((message: WebSocketMessage, dispatch: Dispatch) => void)
| null = null;
constructor(url: string) {
this.url = `${url}?auth=${localStorage.getItem(
"session"
)}&bts=${BrowserTabSession}`;
this.url = url;
}
private dispatch: Dispatch | null = null;
public connect(): void {
this.socket = new WebSocket(this.url);
this.socket = new WebSocket(
`${this.url}?auth=${localStorage.getItem(
"session"
)}&bts=${BrowserTabSession}`
);
this.socket.onopen = () => {
console.log("WebSocket connected");
console.log("WebSocket connected", this.firstConnect);
// Send all messages from the offline queue
this.offlineQueue.forEach((message) => this.send(message));
this.offlineQueue = [];
// Dispatch event to notify that the WebSocket connection is established
if (!this.firstConnect) {
document.dispatchEvent(webSocketConnectionEvent);
} else {
this.firstConnect = false;
}
};
this.socket.onmessage = (event: MessageEvent) => {
const data: WebSocketMessage = JSON.parse(event.data);
this.handleMessage(data);
if (this.messageHandler) {
this.messageHandler(data, this.dispatch!);
} else {
console.error("No handler defined for WebSocket messages");
}
};
this.socket.onclose = () => {
@ -39,21 +75,24 @@ class WebSocketService {
};
}
private handleMessage(data: WebSocketMessage): void {
const { type, payload } = data;
if (this.handlers[type]) {
this.handlers[type](payload);
}
public setHandler(
handler: (message: WebSocketMessage, dispatch: Dispatch) => void,
dispatch: Dispatch
): void {
this.messageHandler = handler;
this.dispatch = dispatch;
}
public onMessage(type: string, handler: (payload: any) => void): void {
this.handlers[type] = handler;
}
public send(type: string, payload: any): void {
const message: WebSocketMessage = { type, payload };
if (this.socket) {
this.socket.send(JSON.stringify(message));
public send(message: WebSocketMessage): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(
JSON.stringify({
Cmd: message.Cmd,
Body: message.Body,
})
);
} else {
this.offlineQueue.push(message);
}
}
@ -64,5 +103,52 @@ class WebSocketService {
}
}
const webSocketConnectionEventName = "WebSocketConnectionEvent";
const webSocketConnectionEvent = new CustomEvent(webSocketConnectionEventName, {
detail: "wsReconnect",
});
export function addWebSocketReconnectListener(callback: () => void): void {
document.addEventListener(webSocketConnectionEventName, callback);
}
export function removeWebSocketReconnectListener(callback: () => void): void {
document.removeEventListener(webSocketConnectionEventName, callback);
}
const webSocketService = new WebSocketService(Constants.WS_ADDRESS);
export default webSocketService;
export function WebSocketMessageHandler(
message: WebSocketMessage,
dispatch: Dispatch
) {
const { Cmd, Body } = message;
console.log("WebSocketMessageHandler", Cmd, Body);
switch (Cmd) {
case WebSocketReceivedMessagesCmds.SettingsUpdated:
dispatch(setPrimaryColor(Body.PrimaryColor));
break;
case WebSocketReceivedMessagesCmds.SettingsUpdatedLogo:
dispatch(setLogoUrl(Body));
break;
case WebSocketReceivedMessagesCmds.SettingsUpdatedBanner:
dispatch(setBannerUrl(Body));
break;
case WebSocketReceivedMessagesCmds.SettingsUpdatedSubdomain:
localStorage.removeItem("session");
window.location.href = `${
window.location.protocol
}//${Body}.${window.location.hostname.split(".").slice(1).join(".")}`;
break;
case WebSocketReceivedMessagesCmds.TeamAddedMember:
dispatch(addTeamMember(Body));
break;
default:
console.error("Unknown message type:", Cmd);
}
}

View File

@ -1,26 +1,33 @@
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';
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";
import { teamSlice } from "features/Team/teamSlice";
const makeStore = (/* preloadedState */) => {
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),
});
const store = configureStore({
reducer: {
app: appSlice.reducer,
sideMenu: sideMenuSlice.reducer,
lessonPageEditor: lessonPageEditorSlice.reducer,
teamSlice: teamSlice.reducer,
[lessonsApi.reducerPath]: lessonsApi.reducer,
[organizationApi.reducerPath]: organizationApi.reducer,
[teamSlice.reducerPath]: teamSlice.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

@ -3,7 +3,7 @@ export interface TeamMember {
FirstName: string;
LastName: string;
Email: string;
Role: string;
RoleId: string;
Status: string;
}
@ -15,6 +15,18 @@ export interface OrganizationSettings {
BannerUrl: string;
}
export interface IsSubdomainAvailableResponse {
Available: boolean;
interface RoleUser {
FirstName: string;
LastName: string;
ProfilePictureUrl: string;
}
export interface Role {
Id: string;
Permissions: number[];
Users: RoleUser[];
}
export interface Roles {
Roles: Role[];
}

View File

@ -8,6 +8,7 @@ export const Constants = {
WS_ADDRESS: `${wssProtocol}${window.location.hostname}/apiws`,
STATIC_CONTENT_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/static`,
ROUTE_PATHS: {
BOARD: "/",
LESSIONS: {
ROOT: "/lessons",
PAGE: "/lessons/:lessonId",
@ -16,7 +17,7 @@ export const Constants = {
ORGANIZATION_TEAM: "/team",
ORGANIZATION_TEAM_CREATE_USER: "/team/create-user",
ORGANIZATION_ROLES: "/roles",
ORGANIZATION_SETTINGS: "/organization",
ORGANIZATION_SETTINGS: "/settings",
ACCOUNT_SETTINGS: "/account",
WHATS_NEW: "/whats-new",
SUGGEST_FEATURE: "/suggest-feature",
@ -36,6 +37,20 @@ export const Constants = {
GLOBALS: {
MIN_SUBDOMAIN_LENGTH: 3,
MAX_SUBDOMAIN_LENGTH: 32,
MIN_FIRST_NAME_LENGTH: 2,
MAX_FIRST_NAME_LENGTH: 32,
MIN_LAST_NAME_LENGTH: 2,
MAX_LAST_NAME_LENGTH: 32,
MIN_PASSWORD_LENGTH: 8,
MAX_PASSWORD_LENGTH: 32,
},
DEMO_LOGO_URL: "/demo/logo.png",
DEMO_BANNER_URL: "/demo/organization_banner.jpeg",
PERMISSIONS: {
TEAM_INVITE_NEW_MEMBER: 1,
TEAM_REMOVE_MEMBER: 2,
ROLES_CREATE: 3,
ROLES_EDIT: 4,
},
};

View File

@ -0,0 +1,13 @@
enum WebSocketSendMessagesCmds {
SubscribeToTopic = 1,
}
enum WebSocketReceivedMessagesCmds {
SettingsUpdated = 1,
SettingsUpdatedLogo = 2,
SettingsUpdatedBanner = 3,
SettingsUpdatedSubdomain = 4,
TeamAddedMember = 5,
}
export { WebSocketSendMessagesCmds, WebSocketReceivedMessagesCmds };

View File

@ -0,0 +1,38 @@
import React from 'react';
import { FloatButton } from 'antd';
import { CommentOutlined } from '@ant-design/icons';
import { DeepChat } from 'deep-chat-react';
function AiChat() {
const [visible, setVisible] = React.useState(false);
return (
<>
{visible ? (
<div style={{ position: 'fixed', bottom: 100, right: 10, zIndex: 10000, maxWidth: '95vw', width: 500, height: 1000, maxHeight: 'calc(100vh - 165px)' }}>
<DeepChat
style={{ width: '100%', height: '100%', borderRadius: 10, boxShadow: '0 0 10px rgba(0,0,0,0.1)', bottom: 0, position: 'absolute' }}
history={[
{ text: 'Show me a modern city', role: 'user' },
{ files: [{ src: 'https://test.ex.umbach.dev/api/statico/809fe37e-8c41-4a44-98d1-d9247affd531/67c763b6-ea67-4b49-9621-2f78b85eb180.png', type: 'image' }], role: 'ai' },
{ text: 'Whats on your mind?', role: 'user' },
{ text: 'Peace and tranquility', role: 'ai' },
]}
></DeepChat>
</div>
) : null}
<FloatButton
icon={<CommentOutlined />}
type="primary"
onClick={() => console.log('onClick')}
style={{ zIndex: 10000 }}
onClickCapture={() => {
setVisible(!visible);
}}
/>
</>
);
}
export default AiChat;

View File

@ -0,0 +1,10 @@
import HeaderBar from "core/components/Header";
import MyBanner from "shared/components/MyBanner";
export default function Board() {
return (
<>
<MyBanner title="Board" headerBar={<HeaderBar />} />
</>
);
}

View File

@ -1,7 +1,20 @@
import { Descriptions, Typography } from 'antd';
import MyMiddleCard from 'shared/components/MyMiddleCard';
import MyBanner from 'shared/components/MyBanner';
export default function ContactSupport() {
return (
<>
<h1>ContactSupport</h1>
</>
);
return (
<>
<MyBanner title="Contact Support" />
<MyMiddleCard title="Support">
<Typography.Paragraph>If you have any questions or need help, please contact us at the following e-mail address:</Typography.Paragraph>
<Descriptions>
<Descriptions.Item label="E-Mail">
<a href="mailto:support@jannex.de">support@jannex.de</a>
</Descriptions.Item>
</Descriptions>
</MyMiddleCard>
</>
);
}

View File

@ -4,13 +4,14 @@ 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 { useRef } 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';
import { darkMode } from 'core/reducers/appSlice';
const extractVideoId = (url: string) => {
// regex to extract video id from youtube url
@ -22,6 +23,8 @@ const extractVideoId = (url: string) => {
export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edititable'; lessonContent: LessonContent; onEdit?: (newData: string) => void }) {
const lessonId = useSelector(currentLessonId);
const isDarkMode = useSelector(darkMode);
const [reqUpdateLessonContent] = useUpdateLessonContentMutation();
const debounceRef = useRef<null | NodeJS.Timeout>(null);
@ -63,7 +66,19 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
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 (
<ul
style={{
fontSize: 16,
wordBreak: 'break-all',
padding: 0,
margin: 0,
listStyleType: 'none',
}}
>
{formattedText}
</ul>
);
}
return (
@ -106,7 +121,9 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
position: 'relative',
height: 120,
width: '100%',
backgroundColor: '#EBEBEB',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
marginTop: 4,
borderRadius: 4,
marginRight: 8,
}}
>
@ -151,7 +168,8 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
position: 'relative',
height: 120,
width: '100%',
backgroundColor: '#EBEBEB',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
borderRadius: 4,
margin: '12px 12px 12px 0',
}}
>
@ -179,7 +197,15 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
<img src={`${Constants.STATIC_CONTENT_ADDRESS}/${lessonContent.Data}`} alt="img" style={{ width: '100%' }} />
{mode === 'edititable' && (
<Flex style={{ width: '100%', backgroundColor: '#EBEBEB' }} justify="center">
<Flex
style={{
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
marginTop: 4,
borderRadius: 4,
}}
justify="center"
>
<div>
<span>Choose another image from</span>
<GalleryUpload />
@ -247,7 +273,9 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
position: 'relative',
height: 120,
width: '100%',
backgroundColor: '#EBEBEB',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
marginTop: 4,
borderRadius: 4,
marginRight: 8,
}}
>
@ -289,7 +317,8 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
position: 'relative',
height: 120,
width: '100%',
backgroundColor: '#EBEBEB',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
borderRadius: 4,
margin: '12px 12px 12px 0',
}}
>
@ -320,7 +349,17 @@ export function Converter({ mode, lessonContent, onEdit }: { mode: 'view' | 'edi
</MediaPlayer>
{mode === 'edititable' && (
<Flex style={{ width: '100%', backgroundColor: '#EBEBEB', height: 48 }} justify="center" align="center">
<Flex
style={{
width: '100%',
backgroundColor: isDarkMode ? '#222' : '#EBEBEB',
margin: '4px 0',
borderRadius: 4,
height: 48,
}}
justify="center"
align="center"
>
<div>
<span>Choose another video from</span>
<VideoUpload />

View File

@ -1,11 +1,7 @@
import { Button, Divider, Flex, Segmented } from "antd";
import { Button, Divider, Flex } from "antd";
import MyBanner from "shared/components/MyBanner";
import { MyContainer } from "shared/components/MyContainer";
import {
AppstoreOutlined,
BarsOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { PlusOutlined } from "@ant-design/icons";
import Search, { SearchProps } from "antd/es/input/Search";
import { useNavigate } from "react-router-dom";
import HeaderBar from "core/components/Header";
@ -61,8 +57,8 @@ const LessonList: React.FC = () => {
if (!data || data.length === 0) return <MyEmpty />;
const publishedItems = data.filter(item => item.State === 1);
const unpublishedItems = data.filter(item => item.State === 2);
const publishedItems = data.filter((item) => item.State === 1);
const unpublishedItems = data.filter((item) => item.State === 2);
return (
<>
@ -85,7 +81,9 @@ const LessonList: React.FC = () => {
{unpublishedItems.length > 0 && (
<>
<Divider orientation="left" style={{marginBottom: 0}}>Unpublished</Divider>
<Divider orientation="left" style={{ marginBottom: 0 }}>
Unpublished
</Divider>
{unpublishedItems.map((item, index) => (
<LessonPreviewCard
@ -115,13 +113,6 @@ export default function Lessons() {
<MyContainer>
<Flex justify="right" gap={16} style={{ paddingBottom: 16 }}>
<Segmented
options={[
{ value: "List", icon: <BarsOutlined /> },
{ value: "Kanban", icon: <AppstoreOutlined /> },
]}
/>
<CreateLessonButton />
<Search

View File

@ -1,17 +1,339 @@
import { Checkbox, Collapse, Form } from "antd";
import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer";
import MyErrorResult from "shared/components/MyResult";
import MyEmpty from "shared/components/MyEmpty";
import MyCenteredSpin from "shared/components/MyCenteredSpin";
import { Role } from "core/types/organization";
import { useGetRolesQuery } from "core/services/organization";
export default function Roles() {
const { data, error, isLoading } = useGetRolesQuery(undefined, {
refetchOnMountOrArgChange: true,
});
return (
<>
<MyBanner title="Roles" subtitle="MANAGE" headerBar={
<HeaderBar />
} />
<MyBanner title="Roles" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyContainer>
<h1>Roles</h1>
<MyContainer
style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{error ? (
<MyErrorResult />
) : isLoading ? (
<MyCenteredSpin height="240px" />
) : data === undefined ? (
<MyEmpty />
) : (
<>
{data.Roles.map((role, index) => (
<RoleComponent key={index} role={role} />
))}
</>
)}
</MyContainer>
</>
);
}
interface Permission {
id: number;
category: string;
title: string;
description: string;
}
// test data
const tmpI18nObj = [
{
id: 1,
category: "Team",
title: "Invite new team member",
description:
"Permission to invite a member. An email will be sent to the new member.",
},
{
id: 2,
category: "Team",
title: "Remove team member",
description: "Permission to remove a member.",
},
{
id: 3,
category: "Roles",
title: "Create new role",
description:
"Permission to invite a member. An email will be sent to the new member.",
},
{
id: 4,
category: "Roles",
title: "Delete role",
description:
"Permission to invite a member. An email will be sent to the new member.",
},
];
export const tmpRoleNames = {
"d0f0fa0d-3f3b-438b-a76f-7febeb8aab57": "Admin",
"b7359e12-359e-423b-b39c-f0d4069adebc": "Moderator",
"a1f084ad-d501-4015-b326-4c5c46fd1c5e": "User",
} as any;
function RoleComponent({ role }: { role: Role }) {
const teamPermissions = tmpI18nObj.filter(
(permission) => permission.category === "Team"
);
const rolePermissions = tmpI18nObj.filter(
(permission) => permission.category === "Roles"
);
const MyCheckbox = ({ permission }: { permission: Permission }) => {
return (
<Form.Item extra={permission.description}>
<Checkbox disabled checked={role.Permissions.includes(permission.id)}>
{permission.title}
</Checkbox>
</Form.Item>
);
};
return (
<Form>
<Collapse
defaultActiveKey={["1"]}
items={[
{
key: "1",
label: tmpRoleNames[role.Id],
children: (
<Collapse
defaultActiveKey={["1"]}
ghost
items={[
{
key: "1",
label: "Team",
children: (
<>
{teamPermissions.map((permission, index) => (
<MyCheckbox key={index} permission={permission} />
))}
</>
),
},
{
key: "2",
label: "Roles",
children: (
<>
{rolePermissions.map((permission, index) => (
<MyCheckbox key={index} permission={permission} />
))}
</>
),
},
]}
/>
),
},
]}
/>
</Form>
);
}
/*
function RoleComponent({ role }: { role: Role }) {
const [expandIconPosition, setExpandIconPosition] = useState<ExpandIconPosition>('start');
const [editMode, setEditMode] = useState(false);
const [form] = useForm();
const onPositionChange = (newExpandIconPosition: ExpandIconPosition) => {
setExpandIconPosition(newExpandIconPosition);
};
const onChange = (key: string | string[]) => {};
const teamPermissions = [
{
id: 1,
label: 'Invite new team member',
description: 'Permission to invite a member. An email will be sent to the new member.',
},
{
id: 2,
label: 'Remove team member',
description: 'Permission to invite a member. An email will be sent to the new member.',
},
];
const rolePermissions = [
{
id: 3,
label: 'Invite new team member',
description: 'Permission to invite a member. An email will be sent to the new member.',
},
{
id: 4,
label: 'Invite new team member',
description: 'Permission to invite a member. An email will be sent to the new member.',
},
];
const MyCheckbox = ({ name, label, description }: { name: number; label: string; description: string }) => {
return (
<FormItem name={name} valuePropName="checked" extra={description}>
<Checkbox disabled={role.Master || (!role.Master && !editMode)}>{label}</Checkbox>
</FormItem>
);
};
const handleSave = () => {
console.log(form.getFieldsValue());
};
/*
useEffect(() => {
// set all checkboxes to true if role is master
if (role.Master) {
const obj = {} as any;
const objPermissionValues = Object.values(Constants.PERMISSIONS);
for (let i = 1; i <= Object.keys(Constants.PERMISSIONS).length; i++) {
obj[objPermissionValues[i - 1]] = true;
}
form.setFieldsValue(obj);
}
}, []);
*/
/*
return (
<Form form={form}>
<Collapse
defaultActiveKey={['1']}
collapsible={editMode ? 'icon' : 'header'}
onChange={onChange}
expandIconPosition={expandIconPosition}
items={[
{
key: '1',
label: editMode ? <Input placeholder="Role name" value={'Admin'} size="small" /> : role.Name,
children: (
<>
{role.Master && (
<Typography.Text type="secondary" italic>
Permissions for this role cannot be changed as it the master role.
</Typography.Text>
)}
<Collapse
defaultActiveKey={['1']}
ghost
onChange={onChange}
expandIconPosition={expandIconPosition}
items={[
{
key: '1',
label: 'Team',
children: (
<>
{teamPermissions.map((permission) => (
<MyCheckbox key={permission.id} name={permission.id} label={permission.label} description={permission.description} />
))}
</>
),
},
{
key: '2',
label: 'Roles',
children: (
<>
{rolePermissions.map((permission) => (
<MyCheckbox key={permission.id} name={permission.id} label={permission.label} description={permission.description} />
))}
</>
),
},
]}
/>
</>
),
extra: (
<Space style={{ paddingLeft: 12 }}>
{!editMode ? (
<>
{role.Users.length > 0 && (
<Avatar.Group
size="small"
max={{
count: 2,
}}
>
{role.Users.map((user) => (
<Tooltip title={`${user.FirstName} ${user.LastName}`} placement="top">
<Avatar style={{ backgroundColor: '#f56a00' }}>{user.FirstName[0]}</Avatar>
</Tooltip>
))}
</Avatar.Group>
)}
</>
) : (
<>
<DeleteOutlined />
<SaveOutlined
onClick={(event) => {
event.stopPropagation();
setEditMode(false);
handleSave();
}}
/>
<CloseOutlined
onClick={(event) => {
event.stopPropagation();
setEditMode(false);
}}
/>
</>
)}
</Space>
),
},
]}
/>
</Form>
);
}
*/
/*
const CreateRoleButton: React.FC = () => {
const [createRole, { isLoading }] = useCreateRoleMutation();
const handleCreateRole = async () => {
try {
createRole('New Role');
} catch (err) {
console.error(err);
}
};
return (
<Button icon={<PlusOutlined />} onClick={handleCreateRole} loading={isLoading}>
Create role
</Button>
);
};
*/

View File

@ -25,6 +25,7 @@ import {
} from "core/reducers/appSlice";
import MyMiddleCard from "shared/components/MyMiddleCard";
import { OrganizationSettings } from "core/types/organization";
import { useMessage } from "core/context/MessageContext";
type GeneralFieldType = {
primaryColor: string | AggregationColor;
@ -47,7 +48,7 @@ export default function Settings() {
<GeneralCard data={data} isLoading={isLoading} />
<MediaCard data={data} isLoading={isLoading} />
<MediaCard isLoading={isLoading} />
<SubdomainCard data={data} isLoading={isLoading} />
</>
@ -59,6 +60,7 @@ function GeneralCard({
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const [form] = useForm<GeneralFieldType>();
const { success } = useMessage();
const dispatch = useDispatch();
const debounceRef = useRef<null | NodeJS.Timeout>(null);
@ -67,19 +69,21 @@ function GeneralCard({
const [updateOrganizationSettings, { isLoading: isUpdateSettingsLoading }] =
useUpdateOrganizationSettingsMutation();
const handleSave = (values: GeneralFieldType) => {
const handleSave = async (values: GeneralFieldType) => {
const hexColor =
typeof values.primaryColor === "string"
? values.primaryColor
: values.primaryColor.toHexString().split("#")[1];
try {
updateOrganizationSettings({
await updateOrganizationSettings({
primaryColor: hexColor,
companyName: values.companyName,
});
}).unwrap();
currentPrimaryColor.current = hexColor;
success("Settings updated successfully!");
} catch (error) {
console.error(error);
}
@ -148,7 +152,7 @@ function GeneralCard({
</Form.Item>
<Form.Item<GeneralFieldType> name="companyName" label="Company name">
<Input defaultValue="Jannex" />
<Input />
</Form.Item>
</Flex>
</MyMiddleCard>
@ -157,9 +161,9 @@ function GeneralCard({
}
function MediaCard({
data,
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const { success } = useMessage();
const dispatch = useDispatch();
const appLogoUrl = useSelector(logoUrl);
@ -174,6 +178,8 @@ function MediaCard({
onChange={(info) => {
if (info.file.status === "done" && info.file.response.Data) {
dispatch(setLogoUrl(info.file.response.Data));
success("Logo updated successfully!");
}
}}
imgCropProps={{
@ -182,7 +188,9 @@ function MediaCard({
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}${appLogoUrl}`}
src={`${Constants.STATIC_CONTENT_ADDRESS}${
appLogoUrl || Constants.DEMO_LOGO_URL
}`}
alt="Company Logo"
style={{
width: 128,
@ -199,8 +207,13 @@ function MediaCard({
<MyUpload
action="/organization/file/banner"
onChange={(info) => {
console.log("Banner updated1!", info.file.status);
if (info.file.status === "done" && info.file.response.Data) {
dispatch(setBannerUrl(info.file.response.Data));
console.log("Banner updated!");
success("Banner updated successfully!");
}
}}
imgCropProps={{
@ -209,7 +222,9 @@ function MediaCard({
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}${appBannerUrl}`}
src={`${Constants.STATIC_CONTENT_ADDRESS}${
appBannerUrl || Constants.DEMO_BANNER_URL
}`}
alt="Banner"
style={{
width: "100%",
@ -234,6 +249,7 @@ function SubdomainCard({
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const [form] = useForm();
const { success, info } = useMessage();
const [isModalOpen, setIsModalOpen] = useState(false);
const [reqIsSubdomainAvailable] = useIsSubdomainAvailableMutation();
@ -287,13 +303,16 @@ function SubdomainCard({
centered
onCancel={() => setIsModalOpen(false)}
okText="Change"
onOk={() => {
onOk={async () => {
try {
reqUpdateSubdomain(form.getFieldValue("subdomain"));
await reqUpdateSubdomain(form.getFieldValue("subdomain"));
success("Subdomain updated successfully!");
info("You will be redirected to the new subdomain!");
/*
window.location.href = `https://${form.getFieldValue(
"subdomain"
)}.${window.location.hostname.split(".").slice(1).join(".")}`;
)}.${window.location.hostname.split(".").slice(1).join(".")}`; */
} catch (error) {
console.error(error);
}

View File

@ -1,7 +1,10 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Flex, Form, Input } from "antd";
import { Button, Flex, Form, Input, Select } from "antd";
import HeaderBar from "core/components/Header";
import { Constants } from "core/utils/utils";
import { useMessage } from "core/context/MessageContext";
import { useCreateTeamMemberMutation } from "core/services/organization";
import { Constants, EncodeStringToBase64 } from "core/utils/utils";
import { useNavigate } from "react-router-dom";
import MyMiddleCard from "shared/components/MyMiddleCard";
type FieldType = {
@ -14,6 +17,11 @@ type FieldType = {
};
export default function TeamCreateUser() {
const navigate = useNavigate();
const { success } = useMessage();
const [reqCreateTeamMember, { isLoading }] = useCreateTeamMemberMutation();
return (
<>
<HeaderBar
@ -22,21 +30,72 @@ export default function TeamCreateUser() {
/>
<MyMiddleCard title="Create User">
<Form layout="vertical" requiredMark={false}>
<Form
layout="vertical"
requiredMark={false}
initialValues={{
roleId: "a1f084ad-d501-4015-b326-4c5c46fd1c5e",
}}
onFinish={async (values) => {
console.log(values);
try {
await reqCreateTeamMember({
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
roleId: values.roleId,
password: EncodeStringToBase64(values.password),
}).unwrap();
success("User created successfully!");
navigate(Constants.ROUTE_PATHS.ORGANIZATION_TEAM);
} catch (error) {
console.error(error);
}
}}
>
<Form.Item<FieldType>
label="First Name"
name="firstName"
rules={[{ required: true, message: "Please input first name!" }]}
rules={[
{ required: true, message: "Please input first name!" },
{
min: Constants.GLOBALS.MIN_FIRST_NAME_LENGTH,
message: `First name must be at least ${Constants.GLOBALS.MIN_FIRST_NAME_LENGTH} characters long!`,
},
{
max: Constants.GLOBALS.MAX_FIRST_NAME_LENGTH,
message: `First name must be at most ${Constants.GLOBALS.MAX_FIRST_NAME_LENGTH} characters long!`,
},
]}
>
<Input placeholder="First Name" />
<Input
placeholder="First Name"
maxLength={Constants.GLOBALS.MAX_FIRST_NAME_LENGTH}
/>
</Form.Item>
<Form.Item<FieldType>
label="Last Name"
name="lastName"
rules={[{ required: true, message: "Please input last name!" }]}
rules={[
{ required: true, message: "Please input last name!" },
{
min: Constants.GLOBALS.MIN_LAST_NAME_LENGTH,
message: `Last name must be at least ${Constants.GLOBALS.MIN_LAST_NAME_LENGTH} characters long!`,
},
{
max: Constants.GLOBALS.MAX_LAST_NAME_LENGTH,
message: `Last name must be at most ${Constants.GLOBALS.MAX_LAST_NAME_LENGTH} characters long!`,
},
]}
>
<Input placeholder="Last Name" />
<Input
placeholder="Last Name"
maxLength={Constants.GLOBALS.MAX_LAST_NAME_LENGTH}
/>
</Form.Item>
<Form.Item<FieldType>
@ -49,30 +108,48 @@ export default function TeamCreateUser() {
<Input placeholder="Email" />
</Form.Item>
<Form.Item<FieldType>
name="changePasswordOnFirstLogin"
valuePropName="checked"
>
<Checkbox>Change Password on First Login</Checkbox>
</Form.Item>
<Form.Item<FieldType>
label="Password"
name="password"
rules={[{ required: true, message: "Please input password!" }]}
rules={[
{ required: true, message: "Please input password!" },
{
min: Constants.GLOBALS.MIN_PASSWORD_LENGTH,
message: `Password must be at least ${Constants.GLOBALS.MIN_PASSWORD_LENGTH} characters long!`,
},
{
max: Constants.GLOBALS.MAX_PASSWORD_LENGTH,
message: `Password must be at most ${Constants.GLOBALS.MAX_PASSWORD_LENGTH} characters long!`,
},
]}
>
<Input.Password placeholder="Password" />
<Input.Password
placeholder="Password"
maxLength={Constants.GLOBALS.MAX_PASSWORD_LENGTH}
/>
</Form.Item>
<Form.Item<FieldType>
name="sendInvitationEmail"
valuePropName="checked"
>
<Checkbox>Send an invitation email to the user</Checkbox>
<Form.Item name="roleId" label="Role">
<Select>
<Select.Option value="d0f0fa0d-3f3b-438b-a76f-7febeb8aab57">
Admin
</Select.Option>
<Select.Option value="b7359e12-359e-423b-b39c-f0d4069adebc">
Moderator
</Select.Option>
<Select.Option value="a1f084ad-d501-4015-b326-4c5c46fd1c5e">
User
</Select.Option>
</Select>
</Form.Item>
<Flex justify="end">
<Button type="primary" icon={<PlusOutlined />}>
<Button
type="primary"
icon={<PlusOutlined />}
htmlType="submit"
loading={isLoading}
>
Create User
</Button>
</Flex>
@ -81,3 +158,12 @@ export default function TeamCreateUser() {
</>
);
}
/*
<Form.Item<FieldType>
name="sendInvitationEmail"
valuePropName="checked"
>
<Checkbox>Send an invitation email to the user</Checkbox>
</Form.Item>
*/

View File

@ -1,100 +1,120 @@
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';
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";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setTeamMembers, teamMembers } from "./teamSlice";
import { tmpRoleNames } from "features/Roles";
const TeamList: React.FC = () => {
const { data, error, isLoading } = useGetTeamQuery(undefined, {
refetchOnMountOrArgChange: true,
const dispatch = useDispatch();
const dataTeamMembers = useSelector(teamMembers);
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 (!dataTeamMembers) return items;
dataTeamMembers.forEach((item) => {
items.push({
key: item.Id,
firstName: item.FirstName,
lastName: item.LastName,
email: item.Email,
role: tmpRoleNames[item.RoleId],
status: item.Status,
});
});
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;
};
return items;
};
useEffect(() => {
if (!data) return;
const getTableItems = () => {
let items = [] as any[];
dispatch(setTeamMembers(data));
}, [data]);
if (!data) return items;
if (error) return <MyErrorResult />;
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} />;
return (
<MyTable
loading={isLoading}
columns={getTableContent()}
dataSource={getTableItems()}
/>
);
};
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

@ -0,0 +1,29 @@
import { createSlice } from "@reduxjs/toolkit";
import { TeamMember } from "core/types/organization";
interface AddTeamMemberAction {
type: string;
payload: TeamMember;
}
export const teamSlice = createSlice({
name: "team",
initialState: {
teamMembers: [] as TeamMember[],
},
reducers: {
addTeamMember: (state, action: AddTeamMemberAction) => {
state.teamMembers.push(action.payload);
},
setTeamMembers: (state, action) => {
state.teamMembers = action.payload;
},
},
selectors: {
teamMembers: (state) => state.teamMembers,
},
});
export const { addTeamMember, setTeamMembers } = teamSlice.actions;
export const { teamMembers } = teamSlice.selectors;

View File

@ -1,7 +1,7 @@
export default function WhatsNew() {
return (
<>
<h1>WhatsNew</h1>
</>
);
return (
<>
<h1>WhatsNew</h1>
</>
);
}

View File

@ -22,7 +22,7 @@ export default function MyBanner({
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}${
appBannerUrl || "/demo/organization_banner.jpeg"
appBannerUrl || Constants.DEMO_BANNER_URL
}`}
alt="banner"
style={{

View File

@ -1,10 +1,12 @@
import { MyCenteredContainer } from "../MyContainer";
import { MyCenteredContainer, MyCenteredContainerProps } from "../MyContainer";
import MySpin from "../MySpin";
export default function MyCenteredSpin({ fullHeight = false }) {
const MyCenteredSpin: React.FC<MyCenteredContainerProps> = (props) => {
return (
<MyCenteredContainer fullHeight>
<MyCenteredContainer {...props}>
<MySpin />
</MyCenteredContainer>
);
}
};
export default MyCenteredSpin;

View File

@ -10,15 +10,15 @@ export function MyContainer({
return <Content style={{ padding: 12, ...style }}>{children}</Content>;
}
export interface MyCenteredContainerProps {
children?: React.ReactNode;
height?: string;
}
export function MyCenteredContainer({
children,
fullHeight = false,
height = "100vh",
}: {
children: React.ReactNode;
fullHeight?: boolean;
height?: string;
}) {
height,
}: MyCenteredContainerProps) {
return (
<div
style={{
@ -27,7 +27,7 @@ export function MyCenteredContainer({
justifyContent: "center",
alignContent: "center",
alignItems: "center",
height: fullHeight ? height : "85.3vh",
height: height || "100vh",
}}
>
{children}

View File

@ -1,6 +1,6 @@
import { LoadingOutlined } from "@ant-design/icons";
import { Spin } from "antd";
import { LoadingOutlined } from '@ant-design/icons';
import { Spin } from 'antd';
export default function MySpin() {
return <Spin size="large" indicator={<LoadingOutlined spin />} />;
return <Spin size="large" indicator={<LoadingOutlined spin />} />;
}

View File

@ -13,7 +13,7 @@ export function MySupsenseFallback({
<Suspense
fallback={
spinnerCentered ? (
<MyCenteredSpin fullHeight />
<MyCenteredSpin height="100vh" />
) : (
<div
style={{

View File

@ -2,8 +2,6 @@ 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, useState } from "react";
import MySpin from "../MySpin";
export default function MyUpload({
children,
@ -26,8 +24,6 @@ export default function MyUpload({
onChange?: (info: any) => void;
fileType?: "image" | "video";
}) {
const [uploading, setUploading] = useState(false);
const beforeUpload = (file: File) => {
if (!accept.includes(file.type)) {
console.error("File typ not allowed!");
@ -54,17 +50,7 @@ export default function MyUpload({
headers={headers}
action={`${Constants.API_ADDRESS}${action}`}
onChange={(info) => {
if (onChange) {
console.log("call");
onChange(info);
}
if (info.file.status === "uploading") {
setUploading(true);
} else if (info.file.status === "done") {
console.log("done2");
setUploading(false);
}
if (onChange) onChange(info);
}}
beforeUpload={beforeUpload}
>

View File

@ -1,21 +1,21 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
"compilerOptions": {
"baseUrl": "src",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"jsx": "react-jsx"
},
"include": ["src"]
}