From b17c3ab8d798a4cf7293f42945741fd5672046a6 Mon Sep 17 00:00:00 2001 From: Delirium Date: Wed, 31 Jul 2024 09:56:41 +0200 Subject: [PATCH] Spots UI (#2385) * feat ui: login flow for spot extension * spot list, store and service created * some fixing for header * start work on single spot * spot player start * header for player, comments, icons, etc * split stuff into compoennts, create player state manager * player controls, activity panel etc etc * comments, empty page, rename and stuff * interval buttons etc * access modal * pubkey support * fix tooltip * limit 10 -> 9 * hls lib instead of videojs * some warnings * fix date display for exp * change public links * display more client data * fix cleaning, init comment * map network to replay player network ev * stream support, console panel, close panels on X * fixing streaming, destroy on leave * fix autoplay * show notification on spot login * fix spot login * backup player added, fix audio issue * show thumbnail when no video, add spot roles * add poster thumbnail * some fixes to video check * fix events jump * fix play btn * try catch over pubkey * icons * spot login pinging * move spot login flow to login comp, use separate spot login path for unique jwt * invalidate spot jwt on logout * add visual data on page load event * typo fix * issue to copy change * share spot url f --- frontend/app/PrivateRoutes.tsx | 19 + frontend/app/PublicRoutes.tsx | 6 +- frontend/app/Router.tsx | 366 +++++---- frontend/app/api_client.ts | 10 +- frontend/app/assets/img/spot1.jpg | Bin 0 -> 26973 bytes frontend/app/assets/img/spot2.jpg | Bin 0 -> 14373 bytes .../DashboardEditModal/DashboardEditModal.tsx | 4 +- .../components/Header/UserMenu/UserMenu.tsx | 13 +- frontend/app/components/Login/Login.tsx | 132 ++- .../Player/MobilePlayer/ReplayWindow.tsx | 2 +- .../components/Session_/EventsBlock/Event.tsx | 137 ++-- .../Session_/Player/Controls/Controls.tsx | 9 +- .../Session_/Player/Controls/Timeline.tsx | 6 +- .../components/ControlsComponents.tsx | 4 +- .../Controls/components/CustomDragLayer.tsx | 2 +- .../Controls/components/PlayerControls.tsx | 7 - .../Player/Controls/timeline.module.css | 1 + .../Spots/SpotPlayer/SpotPlayer.tsx | 258 ++++++ .../SpotPlayer/components/AccessModal.tsx | 203 +++++ .../SpotPlayer/components/CommentsSection.tsx | 138 ++++ .../components/Panels/SpotConsole.tsx | 110 +++ .../components/Panels/SpotNetwork.tsx | 35 + .../SpotPlayer/components/SpotActivity.tsx | 115 +++ .../SpotPlayer/components/SpotLocation.tsx | 25 + .../components/SpotPlayerControls.tsx | 113 +++ .../components/SpotPlayerHeader.tsx | 166 ++++ .../SpotPlayer/components/SpotSideBar.tsx | 26 + .../SpotPlayer/components/SpotTimeTracker.tsx | 23 + .../SpotPlayer/components/SpotTimeline.tsx | 66 ++ .../components/SpotVideoContainer.tsx | 172 ++++ .../Spots/SpotPlayer/components/Vjs.tsx | 58 ++ .../app/components/Spots/SpotPlayer/consts.ts | 6 + .../app/components/Spots/SpotPlayer/index.tsx | 1 + .../Spots/SpotPlayer/spotPlayerStore.ts | 272 +++++++ .../Spots/SpotsList/EditItemModal.tsx | 58 ++ .../Spots/SpotsList/SpotListItem.tsx | 170 ++++ .../app/components/Spots/SpotsList/index.tsx | 241 ++++++ .../shared/DevTools/BottomBlock/Header.tsx | 4 +- .../BottomBlock/{index.js => index.ts} | 5 +- .../DevTools/ConsolePanel/ConsolePanel.tsx | 6 +- .../shared/DevTools/ConsoleRow/ConsoleRow.tsx | 4 +- .../DevTools/NetworkPanel/NetworkPanel.tsx | 18 +- .../components/FetchTabs/FetchTabs.tsx | 1 + .../ui/EscapeButton/EscapeButton.js | 2 +- frontend/app/components/ui/Icons/index.ts | 1 + frontend/app/components/ui/Icons/orSpot.tsx | 19 + frontend/app/components/ui/SVG.tsx | 6 +- frontend/app/duck/roles.js | 4 +- frontend/app/layout/Layout.tsx | 2 +- frontend/app/layout/SideMenu.tsx | 1 + frontend/app/layout/data.ts | 8 + frontend/app/mstore/index.tsx | 6 + frontend/app/mstore/loginStore.ts | 87 ++ frontend/app/mstore/notesStore.ts | 2 - frontend/app/mstore/spotStore.ts | 166 ++++ frontend/app/mstore/types/session.ts | 2 +- frontend/app/mstore/types/spot.ts | 34 + frontend/app/player-ui/FullScreenButton.tsx | 7 +- frontend/app/routes.ts | 4 + frontend/app/services/index.ts | 39 +- frontend/app/services/loginService.ts | 19 + frontend/app/services/spotService.ts | 101 +++ frontend/app/svg/icons/orSpot.svg | 22 + frontend/package.json | 12 +- frontend/webpack.config.ts | 2 +- frontend/yarn.lock | 759 +++++++++++++++--- 66 files changed, 3882 insertions(+), 435 deletions(-) create mode 100644 frontend/app/assets/img/spot1.jpg create mode 100644 frontend/app/assets/img/spot2.jpg create mode 100644 frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/AccessModal.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotLocation.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotSideBar.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotTimeTracker.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotTimeline.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/components/Vjs.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/consts.ts create mode 100644 frontend/app/components/Spots/SpotPlayer/index.tsx create mode 100644 frontend/app/components/Spots/SpotPlayer/spotPlayerStore.ts create mode 100644 frontend/app/components/Spots/SpotsList/EditItemModal.tsx create mode 100644 frontend/app/components/Spots/SpotsList/SpotListItem.tsx create mode 100644 frontend/app/components/Spots/SpotsList/index.tsx rename frontend/app/components/shared/DevTools/BottomBlock/{index.js => index.ts} (60%) create mode 100644 frontend/app/components/ui/Icons/orSpot.tsx create mode 100644 frontend/app/mstore/loginStore.ts create mode 100644 frontend/app/mstore/spotStore.ts create mode 100644 frontend/app/mstore/types/spot.ts create mode 100644 frontend/app/services/loginService.ts create mode 100644 frontend/app/services/spotService.ts create mode 100644 frontend/app/svg/icons/orSpot.svg diff --git a/frontend/app/PrivateRoutes.tsx b/frontend/app/PrivateRoutes.tsx index d8456da80..68793ec69 100644 --- a/frontend/app/PrivateRoutes.tsx +++ b/frontend/app/PrivateRoutes.tsx @@ -26,6 +26,8 @@ const components: any = { UsabilityTestingPure: lazy(() => import('Components/UsabilityTesting/UsabilityTesting')), UsabilityTestEditPure: lazy(() => import('Components/UsabilityTesting/TestEdit')), UsabilityTestOverviewPure: lazy(() => import('Components/UsabilityTesting/TestOverview')), + SpotsListPure: lazy(() => import('Components/Spots/SpotsList')), + SpotPure: lazy(() => import('Components/Spots/SpotPlayer')), }; const enhancedComponents: any = { @@ -43,6 +45,8 @@ const enhancedComponents: any = { UsabilityTesting: withSiteIdUpdater(components.UsabilityTestingPure), UsabilityTestEdit: withSiteIdUpdater(components.UsabilityTestEditPure), UsabilityTestOverview: withSiteIdUpdater(components.UsabilityTestOverviewPure), + SpotsList: withSiteIdUpdater(components.SpotsListPure), + Spot: components.SpotPure, }; const withSiteId = routes.withSiteId; @@ -86,6 +90,9 @@ const USABILITY_TESTING_PATH = routes.usabilityTesting(); const USABILITY_TESTING_EDIT_PATH = routes.usabilityTestingEdit(); const USABILITY_TESTING_VIEW_PATH = routes.usabilityTestingView(); +const SPOTS_LIST_PATH = routes.spotsList(); +const SPOT_PATH = routes.spot(); + interface Props { isEnterprise: boolean; tenantId: string; @@ -234,6 +241,18 @@ function PrivateRoutes(props: Props) { path={withSiteId(LIVE_SESSION_PATH, siteIdList)} component={enhancedComponents.LiveSession} /> + + {Object.entries(routes.redirects).map(([fr, to]) => ( diff --git a/frontend/app/PublicRoutes.tsx b/frontend/app/PublicRoutes.tsx index fecdbf518..5099363f1 100644 --- a/frontend/app/PublicRoutes.tsx +++ b/frontend/app/PublicRoutes.tsx @@ -10,10 +10,12 @@ import * as routes from 'App/routes'; const LOGIN_PATH = routes.login(); const SIGNUP_PATH = routes.signup(); const FORGOT_PASSWORD = routes.forgotPassword(); +const SPOT_PATH = routes.spot(); const Login = lazy(() => import('Components/Login/Login')); const ForgotPassword = lazy(() => import('Components/ForgotPassword/ForgotPassword')); const UpdatePassword = lazy(() => import('Components/UpdatePassword/UpdatePassword')); +const Spot = lazy(() => import('Components/Spots/SpotPlayer/SpotPlayer')); interface Props { isEnterprise: boolean; @@ -21,15 +23,17 @@ interface Props { } function PublicRoutes(props: Props) { + const hideSupport = props.isEnterprise || location.pathname.includes('spots') || location.pathname.includes('view-spot') return ( }> + - {!props.isEnterprise && } + {!hideSupport && } ); } diff --git a/frontend/app/Router.tsx b/frontend/app/Router.tsx index 51cae3feb..82d0c7c2b 100644 --- a/frontend/app/Router.tsx +++ b/frontend/app/Router.tsx @@ -1,196 +1,218 @@ -import React, {useEffect, useRef} from 'react'; -import {withRouter, RouteComponentProps} from 'react-router-dom'; -import {connect, ConnectedProps} from 'react-redux'; -import {Loader} from 'UI'; -import {fetchUserInfo, setJwt} from 'Duck/user'; -import {fetchList as fetchSiteList} from 'Duck/site'; -import {withStore} from 'App/mstore'; -import {Map} from 'immutable'; +import { Map } from 'immutable'; +import React, { useEffect, useRef } from 'react'; +import { ConnectedProps, connect } from 'react-redux'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; -import * as routes from './routes'; -import {fetchTenants} from 'Duck/user'; -import {setSessionPath} from 'Duck/sessions'; -import {ModalProvider} from 'Components/Modal'; -import {GLOBAL_DESTINATION_PATH, IFRAME, JWT_PARAM} from 'App/constants/storageKeys'; -import PublicRoutes from 'App/PublicRoutes'; -import Layout from 'App/layout/Layout'; -import {fetchListActive as fetchMetadata} from 'Duck/customField'; -import {init as initSite} from 'Duck/site'; -import PrivateRoutes from 'App/PrivateRoutes'; -import {checkParam} from 'App/utils'; import IFrameRoutes from 'App/IFrameRoutes'; -import {ModalProvider as NewModalProvider} from 'Components/ModalContext'; +import PrivateRoutes from 'App/PrivateRoutes'; +import PublicRoutes from 'App/PublicRoutes'; +import { + GLOBAL_DESTINATION_PATH, + IFRAME, + JWT_PARAM, +} from 'App/constants/storageKeys'; +import Layout from 'App/layout/Layout'; +import { withStore } from "App/mstore"; +import { checkParam } from 'App/utils'; +import { ModalProvider } from 'Components/Modal'; +import { ModalProvider as NewModalProvider } from 'Components/ModalContext'; +import { fetchListActive as fetchMetadata } from 'Duck/customField'; +import { setSessionPath } from 'Duck/sessions'; +import { fetchList as fetchSiteList } from 'Duck/site'; +import { init as initSite } from 'Duck/site'; +import { fetchUserInfo, setJwt } from 'Duck/user'; +import { fetchTenants } from 'Duck/user'; +import { Loader } from 'UI'; +import * as routes from './routes'; -interface RouterProps extends RouteComponentProps, ConnectedProps { - isLoggedIn: boolean; - sites: Map; - loading: boolean; - changePassword: boolean; - isEnterprise: boolean; - fetchUserInfo: () => any; - fetchTenants: () => any; - setSessionPath: (path: any) => any; - fetchSiteList: (siteId?: number) => any; - match: { - params: { - siteId: string; - } +interface RouterProps + extends RouteComponentProps, + ConnectedProps { + isLoggedIn: boolean; + sites: Map; + loading: boolean; + changePassword: boolean; + isEnterprise: boolean; + fetchUserInfo: () => any; + fetchTenants: () => any; + setSessionPath: (path: any) => any; + fetchSiteList: (siteId?: number) => any; + match: { + params: { + siteId: string; }; - mstore: any; - setJwt: (jwt: string) => any; - fetchMetadata: (siteId: string) => void; - initSite: (site: any) => void; + }; + mstore: any; + setJwt: (jwt: string) => any; + fetchMetadata: (siteId: string) => void; + initSite: (site: any) => void; } const Router: React.FC = (props) => { - const { - isLoggedIn, - siteId, - sites, - loading, - location, - fetchUserInfo, - fetchSiteList, - history, - match: {params: {siteId: siteIdFromPath}}, - setSessionPath, - } = props; - const [isIframe, setIsIframe] = React.useState(false); - const [isJwt, setIsJwt] = React.useState(false); - - const handleJwtFromUrl = () => { - const urlJWT = new URLSearchParams(location.search).get('jwt'); - if (urlJWT) { - props.setJwt(urlJWT); - } - }; - - const handleDestinationPath = () => { - if (!isLoggedIn && location.pathname !== routes.login()) { - localStorage.setItem(GLOBAL_DESTINATION_PATH, location.pathname + location.search); - } - }; - - const handleUserLogin = async () => { - await fetchUserInfo(); - const siteIdFromPath = parseInt(location.pathname.split('/')[1]); - await fetchSiteList(siteIdFromPath); - props.mstore.initClient(); - - const destinationPath = localStorage.getItem(GLOBAL_DESTINATION_PATH); - if ( - destinationPath && - destinationPath !== routes.login() && - destinationPath !== routes.signup() && - destinationPath !== '/' - ) { - const url = new URL(destinationPath, window.location.origin); - checkParams(url.search) - history.push(destinationPath); - localStorage.removeItem(GLOBAL_DESTINATION_PATH); - } - }; - - const checkParams = (search?: string) => { - const _isIframe = checkParam('iframe', IFRAME, search); - const _isJwt = checkParam('jwt', JWT_PARAM, search); - setIsIframe(_isIframe); - setIsJwt(_isJwt); + const { + isLoggedIn, + siteId, + sites, + loading, + location, + fetchUserInfo, + fetchSiteList, + history, + match: { + params: { siteId: siteIdFromPath }, + }, + setSessionPath, + } = props; + const [isIframe, setIsIframe] = React.useState(false); + const [isJwt, setIsJwt] = React.useState(false); + const handleJwtFromUrl = () => { + const urlJWT = new URLSearchParams(location.search).get('jwt'); + if (urlJWT) { + props.setJwt(urlJWT); } + }; - useEffect(() => { - checkParams(); - handleJwtFromUrl(); - }, []); - - useEffect(() => { - // handleJwtFromUrl(); - handleDestinationPath(); - - - setSessionPath(previousLocation ? previousLocation : location); - }, [location]); - - useEffect(() => { - if (prevIsLoggedIn !== isLoggedIn && isLoggedIn) { - handleUserLogin(); - } - }, [isLoggedIn]); - - useEffect(() => { - if (siteId && siteId !== lastFetchedSiteIdRef.current) { - const activeSite = sites.find((s) => s.id == siteId); - props.initSite(activeSite); - props.fetchMetadata(siteId); - lastFetchedSiteIdRef.current = siteId; - } - }, [siteId]); - - const lastFetchedSiteIdRef = useRef(null); - - function usePrevious(value: any) { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }, [value]); - return ref.current; + const handleDestinationPath = () => { + if (!isLoggedIn && location.pathname !== routes.login()) { + localStorage.setItem( + GLOBAL_DESTINATION_PATH, + location.pathname + location.search + ); } + }; - const prevIsLoggedIn = usePrevious(isLoggedIn); - const previousLocation = usePrevious(location); + const handleUserLogin = async () => { + await fetchUserInfo(); + const siteIdFromPath = parseInt(location.pathname.split('/')[1]); + await fetchSiteList(siteIdFromPath); + props.mstore.initClient(); - const hideHeader = (location.pathname && location.pathname.includes('/session/')) || - location.pathname.includes('/assist/') || location.pathname.includes('multiview'); - - if (isIframe) { - return ; + const destinationPath = localStorage.getItem(GLOBAL_DESTINATION_PATH); + if ( + destinationPath && + destinationPath !== routes.login() && + destinationPath !== routes.signup() && + destinationPath !== '/' + ) { + const url = new URL(destinationPath, window.location.origin); + checkParams(url.search); + history.push(destinationPath); + localStorage.removeItem(GLOBAL_DESTINATION_PATH); } + }; - return isLoggedIn ? ( - - - - - - - - - - ) : ; + const checkParams = (search?: string) => { + const _isIframe = checkParam('iframe', IFRAME, search); + const _isJwt = checkParam('jwt', JWT_PARAM, search); + setIsIframe(_isIframe); + setIsJwt(_isJwt); + }; + + useEffect(() => { + checkParams(); + handleJwtFromUrl(); + }, []); + + useEffect(() => { + // handleJwtFromUrl(); + handleDestinationPath(); + + setSessionPath(previousLocation ? previousLocation : location); + }, [location]); + + useEffect(() => { + if (prevIsLoggedIn !== isLoggedIn && isLoggedIn) { + handleUserLogin(); + } + }, [isLoggedIn]); + + useEffect(() => { + if (siteId && siteId !== lastFetchedSiteIdRef.current) { + const activeSite = sites.find((s) => s.id == siteId); + props.initSite(activeSite); + props.fetchMetadata(siteId); + lastFetchedSiteIdRef.current = siteId; + } + }, [siteId]); + + const lastFetchedSiteIdRef = useRef(null); + + function usePrevious(value: any) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; + } + + const prevIsLoggedIn = usePrevious(isLoggedIn); + const previousLocation = usePrevious(location); + + const hideHeader = + (location.pathname && location.pathname.includes('/session/')) || + location.pathname.includes('/assist/') || + location.pathname.includes('multiview') || + location.pathname.includes('/view-spot/') || + location.pathname.includes('/spots/'); + + if (isIframe) { + return ( + + ); + } + + return isLoggedIn ? ( + + + + + + + + + + ) : ( + + ); }; const mapStateToProps = (state: Map) => { - const siteId = state.getIn(['site', 'siteId']); - const jwt = state.getIn(['user', 'jwt']); - const changePassword = state.getIn(['user', 'account', 'changePassword']); - const userInfoLoading = state.getIn(['user', 'fetchUserInfoRequest', 'loading']); - const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']); + const siteId = state.getIn(['site', 'siteId']); + const jwt = state.getIn(['user', 'jwt']); + const changePassword = state.getIn(['user', 'account', 'changePassword']); + const userInfoLoading = state.getIn([ + 'user', + 'fetchUserInfoRequest', + 'loading', + ]); + const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']); - return { - siteId, - changePassword, - sites: state.getIn(['site', 'list']), - isLoggedIn: jwt !== null && !changePassword, - loading: siteId === null || userInfoLoading || sitesLoading, - email: state.getIn(['user', 'account', 'email']), - account: state.getIn(['user', 'account']), - organisation: state.getIn(['user', 'account', 'name']), - tenantId: state.getIn(['user', 'account', 'tenantId']), - tenants: state.getIn(['user', 'tenants']), - isEnterprise: - state.getIn(['user', 'account', 'edition']) === 'ee' || - state.getIn(['user', 'authDetails', 'edition']) === 'ee' - }; + return { + siteId, + changePassword, + sites: state.getIn(['site', 'list']), + jwt, + isLoggedIn: jwt !== null && !changePassword, + loading: siteId === null || userInfoLoading || sitesLoading, + email: state.getIn(['user', 'account', 'email']), + account: state.getIn(['user', 'account']), + organisation: state.getIn(['user', 'account', 'name']), + tenantId: state.getIn(['user', 'account', 'tenantId']), + tenants: state.getIn(['user', 'tenants']), + isEnterprise: + state.getIn(['user', 'account', 'edition']) === 'ee' || + state.getIn(['user', 'authDetails', 'edition']) === 'ee', + }; }; const mapDispatchToProps = { - fetchUserInfo, - fetchTenants, - setSessionPath, - fetchSiteList, - setJwt, - fetchMetadata, - initSite + fetchUserInfo, + fetchTenants, + setSessionPath, + fetchSiteList, + setJwt, + fetchMetadata, + initSite, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/frontend/app/api_client.ts b/frontend/app/api_client.ts index 73c5d554d..7a85078d9 100644 --- a/frontend/app/api_client.ts +++ b/frontend/app/api_client.ts @@ -149,7 +149,10 @@ export default class APIClient { let fetch = window.fetch; let edp = window.env.API_EDP || window.location.origin + '/api'; - + const spotService = path.includes('/spot') && !path.includes('/login') + if (spotService) { + edp = edp.replace('/api', '') + } if ( path !== '/targets_temp' && !path.includes('/metadata/session_search') && @@ -221,4 +224,9 @@ export default class APIClient { this.init.method = 'DELETE'; return this.fetch(path, params, 'DELETE'); } + + patch(path: string, params?: any, options?: any): Promise { + this.init.method = 'PATCH'; + return this.fetch(path, params, 'PATCH'); + } } \ No newline at end of file diff --git a/frontend/app/assets/img/spot1.jpg b/frontend/app/assets/img/spot1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..274748f7a3b347ee9ea04bacf0c1d0cb90748118 GIT binary patch literal 26973 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 literal 0 HcmV?d00001 diff --git a/frontend/app/components/Dashboard/components/DashboardEditModal/DashboardEditModal.tsx b/frontend/app/components/Dashboard/components/DashboardEditModal/DashboardEditModal.tsx index 23fee3374..3d8cfb44e 100644 --- a/frontend/app/components/Dashboard/components/DashboardEditModal/DashboardEditModal.tsx +++ b/frontend/app/components/Dashboard/components/DashboardEditModal/DashboardEditModal.tsx @@ -1,7 +1,7 @@ import { useObserver } from 'mobx-react-lite'; import React from 'react'; import { Modal, Form, Icon, Checkbox, Input } from 'UI'; -import {Button} from 'antd'; +import { Button } from 'antd'; import { CloseOutlined } from '@ant-design/icons'; import { useStore } from 'App/mstore' @@ -46,7 +46,7 @@ function DashboardEditModal(props: Props) {
- + ) { const { account, history, className, onLogoutClick }: any = props; + const { loginStore } = useStore(); const onAccountClick = () => { history.push(CLIENT_PATH); }; + + const onLogout = () => { + loginStore.invalidateSpotJWT() + window.postMessage({ + type: "orspot:invalidate" + }, "*") + onLogoutClick(); + } return (
) {
diff --git a/frontend/app/components/Login/Login.tsx b/frontend/app/components/Login/Login.tsx index ca187b1d6..eec61d02e 100644 --- a/frontend/app/components/Login/Login.tsx +++ b/frontend/app/components/Login/Login.tsx @@ -1,17 +1,21 @@ -import React, {useState, useEffect, useRef} from 'react'; -// import {useSelector, useDispatch} from 'react-redux'; -import {useHistory, useLocation} from 'react-router-dom'; -import {login, setJwt, fetchTenants} from 'Duck/user'; -import withPageTitle from 'HOCs/withPageTitle'; // Consider using a different approach for titles in functional components -import ReCAPTCHA from 'react-google-recaptcha'; -import {Button, Form, Input, Link, Loader, Popover, Tooltip, Icon} from 'UI'; -import {forgotPassword, signup} from 'App/routes'; -import LoginBg from '../../svg/login-illustration.svg'; -import {ENTERPRISE_REQUEIRED} from 'App/constants'; +import withPageTitle from 'HOCs/withPageTitle'; import cn from 'classnames'; -import stl from './login.module.css'; +import React, { useEffect, useRef, useState } from 'react'; +// Consider using a different approach for titles in functional components +import ReCAPTCHA from 'react-google-recaptcha'; +import { connect } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +import { ENTERPRISE_REQUEIRED } from 'App/constants'; +import { useStore } from 'App/mstore'; +import { forgotPassword, signup } from 'App/routes'; +import { fetchTenants, login, setJwt } from 'Duck/user'; +import { Button, Form, Icon, Input, Link, Loader, Tooltip } from 'UI'; + import Copyright from 'Shared/Copyright'; -import {connect} from 'react-redux'; + +import stl from './login.module.css'; const FORGOT_PASSWORD = forgotPassword(); const SIGNUP_ROUTE = signup(); @@ -26,12 +30,22 @@ interface LoginProps { location: Location; } -const Login: React.FC = ({errors, loading, authDetails, login, setJwt, fetchTenants, location}) => { +const Login: React.FC = ({ + errors, + loading, + authDetails, + login, + setJwt, + fetchTenants, + location, +}) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [CAPTCHA_ENABLED, setCAPTCHA_ENABLED] = useState(window.env.CAPTCHA_ENABLED === 'true'); + const [CAPTCHA_ENABLED, setCAPTCHA_ENABLED] = useState( + window.env.CAPTCHA_ENABLED === 'true' + ); const recaptchaRef = useRef(null); - + const { loginStore } = useStore(); const history = useHistory(); const params = new URLSearchParams(location.search); @@ -44,15 +58,54 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw }, [authDetails]); useEffect(() => { - fetchTenants() + fetchTenants(); const jwt = params.get('jwt'); if (jwt) { setJwt(jwt); } }, []); + const handleSpotLogin = (jwt: string) => { + let tries = 0; + if (!jwt) { + return; + } + let int: ReturnType; + + const onSpotMsg = (event: any) => { + if (event.data.type === 'orspot:logged') { + clearInterval(int); + window.removeEventListener('message', onSpotMsg); + toast.success('You have been logged into Spot successfully'); + } + }; + window.addEventListener('message', onSpotMsg); + + int = setInterval(() => { + if (tries > 20) { + clearInterval(int); + window.removeEventListener('message', onSpotMsg); + return; + } + window.postMessage( + { + type: 'orspot:token', + token: jwt, + }, + '*' + ); + tries += 1; + }, 250); + }; + const handleSubmit = (token?: string) => { - login({email: email.trim(), password, 'g-recaptcha-response': token}); + login({ email: email.trim(), password, 'g-recaptcha-response': token }); + loginStore.setEmail(email.trim()); + loginStore.setPassword(password); + if (token) { + loginStore.setCaptchaResponse(token); + } + void loginStore.generateSpotJWT((jwt) => handleSpotLogin(jwt)); }; const onSubmit = (e: React.FormEvent) => { @@ -65,7 +118,8 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw }; const onSSOClick = () => { - if (window !== window.top) { // if in iframe + if (window !== window.top) { + // if in iframe window.parent.location.href = `${window.location.origin}/api/sso/saml2?iFrame=true`; } else { window.location.href = `${window.location.origin}/api/sso/saml2`; @@ -76,17 +130,17 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw
- +

Login to your account

-
+
{CAPTCHA_ENABLED && ( @@ -97,7 +151,7 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw onChange={(token) => handleSubmit(token)} /> )} -
+
= ({errors, loading, authDetails, login, setJw onChange={(e) => setEmail(e.target.value)} required icon="envelope" - /> @@ -132,8 +185,11 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw
{errors.map((error) => (
- - {error}
+ + + {error} +
+
))}
@@ -150,7 +206,9 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw
- Having trouble logging in?{' '} + + Having trouble logging in? + {' '} {'Reset password'} @@ -163,7 +221,9 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw @@ -174,8 +234,9 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw
{authDetails.edition === 'ee' ? ( - SSO has not been configured.
Please reach out to your admin. -
+ SSO has not been configured.
Please reach out + to your admin. + ) : ( ENTERPRISE_REQUEIRED )} @@ -189,7 +250,9 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw className="pointer-events-none opacity-30" > {`Login with SSO ${ - authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : '' + authDetails.ssoProvider + ? `(${authDetails.ssoProvider})` + : '' }`} @@ -197,7 +260,10 @@ const Login: React.FC = ({errors, loading, authDetails, login, setJw
- +
); }; @@ -227,4 +293,4 @@ const mapDispatchToProps = { export default withPageTitle('Login - OpenReplay')( connect(mapStateToProps, mapDispatchToProps)(Login) -); \ No newline at end of file +); diff --git a/frontend/app/components/Session/Player/MobilePlayer/ReplayWindow.tsx b/frontend/app/components/Session/Player/MobilePlayer/ReplayWindow.tsx index 6f3b76a6c..c32bbd2cf 100644 --- a/frontend/app/components/Session/Player/MobilePlayer/ReplayWindow.tsx +++ b/frontend/app/components/Session/Player/MobilePlayer/ReplayWindow.tsx @@ -171,7 +171,7 @@ function ReplayWindow({ videoURL, userDevice, screenHeight, screenWidth, isAndro }); videoRef.current = videoEl; - } + } } } } diff --git a/frontend/app/components/Session_/EventsBlock/Event.tsx b/frontend/app/components/Session_/EventsBlock/Event.tsx index b5ee09a76..3823699d8 100644 --- a/frontend/app/components/Session_/EventsBlock/Event.tsx +++ b/frontend/app/components/Session_/EventsBlock/Event.tsx @@ -1,14 +1,23 @@ -import React, { useRef, useState } from 'react'; -import copy from 'copy-to-clipboard'; -import cn from 'classnames'; -import { Icon, TextEllipsis, Tooltip } from 'UI'; import { TYPES } from 'Types/session/event'; +import cn from 'classnames'; +import copy from 'copy-to-clipboard'; +import { + Angry, + MessageCircleQuestion, + MousePointerClick, + Navigation, + Pointer, + TextCursorInput, +} from 'lucide-react'; +import React, { useRef, useState } from 'react'; + import { prorata } from 'App/utils'; +import { numberWithCommas } from 'App/utils'; import withOverlay from 'Components/hocs/withOverlay'; +import { Icon, TextEllipsis, Tooltip } from 'UI'; + import LoadInfo from './LoadInfo'; import cls from './event.module.css'; -import { numberWithCommas } from 'App/utils'; -import { Navigation, MessageCircleQuestion, Pointer, TextCursorInput, Angry, MousePointerClick } from 'lucide-react' type Props = { event: any; @@ -24,7 +33,11 @@ type Props = { }; const isFrustrationEvent = (evt: any): boolean => { - if (evt.type === 'mouse_thrashing' || evt.type === TYPES.CLICKRAGE || evt.type === TYPES.TAPRAGE) { + if ( + evt.type === 'mouse_thrashing' || + evt.type === TYPES.CLICKRAGE || + evt.type === TYPES.TAPRAGE + ) { return true; } if (evt.type === TYPES.CLICK || evt.type === TYPES.INPUT) { @@ -34,17 +47,17 @@ const isFrustrationEvent = (evt: any): boolean => { }; const Event: React.FC = ({ - event, - selected = false, - isCurrent = false, - onClick, - showSelection = false, - showLoadInfo, - toggleLoadInfo, - isRed = false, - presentInSearch = false, - whiteBg - }) => { + event, + selected = false, + isCurrent = false, + onClick, + showSelection = false, + showLoadInfo, + toggleLoadInfo, + isRed = false, + presentInSearch = false, + whiteBg, +}) => { const wrapperRef = useRef(null); const [menuOpen, setMenuOpen] = useState(false); const isLocation = event.type === TYPES.LOCATION; @@ -75,12 +88,12 @@ const Event: React.FC = ({ case TYPES.LOCATION: title = 'Visited'; body = event.url; - icon = + icon = ; break; case TYPES.SWIPE: title = 'Swipe'; body = event.direction; - iconName = `chevron-${event.direction}` + iconName = `chevron-${event.direction}`; break; case TYPES.TOUCH: title = 'Tapped'; @@ -90,23 +103,35 @@ const Event: React.FC = ({ case TYPES.CLICK: title = 'Clicked'; body = event.label; - icon = isFrustration ? : ; + icon = isFrustration ? ( + + ) : ( + + ); isFrustration ? Object.assign(tooltip, { - disabled: false, - text: `User hesitated ${Math.round(event.hesitation / 1000)}s to perform this event` - }) + disabled: false, + text: `User hesitated ${Math.round( + event.hesitation / 1000 + )}s to perform this event`, + }) : null; break; case TYPES.INPUT: title = 'Input'; body = event.value; - icon = isFrustration ? : ; + icon = isFrustration ? ( + + ) : ( + + ); isFrustration ? Object.assign(tooltip, { - disabled: false, - text: `User hesitated ${Math.round(event.hesitation / 1000)}s to enter a value in this input field.` - }) + disabled: false, + text: `User hesitated ${Math.round( + event.hesitation / 1000 + )}s to enter a value in this input field.`, + }) : null; break; case TYPES.CLICKRAGE: @@ -135,25 +160,38 @@ const Event: React.FC = ({ containerClassName={'w-full'} >
-
+
- {event.type && iconName ? : icon} + {event.type && iconName ? ( + + ) : ( + icon + )}
-
-
-
- {title} +
+
+
+ + {title} + {body && !isLocation && ( )}
{isLocation && event.speedIndex != null && ( -
-
{'Speed Index'}
+
+
{'Speed Index'}
{numberWithCommas(event.speedIndex || 0)}
)} @@ -164,8 +202,12 @@ const Event: React.FC = ({
{isLocation && ( -
- {body} +
+
)}
@@ -175,12 +217,12 @@ const Event: React.FC = ({ const isFrustration = isFrustrationEvent(event); - const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE] + const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE]; return (
= ({ [cls.selected]: selected, [cls.showSelection]: showSelection, [cls.red]: isRed, - [cls.clickType]: event.type === TYPES.CLICK || event.type === TYPES.SWIPE, + [cls.clickType]: + event.type === TYPES.CLICK || event.type === TYPES.SWIPE, [cls.inputType]: event.type === TYPES.INPUT, [cls.frustration]: isFrustration, [cls.highlight]: presentInSearch, @@ -208,7 +251,9 @@ const Event: React.FC = ({ {renderBody()}
{isLocation && - (event.fcpTime || event.visuallyComplete || event.timeToInteractive) && ( + (event.fcpTime || + event.visuallyComplete || + event.timeToInteractive) && ( = ({ elements: { a: event.fcpTime, b: event.visuallyComplete, - c: event.timeToInteractive + c: event.timeToInteractive, }, startDivisorFn: (elements) => elements / 1.2, - divisorFn: (elements, parts) => elements / (2 * parts + 1) + divisorFn: (elements, parts) => elements / (2 * parts + 1), })} /> )} diff --git a/frontend/app/components/Session_/Player/Controls/Controls.tsx b/frontend/app/components/Session_/Player/Controls/Controls.tsx index dec072fd9..76a111f82 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.tsx +++ b/frontend/app/components/Session_/Player/Controls/Controls.tsx @@ -10,6 +10,7 @@ import { PlayerContext } from 'App/components/Session/playerContext'; import { useStore } from 'App/mstore'; import { FullScreenButton, PlayButton, PlayingState } from 'App/player-ui'; import { session as sessionRoute, withSiteId } from 'App/routes'; +import DropdownAudioPlayer from 'Components/Session/Player/ReplayPlayer/AudioPlayer'; import useShortcuts from 'Components/Session/Player/ReplayPlayer/useShortcuts'; import { LaunchConsoleShortcut, @@ -37,7 +38,6 @@ import { import { fetchSessions } from 'Duck/liveSearch'; import { Icon } from 'UI'; -import DropdownAudioPlayer from '../../../Session/Player/ReplayPlayer/AudioPlayer'; import ControlButton from './ControlButton'; import Timeline from './Timeline'; import PlayerControls from './components/PlayerControls'; @@ -432,7 +432,12 @@ export default connect( const permissions = state.getIn(['user', 'account', 'permissions']) || []; const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee'; return { - disableDevtools: isEnterprise && !(permissions.includes('DEV_TOOLS') || permissions.includes('SERVICE_DEV_TOOLS')), + disableDevtools: + isEnterprise && + !( + permissions.includes('DEV_TOOLS') || + permissions.includes('SERVICE_DEV_TOOLS') + ), fullscreen: state.getIn(['components', 'player', 'fullscreen']), bottomBlock: state.getIn(['components', 'player', 'bottomBlock']), showStorageRedux: !state.getIn([ diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.tsx b/frontend/app/components/Session_/Player/Controls/Timeline.tsx index e803424bf..040480cc7 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.tsx +++ b/frontend/app/components/Session_/Player/Controls/Timeline.tsx @@ -31,6 +31,7 @@ interface IProps { function Timeline(props: IProps) { const { player, store } = useContext(PlayerContext); const [wasPlaying, setWasPlaying] = useState(false); + const [maxWidth, setMaxWidth] = useState(0); const { settingsStore } = useStore(); const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get(); const { issues, timezone, timelineZoomEnabled } = props; @@ -46,6 +47,9 @@ function Timeline(props: IProps) { if (firstIssue && skipToIssue) { player.jump(firstIssue.time); } + if (progressRef.current) { + setMaxWidth(progressRef.current.clientWidth); + } }, []); const debouncedJump = useMemo(() => debounce(player.jump, 500), []); @@ -150,7 +154,7 @@ function Timeline(props: IProps) {
diff --git a/frontend/app/components/Session_/Player/Controls/components/ControlsComponents.tsx b/frontend/app/components/Session_/Player/Controls/components/ControlsComponents.tsx index 20bcd029d..99e889eb5 100644 --- a/frontend/app/components/Session_/Player/Controls/components/ControlsComponents.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/ControlsComponents.tsx @@ -137,12 +137,10 @@ export function JumpForward({ export function SpeedOptions({ toggleSpeed, disabled, - toggleTooltip, speed, }: { toggleSpeed: (i: number) => void; disabled: boolean; - toggleTooltip: () => void; speed: number; }) { return ( @@ -175,7 +173,7 @@ export function SpeedOptions({
)} > -
+
diff --git a/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx b/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx index c1b91412d..d52da5a73 100644 --- a/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx @@ -72,7 +72,7 @@ const CustomDragLayer: FC = memo(function CustomDragLayer({ maxX, minX, o } return ( -
+
diff --git a/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx index a29b5874c..49f5ba2d0 100644 --- a/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx @@ -43,7 +43,6 @@ function PlayerControls(props: Props) { startedAt, sessionTz, } = props; - const [showTooltip, setShowTooltip] = React.useState(false); const [timeMode, setTimeMode] = React.useState( localStorage.getItem('__or_player_time_mode') as ITimeMode ); @@ -53,10 +52,6 @@ function PlayerControls(props: Props) { setTimeMode(mode); }; - const toggleTooltip = () => { - setShowTooltip(!showTooltip); - }; - return (
{playButton} @@ -74,7 +69,6 @@ function PlayerControls(props: Props) { @@ -84,7 +78,6 @@ function PlayerControls(props: Props) { +
+ + ) : !generated ? ( +
+ +
+ ) : ( + <> +
+
Anyone with following link will be able to view this spot
+
+ {spotLink} +
+
+
+
Link expires in
+ +
+ {spotStore.isLoading ? 'Loading' : durationFormatted(spotStore.pubKey!.expiration * 1000)} + +
+
+
+
+
+ +
+ +
+ + )} +
+ ); +} + +export default AccessModal; diff --git a/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx b/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx new file mode 100644 index 000000000..5dc336251 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx @@ -0,0 +1,138 @@ +import { Button, Input, Tooltip } from 'antd'; +import cn from 'classnames'; +import { X } from 'lucide-react'; +import React from 'react'; +import { connect } from 'react-redux'; +import { resentOrDate } from 'App/date'; +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; +import { SendOutlined } from '@ant-design/icons'; + +function CommentsSection({ + onClose, +}: { + onClose?: () => void; +}) { + const { spotStore } = useStore(); + const comments = spotStore.currentSpot?.comments ?? []; + return ( +
+
+
Comments
+
+ +
+
+
+ {comments.map((comment) => ( +
+
+
+ {comment.user[0]} +
+
{comment.user}
+
+
{comment.text}
+
+ {resentOrDate(new Date(comment.createdAt).getTime())} +
+
+ ))} + + 5} /> +
+
+ ); +} + +function BottomSection({ loggedIn, userEmail, disableComments }: { disableComments: boolean, loggedIn?: boolean, userEmail?: string }) { + const [commentText, setCommentText] = React.useState(''); + const [userName, setUserName] = React.useState(userEmail ?? ''); + const { spotStore } = useStore(); + + const addComment = async () => { + await spotStore.addComment( + spotStore.currentSpot!.spotId, + commentText, + userName + ); + setCommentText(''); + }; + + const disableSubmit = commentText.trim().length === 0 || userName.trim().length === 0 || disableComments + return ( +
+
+
+ setUserName(e.target.value)} + /> + setCommentText(e.target.value)} + /> +
+ +
+
+ ); +} + +function mapStateToProps(state: any) { + const userEmail = state.getIn(['user', 'account', 'name']); + const loggedIn = !!userEmail; + return { + userEmail, + loggedIn, + }; +} + +const BottomSectionContainer = connect(mapStateToProps)(BottomSection); + +// const promoTitles = ['Found this Spot helpful?', 'Enjoyed this recording?']; +// +//
+//
{promoTitles[0]}
+//
+// With Spot, capture issues and provide your team with detailed insights for frictionless experiences. +//
+// +//
+// )} + +export default observer(CommentsSection); diff --git a/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx new file mode 100644 index 000000000..6c7aa4404 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx @@ -0,0 +1,110 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { AutoSizer, CellMeasurer, List } from 'react-virtualized'; + +import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'; +import BottomBlock from 'Components/shared/DevTools/BottomBlock'; +import { + TABS, + getIconProps, + renderWithNL, +} from 'Components/shared/DevTools/ConsolePanel/ConsolePanel'; +import ConsoleRow from 'Components/shared/DevTools/ConsoleRow'; +import { Icon, NoContent, Tabs } from 'UI'; + +import spotPlayerStore from '../../spotPlayerStore'; + +function SpotConsole({ onClose }: { onClose: () => void }) { + const [activeTab, setActiveTab] = React.useState(TABS[0]); + const _list = React.useRef(null); + const cache = useCellMeasurerCache(); + const onTabClick = (tab: any) => { + setActiveTab(tab); + }; + const logs = spotPlayerStore.logs; + console.log(logs) + const filteredList = React.useMemo(() => { + return logs.filter((log) => { + const tabType = activeTab.text.toLowerCase(); + if (tabType === 'all') return true; + return tabType.includes(log.level); + }); + }, [activeTab]); + const jump = (t: number) => { + spotPlayerStore.setTime(t); + }; + const _rowRenderer = ({ index, key, parent, style }: any) => { + const item = filteredList[index]; + + return ( + // @ts-ignore + + {({ measure, registerChild }) => ( + // @ts-ignore +
+ +
+ )} +
+ ); + }; + + return ( + + +
+ Console + +
+
+ + + + No Data +
+ } + size="small" + show={filteredList.length === 0} + > + + {({ height, width }: any) => ( + + )} + + + + + ); +} + +export default observer(SpotConsole); diff --git a/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx new file mode 100644 index 000000000..0a5bf49f5 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx @@ -0,0 +1,35 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import { NetworkPanelComp } from 'Components/shared/DevTools/NetworkPanel/NetworkPanel'; + +import spotPlayerStore from '../../spotPlayerStore'; + +function SpotNetwork({ panelHeight, onClose }: { panelHeight: number, onClose: () => void }) { + const list = spotPlayerStore.network; + const { index } = spotPlayerStore.getHighlightedEvent( + spotPlayerStore.time, + list + ); + const listNow = list.slice(0, index); + + return ( + spotPlayerStore.setTime(t) }} + activeIndex={index} + onClose={onClose} + /> + ); +} + +export default observer(SpotNetwork); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx new file mode 100644 index 000000000..3f1f4689b --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx @@ -0,0 +1,115 @@ +import { TYPES } from 'Types/session/event'; +import { X } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import Event from 'Components/Session_/EventsBlock/Event'; + +import spotPlayerStore from '../spotPlayerStore'; + +function SpotActivity({ onClose }: { onClose: () => void }) { + const mixedEvents = React.useMemo(() => { + const result = [...spotPlayerStore.locations, ...spotPlayerStore.clicks]; + return result.sort((a, b) => a.time - b.time); + }, [spotPlayerStore.locations, spotPlayerStore.clicks]); + + const { index } = spotPlayerStore.getHighlightedEvent( + spotPlayerStore.time, + mixedEvents + ); + const jump = (time: number) => { + spotPlayerStore.setTime(time / 1000); + }; + + const getShadowColor = (ind: number) => { + if (ind < index) return '#A7BFFF'; + if (ind === index) return '#394EFF'; + return 'transparent'; + }; + return ( +
+
+
Activity
+
+ +
+
+
+ {mixedEvents.map((event, i) => ( +
jump(event.time)} + className={'relative'} + > +
+ {i === index ? ( +
+ ) : null} + {'label' in event ? ( + // @ts-ignore + + ) : ( + + )} +
+ ))} +
+
+ ); +} + +function LocationEv({ + event, + isCurrent, +}: { + event: { time: number; location: string }; + isCurrent?: boolean; +}) { + const locEvent = { ...event, type: TYPES.LOCATION, url: event.location }; + return ; +} + +function ClickEv({ + event, + isCurrent, +}: { + event: { time: number; label: string }; + isCurrent?: boolean; +}) { + const clickEvent = { + type: TYPES.CLICK, + label: event.label, + count: 1, + }; + return ; +} + +export default observer(SpotActivity); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotLocation.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotLocation.tsx new file mode 100644 index 000000000..bf96bbbc3 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotLocation.tsx @@ -0,0 +1,25 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { Tooltip } from 'antd' +import { Icon } from 'UI'; +import spotPlayerStore from '../spotPlayerStore'; + +function SpotLocation() { + const currUrl = spotPlayerStore.getClosestLocation( + spotPlayerStore.time + )?.location; + return ( +
+
+ + + + {currUrl} + + +
+
+ ); +} + +export default observer(SpotLocation); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx new file mode 100644 index 000000000..55fab6655 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx @@ -0,0 +1,113 @@ +import { SPEED_OPTIONS } from 'Player/player/Player'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import { + IntervalSelector, + JumpBack, + JumpForward, + SpeedOptions, +} from 'App/components/Session_/Player/Controls/components/ControlsComponents'; +import { + FullScreenButton, + PlayButton, + PlayTime, + PlayingState, +} from 'App/player-ui'; +import ControlButton from 'Components/Session_/Player/Controls/ControlButton'; +import { SKIP_INTERVALS } from 'Components/Session_/Player/Controls/Controls'; + +import spotPlayerStore, { PANELS, PanelType } from '../spotPlayerStore'; + +function SpotPlayerControls() { + const toggleFullScreen = () => { + spotPlayerStore.setIsFullScreen(true); + }; + const togglePlay = () => { + if (spotPlayerStore.state === PlayingState.Completed) { + spotPlayerStore.setTime(0); + spotPlayerStore.setIsPlaying(true); + } + spotPlayerStore.setIsPlaying(!spotPlayerStore.isPlaying); + }; + + const changeSpeed = (speed: number) => { + spotPlayerStore.setPlaybackRate(SPEED_OPTIONS[speed]); + }; + const playState = spotPlayerStore.state + + const togglePanel = (panel: PanelType) => { + spotPlayerStore.setActivePanel( + panel === spotPlayerStore.activePanel ? null : panel + ); + }; + + const back = () => { + spotPlayerStore.setTime(spotPlayerStore.time - spotPlayerStore.skipInterval); + }; + const forth = () => { + spotPlayerStore.setTime(spotPlayerStore.time + spotPlayerStore.skipInterval); + }; + + return ( +
+ + +
+ + / +
{spotPlayerStore.durationString}
+
+ +
+ + + +
+ + + +
+ + togglePanel(PANELS.CONSOLE)} + active={spotPlayerStore.activePanel === PANELS.CONSOLE} + /> + togglePanel(PANELS.NETWORK)} + active={spotPlayerStore.activePanel === PANELS.NETWORK} + /> + + +
+ ); +} + +export default observer(SpotPlayerControls); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx new file mode 100644 index 000000000..3c6537760 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx @@ -0,0 +1,166 @@ +import { + ArrowLeftOutlined, + CommentOutlined, + LinkOutlined, + SettingOutlined, + UserSwitchOutlined, +} from '@ant-design/icons'; +import { Button, Popover } from 'antd'; +import copy from 'copy-to-clipboard'; +import React from 'react'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { spotsList } from 'App/routes'; +import { hashString } from 'App/types/session/session'; +import { Avatar, Icon } from 'UI'; + +import { TABS, Tab } from '../consts'; +import AccessModal from './AccessModal'; + +const spotLink = spotsList(); + +function SpotPlayerHeader({ + activeTab, + setActiveTab, + title, + user, + date, + isLoggedIn, + browserVersion, + resolution, + platform, + hasShareAccess, +}: { + activeTab: Tab | null; + setActiveTab: (tab: Tab) => void; + title: string; + user: string; + date: string; + isLoggedIn: boolean; + browserVersion: string | null; + resolution: string | null; + platform: string | null; + hasShareAccess: boolean; +}) { + const [isCopied, setIsCopied] = React.useState(false); + const [dropdownOpen, setDropdownOpen] = React.useState(false); + const onCopy = () => { + setIsCopied(true); + copy(window.location.href); + setTimeout(() => setIsCopied(false), 2000); + }; + return ( +
+
+ {isLoggedIn ? ( + +
+ +
All Spots
+
+ + ) : ( + <> +
+ +
Spot
+
+
by OpenReplay
+ + )} +
+
+
+ +
+
{title}
+
+
{user}
+
·
+
{date}
+ {browserVersion && ( + <> +
·
+
Chrome v{browserVersion}
+ + )} + {resolution && ( + <> +
·
+
{resolution}
+ + )} + {platform && ( + <> +
·
+
{platform}
+ + )} +
+
+
+
+ {isLoggedIn ? ( + <> + + {hasShareAccess ? ( + }> + + + ) : null} +
+ + ) : null} + + +
+ ); +} + +export default connect((state: any) => { + const jwt = state.getIn(['user', 'jwt']); + const isEE = state.getIn(['user', 'account', 'edition']) === 'ee'; + const permissions: string[] = + state.getIn(['user', 'account', 'permissions']) || []; + + const hasShareAccess = isEE ? permissions.includes('SPOT_PUBLIC') : true; + return { isLoggedIn: !!jwt, hasShareAccess }; +})(SpotPlayerHeader); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotSideBar.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotSideBar.tsx new file mode 100644 index 000000000..f1079207a --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotSideBar.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { SpotComment } from "App/services/spotService"; +import CommentsSection from "./CommentsSection"; +import { Tab, TABS } from "../consts"; +import SpotActivity from "./SpotActivity"; + +function SpotPlayerSideBar({ + activeTab, + onClose, + comments, +}: { + activeTab: Tab | null; + onClose: () => void; + comments: SpotComment[]; +}) { + if (activeTab === TABS.COMMENTS) { + return ; + } + if (activeTab === TABS.ACTIVITY) { + return ; + } + + return null; +} + +export default SpotPlayerSideBar \ No newline at end of file diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotTimeTracker.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotTimeTracker.tsx new file mode 100644 index 000000000..e3792e0f4 --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotTimeTracker.tsx @@ -0,0 +1,23 @@ +import DraggableCircle from 'Components/Session_/Player/Controls/components/DraggableCircle'; +import React from 'react' +import { observer } from 'mobx-react-lite'; +import { ProgressBar } from "App/player-ui"; +import spotPlayerStore from "../spotPlayerStore"; + +function SpotTimeTracker({ onDrop }: { onDrop: () => void }) { + const leftPercent = (spotPlayerStore.time / spotPlayerStore.duration) * 100 + + return ( + <> + + + + ); +} + +export default observer(SpotTimeTracker); \ No newline at end of file diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotTimeline.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotTimeline.tsx new file mode 100644 index 000000000..58a6bdc7b --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotTimeline.tsx @@ -0,0 +1,66 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import CustomDragLayer from 'App/components/Session_/Player/Controls/components/CustomDragLayer'; +import stl from 'App/components/Session_/Player/Controls/timeline.module.css'; +import { debounce } from 'App/utils'; +import cn from 'classnames' +import spotPlayerStore from '../spotPlayerStore'; +import SpotTimeTracker from './SpotTimeTracker'; + +function SpotTimeline() { + const progressRef = React.useRef(null); + const wasPlaying = React.useRef(false); + const [maxWidth, setMaxWidth] = React.useState(0); + + const debounceSetTime = React.useMemo( + () => debounce(spotPlayerStore.setTime, 100), + [] + ); + React.useEffect(() => { + if (progressRef.current) { + setMaxWidth(progressRef.current.clientWidth); + } + }, []); + const getOffset = (offsX: number) => { + return offsX / (progressRef.current?.clientWidth || 1); + }; + + const onDrag = (offset: { x: number }) => { + if (spotPlayerStore.isPlaying) { + wasPlaying.current = true; + spotPlayerStore.setIsPlaying(false); + } + const offs = getOffset(offset.x); + const time = spotPlayerStore.duration * offs; + debounceSetTime(time); + }; + + const onDrop = () => { + if (wasPlaying.current) { + spotPlayerStore.setIsPlaying(true); + wasPlaying.current = false; + } + }; + + const jump = (e: React.MouseEvent) => { + const offs = getOffset(e.nativeEvent.offsetX); + const time = spotPlayerStore.duration * offs; + spotPlayerStore.setTime(time); + }; + + return ( +
+ + +
+
+ ); +} + +export default observer(SpotTimeline); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx new file mode 100644 index 000000000..84c98f63a --- /dev/null +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx @@ -0,0 +1,172 @@ +import Hls from 'hls.js'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import { useStore } from 'App/mstore'; + +import spotPlayerStore from '../spotPlayerStore'; + +const base64toblob = (str: string) => { + const byteCharacters = atob(str); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray]); +}; + +function SpotVideoContainer({ + videoURL, + streamFile, + thumbnail, +}: { + videoURL: string; + streamFile?: string; + thumbnail?: string; +}) { + const [videoLink, setVideoLink] = React.useState(videoURL); + + const { spotStore } = useStore(); + const [isLoaded, setLoaded] = React.useState(false); + const videoRef = React.useRef(null); + const playbackTime = React.useRef(0); + const hlsRef = React.useRef(null); + + React.useEffect(() => { + if (Hls.isSupported() && videoRef.current) { + videoRef.current.addEventListener('loadeddata', () => { + setLoaded(true); + }); + if (streamFile) { + const hls = new Hls({ + enableWorker: false, + // workerPath: '/hls-worker.js', + // 1MB buffer -- we have small videos anyways + maxBufferSize: 1000 * 1000, + }); + const url = URL.createObjectURL(base64toblob(streamFile)); + if (url && videoRef.current) { + hls.loadSource(url); + hls.attachMedia(videoRef.current); + if (spotPlayerStore.isPlaying) { + void videoRef.current.play(); + } + hlsRef.current = hls; + } else { + if (videoRef.current) { + videoRef.current.src = videoURL; + if (spotPlayerStore.isPlaying) { + void videoRef.current.play(); + } + } + } + } else { + const check = () => { + fetch(videoLink).then((r) => { + if (r.ok && r.status === 200) { + if (videoRef.current) { + videoRef.current.src = ''; + setTimeout(() => { + videoRef.current!.src = videoURL; + }, 0); + } + + return true; + } else { + setTimeout(() => { + check(); + }, 1000); + } + }); + }; + check(); + videoRef.current.src = videoURL; + if (spotPlayerStore.isPlaying) { + void videoRef.current.play(); + } + } + } else { + if (videoRef.current) { + videoRef.current.addEventListener('loadeddata', () => { + setLoaded(true); + }); + videoRef.current.src = videoURL; + if (spotPlayerStore.isPlaying) { + void videoRef.current.play(); + } + } + } + return () => { + hlsRef.current?.destroy(); + }; + }, []); + + React.useEffect(() => { + if (spotPlayerStore.isPlaying) { + videoRef.current + ?.play() + .then((r) => { + console.log('started', r); + }) + .catch((e) => console.error(e)); + } else { + videoRef.current?.pause(); + } + }, [spotPlayerStore.isPlaying]); + + React.useEffect(() => { + const int = setInterval(() => { + const videoTime = videoRef.current?.currentTime ?? 0; + if (videoTime !== spotPlayerStore.time) { + playbackTime.current = videoTime; + spotPlayerStore.setTime(videoTime); + } + }, 100); + if (videoRef.current) { + videoRef.current.addEventListener('ended', () => { + spotPlayerStore.onComplete() + }) + } + return () => clearInterval(int); + }, []); + + React.useEffect(() => { + if (playbackTime.current !== spotPlayerStore.time && videoRef.current) { + videoRef.current.currentTime = spotPlayerStore.time; + } + }, [spotPlayerStore.time]); + + React.useEffect(() => { + if (videoRef.current) { + videoRef.current.playbackRate = spotPlayerStore.playbackRate; + } + }, [spotPlayerStore.playbackRate]); + return ( + <> +