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 000000000..274748f7a Binary files /dev/null and b/frontend/app/assets/img/spot1.jpg differ diff --git a/frontend/app/assets/img/spot2.jpg b/frontend/app/assets/img/spot2.jpg new file mode 100644 index 000000000..b46784f99 Binary files /dev/null and b/frontend/app/assets/img/spot2.jpg differ 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 ( + <> +