diff --git a/src/App.tsx b/src/App.tsx index 2b801dc..26c9776 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import DashboardLayout from "./core/components/DashboardLayout"; import { darkMode, primaryColor, + setBannerUrl, + setLogoUrl, setPrimaryColor, setUserAuthenticated, userAuthenticated, @@ -46,6 +48,8 @@ function App() { if (response) { dispatch(setUserAuthenticated(true)); dispatch(setPrimaryColor(`#${response.Organization.PrimaryColor}`)); + dispatch(setLogoUrl(response.Organization.LogoUrl)); + dispatch(setBannerUrl(response.Organization.BannerUrl)); } } catch (error) {} })(); diff --git a/src/core/components/SideMenu/index.tsx b/src/core/components/SideMenu/index.tsx index ee96031..b14d83f 100644 --- a/src/core/components/SideMenu/index.tsx +++ b/src/core/components/SideMenu/index.tsx @@ -26,7 +26,7 @@ import { lessonState, } from "features/Lessons/LessonPageEditor/lessonPageEditorSlice"; import { Component, componentsGroups } from "features/Lessons/components"; -import { darkMode } from "core/reducers/appSlice"; +import { darkMode, logoUrl } from "core/reducers/appSlice"; import { DndContext, DragOverlay, useDraggable } from "@dnd-kit/core"; import { createPortal } from "react-dom"; import { LessonState } from "core/types/lesson"; @@ -43,6 +43,7 @@ export function SideMenuContent() { const dispatch = useDispatch(); const componentFirstRender = useSelector(sideMenuComponentFirstRender); + const appLogoUrl = useSelector(logoUrl); const navigate = useNavigate(); @@ -179,7 +180,15 @@ export function SideMenuContent() { }} > -
+
+ + logo + + navigate(item.key)} diff --git a/src/core/reducers/appSlice.tsx b/src/core/reducers/appSlice.tsx index fb87d49..f5e5c33 100644 --- a/src/core/reducers/appSlice.tsx +++ b/src/core/reducers/appSlice.tsx @@ -6,6 +6,8 @@ export const appSlice = createSlice({ darkMode: false, userAuthenticated: null, primaryColor: "#1677FF", + logoUrl: "", + bannerUrl: "", }, reducers: { setDarkMode: (state, action) => { @@ -17,15 +19,29 @@ export const appSlice = createSlice({ setPrimaryColor: (state, action) => { state.primaryColor = action.payload; }, + setLogoUrl: (state, action) => { + state.logoUrl = action.payload; + }, + setBannerUrl: (state, action) => { + state.bannerUrl = action.payload; + }, }, selectors: { darkMode: (state) => state.darkMode, userAuthenticated: (state) => state.userAuthenticated, primaryColor: (state) => state.primaryColor, + logoUrl: (state) => state.logoUrl, + bannerUrl: (state) => state.bannerUrl, }, }); -export const { setDarkMode, setUserAuthenticated, setPrimaryColor } = - appSlice.actions; +export const { + setDarkMode, + setUserAuthenticated, + setPrimaryColor, + setLogoUrl, + setBannerUrl, +} = appSlice.actions; -export const { darkMode, userAuthenticated, primaryColor } = appSlice.selectors; +export const { darkMode, userAuthenticated, primaryColor, logoUrl, bannerUrl } = + appSlice.selectors; diff --git a/src/core/services/organization.ts b/src/core/services/organization.ts index 83bd8c5..4cc1ff8 100644 --- a/src/core/services/organization.ts +++ b/src/core/services/organization.ts @@ -1,6 +1,10 @@ import { createApi } from "@reduxjs/toolkit/query/react"; import { baseQueryWithErrorHandling } from "core/helper/api"; -import { TeamMember, OrganizationSettings } from "core/types/organization"; +import { + TeamMember, + OrganizationSettings, + IsSubdomainAvailableResponse, +} from "core/types/organization"; export const organizationApi = createApi({ reducerPath: "organizationApi", @@ -25,6 +29,18 @@ export const organizationApi = createApi({ body: { PrimaryColor: primaryColor, CompanyName: companyName }, }), }), + isSubdomainAvailable: builder.mutation({ + query: (subdomain) => ({ + url: `organization/subdomain/${subdomain}`, + method: "GET", + }), + }), + updateSubdomain: builder.mutation({ + query: (subdomain) => ({ + url: `organization/subdomain/${subdomain}`, + method: "PATCH", + }), + }), }), }); @@ -32,4 +48,6 @@ export const { useGetTeamQuery, useGetOrganizationSettingsQuery, useUpdateOrganizationSettingsMutation, + useIsSubdomainAvailableMutation, + useUpdateSubdomainMutation, } = organizationApi; diff --git a/src/core/types/organization.ts b/src/core/types/organization.ts index 590e545..61da409 100644 --- a/src/core/types/organization.ts +++ b/src/core/types/organization.ts @@ -1,16 +1,20 @@ export interface TeamMember { - Id: string; - FirstName: string; - LastName: string; - Email: string; - Role: string; - Status: string; + 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; + Subdomain: string; + CompanyName: string; + PrimaryColor: string; + LogoUrl: string; + BannerUrl: string; +} + +export interface IsSubdomainAvailableResponse { + Available: boolean; } diff --git a/src/core/utils/utils.tsx b/src/core/utils/utils.tsx index 856d3b0..ea88430 100644 --- a/src/core/utils/utils.tsx +++ b/src/core/utils/utils.tsx @@ -1,44 +1,53 @@ -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', - }, - 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', + 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", }, - 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'], + 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", + ], + ACCEPTED_VIDEO_FILE_TYPES: ["video/mp4", "video/webm", "video/mkv"], + GLOBALS: { + MIN_SUBDOMAIN_LENGTH: 3, + MAX_SUBDOMAIN_LENGTH: 32, + }, }; // 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 @@ -49,140 +58,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 { - url?: string; - method?: Method; - body?: TRequest | null; - headers?: Record; - 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; + 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({ - 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): Promise { - 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; + }); } diff --git a/src/features/Settings/index.tsx b/src/features/Settings/index.tsx index d78cb6f..64df8de 100644 --- a/src/features/Settings/index.tsx +++ b/src/features/Settings/index.tsx @@ -1,26 +1,34 @@ -import { Button, Card, Flex, Form, Input } from "antd"; +import { Button, Flex, Form, Input, Modal } from "antd"; import HeaderBar from "../../core/components/Header"; import MyBanner from "../../shared/components/MyBanner"; -import { MyContainer } from "../../shared/components/MyContainer"; import { SaveOutlined } from "@ant-design/icons"; import MyUpload from "shared/components/MyUpload"; import { Constants } from "core/utils/utils"; import ColorPicker from "antd/es/color-picker"; import { useGetOrganizationSettingsQuery, + useIsSubdomainAvailableMutation, useUpdateOrganizationSettingsMutation, + useUpdateSubdomainMutation, } from "core/services/organization"; import MyErrorResult from "shared/components/MyResult"; import { useForm } from "antd/es/form/Form"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { AggregationColor } from "antd/es/color-picker/color"; -import { useDispatch } from "react-redux"; -import { setPrimaryColor } from "core/reducers/appSlice"; +import { useDispatch, useSelector } from "react-redux"; +import { + bannerUrl, + logoUrl, + setBannerUrl, + setLogoUrl, + setPrimaryColor, +} from "core/reducers/appSlice"; +import MyMiddleCard from "shared/components/MyMiddleCard"; +import { OrganizationSettings } from "core/types/organization"; -type FieldType = { +type GeneralFieldType = { primaryColor: string | AggregationColor; companyName: string; - subdomain: string; }; export default function Settings() { @@ -31,7 +39,27 @@ export default function Settings() { } ); - const [form] = useForm(); + if (error) return ; + + return ( + <> + } /> + + + + + + + + ); +} + +function GeneralCard({ + data, + isLoading, +}: { data?: OrganizationSettings; isLoading?: boolean } = {}) { + const [form] = useForm(); + const dispatch = useDispatch(); const debounceRef = useRef(null); const currentPrimaryColor = useRef(); @@ -39,7 +67,7 @@ export default function Settings() { const [updateOrganizationSettings, { isLoading: isUpdateSettingsLoading }] = useUpdateOrganizationSettingsMutation(); - const handleSave = (values: FieldType) => { + const handleSave = (values: GeneralFieldType) => { const hexColor = typeof values.primaryColor === "string" ? values.primaryColor @@ -62,7 +90,6 @@ export default function Settings() { form.setFieldsValue({ primaryColor: data.PrimaryColor, companyName: data.CompanyName, - subdomain: data.Subdomain, }); currentPrimaryColor.current = data.PrimaryColor; @@ -79,119 +106,269 @@ export default function Settings() { }; }, []); - if (error) return ; + return ( +
+ } + type="text" + shape="circle" + size="large" + htmlType="submit" + loading={isUpdateSettingsLoading} + /> + } + > + + + name="primaryColor" + label="Primary color" + > + { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + dispatch(setPrimaryColor(color.toHexString())); + }, 600); + }} + /> + + + name="companyName" label="Company name"> + + + + +
+ ); +} + +function MediaCard({ + data, + isLoading, +}: { data?: OrganizationSettings; isLoading?: boolean } = {}) { + const dispatch = useDispatch(); + + const appLogoUrl = useSelector(logoUrl); + const appBannerUrl = useSelector(bannerUrl); + + return ( +
+ + + { + if (info.file.status === "done" && info.file.response.Data) { + dispatch(setLogoUrl(info.file.response.Data)); + } + }} + imgCropProps={{ + aspect: 1 / 1, + children: <>, + }} + > + Company Logo + + + + + { + if (info.file.status === "done" && info.file.response.Data) { + dispatch(setBannerUrl(info.file.response.Data)); + } + }} + imgCropProps={{ + aspect: 22 / 9, + children: <>, + }} + > + Banner + + + +
+ ); +} + +const subdomainPattern = /^[a-zA-Z0-9-]+$/; + +function SubdomainCard({ + data, + isLoading, +}: { data?: OrganizationSettings; isLoading?: boolean } = {}) { + const [form] = useForm(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [reqIsSubdomainAvailable] = useIsSubdomainAvailableMutation(); + const [reqUpdateSubdomain] = useUpdateSubdomainMutation(); + + const validateSubdomain = async (rule: any, value: string) => { + if (value) { + if ( + value.length < Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH || + value.length > Constants.GLOBALS.MAX_SUBDOMAIN_LENGTH || + !subdomainPattern.test(value) + ) { + return Promise.reject(); + } + + // Check if subdomain is current subdomain + + if (value === window.location.hostname.split(".")[0]) { + return Promise.resolve(); + } + + try { + const { data } = await reqIsSubdomainAvailable(value); + + if (!data.Available) { + return Promise.reject("This subdomain is already taken!"); + } + + return Promise.resolve(); + } catch (error) { + return Promise.reject("This subdomain is already taken!"); + } + } + + return Promise.resolve(); + }; + + useEffect(() => { + if (data) { + form.setFieldsValue({ + subdomain: data.Subdomain, + }); + } + }, [data]); return ( <> - } /> + setIsModalOpen(false)} + okText="Change" + onOk={() => { + try { + reqUpdateSubdomain(form.getFieldValue("subdomain")); - -
- } - type="text" - shape="circle" - size="large" - htmlType="submit" - loading={isUpdateSettingsLoading} - /> - } - > - - name="primaryColor" label="Primary color"> - { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } + window.location.href = `https://${form.getFieldValue( + "subdomain" + )}.${window.location.hostname.split(".").slice(1).join(".")}`; + } catch (error) { + console.error(error); + } + }} + > +

+ Changing your subdomain will make your organization available at the + new subdomain. Please note that you will be logged out and redirected + to the new subdomain. Also the old subdomain will be available for + registration by other users. +

+ - debounceRef.current = setTimeout(() => { - dispatch(setPrimaryColor(color.toHexString())); - }, 600); - }} - /> - - name="companyName" label="Company name"> - - - name="subdomain" label="Subdomain"> - - - - { - if (info.file.status === "done") { - //onThumbnailChanged?.(); - console.log("done"); - } - }} - imgCropProps={{ - aspect: 1 / 1, - children: <>, - }} - > - Company Logo - - -
- - - { - if (info.file.status === "done") { - //onThumbnailChanged?.(); - console.log("done"); - } - }} - imgCropProps={{ - aspect: 22 / 9, - children: <>, - }} - > - Thumbnail - + { + console.log(values); + }} + > + } + type="text" + shape="circle" + size="large" + onClick={() => { + form + .validateFields() + .then(() => setIsModalOpen(true)) + .catch(() => { + console.error("Validation failed!"); + }); + }} + /> + } + > + + + -
-
-
+ + + ); } diff --git a/src/shared/components/MyBanner/index.tsx b/src/shared/components/MyBanner/index.tsx index 989a136..f8a4030 100644 --- a/src/shared/components/MyBanner/index.tsx +++ b/src/shared/components/MyBanner/index.tsx @@ -1,5 +1,7 @@ import { Constants } from "core/utils/utils"; import styles from "./styles.module.css"; +import { useSelector } from "react-redux"; +import { bannerUrl } from "core/reducers/appSlice"; export default function MyBanner({ title, @@ -10,6 +12,8 @@ export default function MyBanner({ subtitle?: string; headerBar?: React.ReactNode; }) { + const appBannerUrl = useSelector(bannerUrl); + return (
banner void; - fileType?: 'image' | 'video'; + 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 (!accept.includes(file.type)) { - console.error('File typ not allowed!'); - return false; - } + const [uploading, setUploading] = useState(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 = () => ( - - {children} - - ); - - if (fileType === 'video') { - return ; + const beforeUpload = (file: File) => { + if (!accept.includes(file.type)) { + console.error("File typ not allowed!"); + return false; } - return ( - - - - ); + 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 = () => ( + { + 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); + } + }} + beforeUpload={beforeUpload} + > + {children} + + ); + + if (fileType === "video") { + return ; + } + + return ( + + + + ); }