* 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
This commit is contained in:
Delirium 2024-07-31 09:56:41 +02:00 committed by GitHub
parent 42eb4b5040
commit b17c3ab8d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3882 additions and 435 deletions

View file

@ -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}
/>
<Route
exact
strict
path={withSiteId(SPOTS_LIST_PATH, siteIdList)}
component={enhancedComponents.SpotsList}
/>
<Route
exact
strict
path={withSiteId(SPOT_PATH, siteIdList)}
component={enhancedComponents.Spot}
/>
{Object.entries(routes.redirects).map(([fr, to]) => (
<Redirect key={fr} exact strict from={fr} to={to} />

View file

@ -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 (
<Suspense fallback={<Loader loading={true} className='flex-1' />}>
<Switch>
<Route exact strict path={SPOT_PATH} component={Spot} />
<Route exact strict path={FORGOT_PASSWORD} component={ForgotPassword} />
<Route exact strict path={LOGIN_PATH} component={props.changePassword ? UpdatePassword : Login} />
<Route exact strict path={SIGNUP_PATH} component={Signup} />
<Redirect to={LOGIN_PATH} />
</Switch>
{!props.isEnterprise && <SupportCallout />}
{!hideSupport && <SupportCallout />}
</Suspense>
);
}

View file

@ -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<typeof connector> {
isLoggedIn: boolean;
sites: Map<string, any>;
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<typeof connector> {
isLoggedIn: boolean;
sites: Map<string, any>;
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<RouterProps> = (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<any>(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 <IFrameRoutes isJwt={isJwt} isLoggedIn={isLoggedIn} loading={loading}/>;
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 ? (
<NewModalProvider>
<ModalProvider>
<Loader loading={loading || !siteId} className='flex-1'>
<Layout hideHeader={hideHeader} siteId={siteId}>
<PrivateRoutes/>
</Layout>
</Loader>
</ModalProvider>
</NewModalProvider>
) : <PublicRoutes/>;
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<any>(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 (
<IFrameRoutes isJwt={isJwt} isLoggedIn={isLoggedIn} loading={loading} />
);
}
return isLoggedIn ? (
<NewModalProvider>
<ModalProvider>
<Loader loading={loading || !siteId} className="flex-1">
<Layout hideHeader={hideHeader} siteId={siteId}>
<PrivateRoutes />
</Layout>
</Loader>
</ModalProvider>
</NewModalProvider>
) : (
<PublicRoutes />
);
};
const mapStateToProps = (state: Map<string, any>) => {
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);

View file

@ -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<Response> {
this.init.method = 'PATCH';
return this.fetch(path, params, 'PATCH');
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -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) {
<Modal.Content>
<Form onSubmit={onSave}>
<Form.Field>
<label>{'Title:'}</label>
<label>Title:</label>
<Input
className=""
name="name"

View file

@ -4,8 +4,8 @@ import { connect } from 'react-redux';
import { logout } from 'Duck/user';
import { client, CLIENT_DEFAULT_TAB } from 'App/routes';
import { Icon } from 'UI';
import cn from 'classnames';
import { getInitials } from 'App/utils';
import { useStore } from "App/mstore";
const CLIENT_PATH = client(CLIENT_DEFAULT_TAB);
@ -17,10 +17,19 @@ interface Props {
}
function UserMenu(props: RouteComponentProps<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 (
<div
@ -42,7 +51,7 @@ function UserMenu(props: RouteComponentProps<Props>) {
<div className="p-2">
<div
className="rounded border border-transparent p-2 cursor-pointer flex items-center hover:bg-active-blue hover:!border-active-blue-border hover-teal"
onClick={onLogoutClick}
onClick={onLogout}
>
<Icon name="door-closed" size="16" />
<button className="ml-2">{'Logout'}</button>

View file

@ -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<LoginProps> = ({errors, loading, authDetails, login, setJwt, fetchTenants, location}) => {
const Login: React.FC<LoginProps> = ({
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<ReCAPTCHA>(null);
const { loginStore } = useStore();
const history = useHistory();
const params = new URLSearchParams(location.search);
@ -44,15 +58,54 @@ const Login: React.FC<LoginProps> = ({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<typeof setInterval>;
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<HTMLFormElement>) => {
@ -65,7 +118,8 @@ const Login: React.FC<LoginProps> = ({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<LoginProps> = ({errors, loading, authDetails, login, setJw
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center">
<div className="m-10 ">
<img src="/assets/logo.svg" width={200}/>
<img src="/assets/logo.svg" width={200} />
</div>
<div className="border rounded-lg bg-white shadow-sm">
<h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full">
Login to your account
</h2>
<div className={cn({'hidden': authDetails.enforceSSO})}>
<div className={cn({ hidden: authDetails.enforceSSO })}>
<Form
onSubmit={onSubmit}
className={cn('flex items-center justify-center flex-col')}
style={{width: '350px'}}
style={{ width: '350px' }}
>
<Loader loading={loading}>
{CAPTCHA_ENABLED && (
@ -97,7 +151,7 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
onChange={(token) => handleSubmit(token)}
/>
)}
<div style={{width: '350px'}} className="px-8">
<div style={{ width: '350px' }} className="px-8">
<Form.Field>
<label>Email Address</label>
<Input
@ -110,7 +164,6 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
onChange={(e) => setEmail(e.target.value)}
required
icon="envelope"
/>
</Form.Field>
<Form.Field>
@ -132,8 +185,11 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
<div className="px-8 my-2 w-full">
{errors.map((error) => (
<div className="flex items-center bg-red-lightest rounded p-3">
<Icon name="info" color="red" size="20"/>
<span className="color-red ml-2">{error}<br/></span>
<Icon name="info" color="red" size="20" />
<span className="color-red ml-2">
{error}
<br />
</span>
</div>
))}
</div>
@ -150,7 +206,9 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
</Button>
<div className="my-8 text-center">
<span className="color-gray-medium">Having trouble logging in?</span>{' '}
<span className="color-gray-medium">
Having trouble logging in?
</span>{' '}
<Link to={FORGOT_PASSWORD} className="link ml-1">
{'Reset password'}
</Link>
@ -163,7 +221,9 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
<a href="#" rel="noopener noreferrer" onClick={onSSOClick}>
<Button variant="text-primary" type="submit">
{`Login with SSO ${
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
authDetails.ssoProvider
? `(${authDetails.ssoProvider})`
: ''
}`}
</Button>
</a>
@ -174,8 +234,9 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
<div className="text-center">
{authDetails.edition === 'ee' ? (
<span>
SSO has not been configured. <br/> Please reach out to your admin.
</span>
SSO has not been configured. <br /> Please reach out
to your admin.
</span>
) : (
ENTERPRISE_REQUEIRED
)}
@ -189,7 +250,9 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
className="pointer-events-none opacity-30"
>
{`Login with SSO ${
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
authDetails.ssoProvider
? `(${authDetails.ssoProvider})`
: ''
}`}
</Button>
</Tooltip>
@ -197,7 +260,10 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
</div>
</div>
<div
className={cn("flex items-center w-96 justify-center my-8", {'hidden': !authDetails.enforceSSO})}>
className={cn('flex items-center w-96 justify-center my-8', {
hidden: !authDetails.enforceSSO,
})}
>
<a href="#" rel="noopener noreferrer" onClick={onSSOClick}>
<Button variant="primary">{`Login with SSO ${
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
@ -207,7 +273,7 @@ const Login: React.FC<LoginProps> = ({errors, loading, authDetails, login, setJw
</div>
</div>
<Copyright/>
<Copyright />
</div>
);
};
@ -227,4 +293,4 @@ const mapDispatchToProps = {
export default withPageTitle('Login - OpenReplay')(
connect(mapStateToProps, mapDispatchToProps)(Login)
);
);

View file

@ -171,7 +171,7 @@ function ReplayWindow({ videoURL, userDevice, screenHeight, screenWidth, isAndro
});
videoRef.current = videoEl;
}
}
}
}
}

View file

@ -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<Props> = ({
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<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const isLocation = event.type === TYPES.LOCATION;
@ -75,12 +88,12 @@ const Event: React.FC<Props> = ({
case TYPES.LOCATION:
title = 'Visited';
body = event.url;
icon = <Navigation size={16} strokeWidth={1} />
icon = <Navigation size={16} strokeWidth={1} />;
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<Props> = ({
case TYPES.CLICK:
title = 'Clicked';
body = event.label;
icon = isFrustration ? <MessageCircleQuestion size={16} strokeWidth={1} /> : <Pointer size={16} strokeWidth={1} />;
icon = isFrustration ? (
<MessageCircleQuestion size={16} strokeWidth={1} />
) : (
<Pointer size={16} strokeWidth={1} />
);
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 ? <MessageCircleQuestion size={16} strokeWidth={1} /> : <TextCursorInput size={16} strokeWidth={1} />;
icon = isFrustration ? (
<MessageCircleQuestion size={16} strokeWidth={1} />
) : (
<TextCursorInput size={16} strokeWidth={1} />
);
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<Props> = ({
containerClassName={'w-full'}
>
<div className={cn(cls.main, 'flex flex-col w-full')}>
<div className={cn('flex items-center w-full', { 'px-4': isLocation })}>
<div
className={cn('flex items-center w-full', { 'px-4': isLocation })}
>
<div style={{ minWidth: '16px' }}>
{event.type && iconName ? <Icon name={iconName} size='16' color={'gray-dark'} /> : icon}
{event.type && iconName ? (
<Icon name={iconName} size="16" color={'gray-dark'} />
) : (
icon
)}
</div>
<div className='ml-3 w-full'>
<div className='flex w-full items-first justify-between'>
<div className='flex items-center w-full' style={{ minWidth: '0' }}>
<span className={cn(cls.title, { 'font-medium': isLocation })}>{title}</span>
<div className="ml-3 w-full">
<div className="flex w-full items-first justify-between">
<div
className="flex items-center w-full"
style={{ minWidth: '0' }}
>
<span
className={cn(cls.title, { 'font-medium': isLocation })}
>
{title}
</span>
{body && !isLocation && (
<TextEllipsis
maxWidth='60%'
className='w-full ml-2 text-sm color-gray-medium'
maxWidth="60%"
className="w-full ml-2 text-sm color-gray-medium"
text={body}
/>
)}
</div>
{isLocation && event.speedIndex != null && (
<div className='color-gray-medium flex font-medium items-center leading-none justify-end'>
<div className='font-size-10 pr-2'>{'Speed Index'}</div>
<div className="color-gray-medium flex font-medium items-center leading-none justify-end">
<div className="font-size-10 pr-2">{'Speed Index'}</div>
<div>{numberWithCommas(event.speedIndex || 0)}</div>
</div>
)}
@ -164,8 +202,12 @@ const Event: React.FC<Props> = ({
</div>
</div>
{isLocation && (
<div className='pt-1 px-4'>
<span className='text-sm font-normal color-gray-medium'>{body}</span>
<div className="pt-1 px-4">
<TextEllipsis
maxWidth="80%"
className="text-sm font-normal color-gray-medium"
text={body}
/>
</div>
)}
</div>
@ -175,12 +217,12 @@ const Event: React.FC<Props> = ({
const isFrustration = isFrustrationEvent(event);
const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE]
const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE];
return (
<div
ref={wrapperRef}
onMouseLeave={onMouseLeave}
data-openreplay-label='Event'
data-openreplay-label="Event"
data-type={event.type}
className={cn(cls.event, {
[cls.menuClosed]: !menuOpen,
@ -188,7 +230,8 @@ const Event: React.FC<Props> = ({
[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<Props> = ({
{renderBody()}
</div>
{isLocation &&
(event.fcpTime || event.visuallyComplete || event.timeToInteractive) && (
(event.fcpTime ||
event.visuallyComplete ||
event.timeToInteractive) && (
<LoadInfo
showInfo={showLoadInfo}
onClick={toggleLoadInfo}
@ -218,10 +263,10 @@ const Event: React.FC<Props> = ({
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),
})}
/>
)}

View file

@ -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([

View file

@ -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) {
<CustomDragLayer
onDrag={onDrag}
minX={0}
maxX={progressRef.current ? progressRef.current.offsetWidth : 0}
maxX={maxWidth}
/>
<div className={stl.timeline} ref={timelineRef}>

View file

@ -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({
</div>
)}
>
<div onClick={toggleTooltip} className="cursor-pointer select-none">
<div className="cursor-pointer select-none">
<AntPopover
content={
<div className={'flex gap-2 items-center'}>

View file

@ -72,7 +72,7 @@ const CustomDragLayer: FC<Props> = memo(function CustomDragLayer({ maxX, minX, o
}
return (
<div style={layerStyles}>
<div id={"drag-layer"} style={layerStyles}>
<div
style={getItemStyles(initialOffset, currentOffset, maxX, minX)}
>

View file

@ -43,7 +43,6 @@ function PlayerControls(props: Props) {
startedAt,
sessionTz,
} = props;
const [showTooltip, setShowTooltip] = React.useState(false);
const [timeMode, setTimeMode] = React.useState<ITimeMode>(
localStorage.getItem('__or_player_time_mode') as ITimeMode
);
@ -53,10 +52,6 @@ function PlayerControls(props: Props) {
setTimeMode(mode);
};
const toggleTooltip = () => {
setShowTooltip(!showTooltip);
};
return (
<div className="flex items-center">
{playButton}
@ -74,7 +69,6 @@ function PlayerControls(props: Props) {
<IntervalSelector
skipIntervals={skipIntervals}
setSkipInterval={setSkipInterval}
toggleTooltip={toggleTooltip}
currentInterval={currentInterval}
/>
<JumpForward forthTenSeconds={forthTenSeconds} currentInterval={currentInterval} />
@ -84,7 +78,6 @@ function PlayerControls(props: Props) {
<SpeedOptions
toggleSpeed={toggleSpeed}
disabled={disabled}
toggleTooltip={toggleTooltip}
speed={speed}
/>
<Button

View file

@ -139,6 +139,7 @@
border: 1px solid $gray-lighter;
display: flex;
align-items: center;
background: white;
}

View file

@ -0,0 +1,258 @@
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { connect } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import { useStore } from 'App/mstore';
import { EscapeButton, Loader } from 'UI';
import {
debounceUpdate,
getDefaultPanelHeight,
} from '../../Session/Player/ReplayPlayer/PlayerInst';
import withPermissions from '../../hocs/withPermissions';
import SpotConsole from './components/Panels/SpotConsole';
import SpotNetwork from './components/Panels/SpotNetwork';
import SpotLocation from './components/SpotLocation';
import SpotPlayerControls from './components/SpotPlayerControls';
import SpotPlayerHeader from './components/SpotPlayerHeader';
import SpotPlayerSideBar from './components/SpotSideBar';
import SpotTimeline from './components/SpotTimeline';
import SpotVideoContainer from './components/SpotVideoContainer';
// import VideoJS from "./components/Vjs"; backup player
import { Tab } from './consts';
import spotPlayerStore, { PANELS } from './spotPlayerStore';
function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
const defaultHeight = getDefaultPanelHeight();
const history = useHistory();
const [panelHeight, setPanelHeight] = React.useState(defaultHeight);
const { spotStore } = useStore();
const { spotId } = useParams<{ spotId: string }>();
const [activeTab, setActiveTab] = React.useState<Tab | null>(null);
React.useEffect(() => {
if (!loggedIn) {
const query = new URLSearchParams(window.location.search);
const pubKey = query.get('pub_key');
if (pubKey) {
spotStore.setAccessKey(pubKey);
} else {
history.push('/');
}
}
}, [loggedIn]);
const handleResize = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
const startY = e.clientY;
const startHeight = panelHeight;
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
const deltaY = e.clientY - startY;
const diff = startHeight - deltaY;
const max =
diff > window.innerHeight / 1.5 ? window.innerHeight / 1.5 : diff;
const newHeight = Math.max(50, max);
setPanelHeight(newHeight);
debounceUpdate(newHeight);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
React.useEffect(() => {
spotStore.fetchSpotById(spotId).then(async (spotInst) => {
if (spotInst.mobURL) {
try {
void spotStore.getPubKey(spotId);
} catch {
// ignore
}
try {
const mobResp = await fetch(spotInst.mobURL);
const {
clicks = [],
logs = [],
network = [],
locations = [],
startTs = 0,
browserVersion,
resolution,
platform,
} = await mobResp.json();
spotPlayerStore.setStartTs(startTs);
spotPlayerStore.setDuration(spotInst.duration);
spotPlayerStore.setDeviceData(browserVersion, resolution, platform);
spotPlayerStore.setEvents(logs, locations, clicks, network);
} catch (e) {
console.error("Couldn't parse mob file", e);
}
}
});
const ev = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
spotPlayerStore.setIsFullScreen(false);
}
if (e.key === 'F') {
spotPlayerStore.setIsFullScreen(true);
}
if (e.key === ' ') {
spotPlayerStore.setIsPlaying(!spotPlayerStore.isPlaying);
}
if (e.key === 'ArrowDown') {
const current = spotPlayerStore.playbackRate;
spotPlayerStore.setPlaybackRate(Math.max(0.5, current / 2));
}
if (e.key === 'ArrowUp') {
const current = spotPlayerStore.playbackRate;
const highest = 16;
spotPlayerStore.setPlaybackRate(Math.min(highest, current * 2));
}
};
document.addEventListener('keydown', ev);
return () => {
document.removeEventListener('keydown', ev);
spotStore.clearCurrent();
spotPlayerStore.clearData();
};
}, []);
if (!spotStore.currentSpot) {
return (
<div className={'w-screen h-screen flex items-center justify-center'}>
<Loader />
</div>
);
}
const closeTab = () => {
setActiveTab(null);
};
const onPanelClose = () => {
spotPlayerStore.setActivePanel(null);
};
const isFullScreen = spotPlayerStore.isFullScreen;
// 2nd player option
// 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]);
// };
//
// const url = URL.createObjectURL(base64toblob(spotStore.currentSpot.streamFile));
// const videoJsOptions = {
// autoplay: true,
// controls: true,
// responsive: false,
// fluid: false,
// fill: true,
// sources: [{
// src: url,
// type: 'application/x-mpegURL'
// }]
// };
console.log(spotStore.currentSpot)
return (
<div
className={cn(
'w-screen h-screen flex flex-col',
isFullScreen ? 'relative' : ''
)}
>
{isFullScreen ? (
<EscapeButton onClose={() => spotPlayerStore.setIsFullScreen(false)} />
) : null}
<SpotPlayerHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
title={spotStore.currentSpot.title}
user={spotStore.currentSpot.user}
date={spotStore.currentSpot.createdAt}
resolution={spotPlayerStore.resolution}
platform={spotPlayerStore.platform}
browserVersion={spotPlayerStore.browserVersion}
/>
<div className={'w-full h-full flex'}>
<div className={'w-full h-full flex flex-col justify-between'}>
<SpotLocation />
<div className={cn('w-full h-full', isFullScreen ? '' : 'relative')}>
{/*<VideoJS backup player */}
{/* options={videoJsOptions}*/}
{/*/>*/}
<SpotVideoContainer
videoURL={spotStore.currentSpot.videoURL!}
streamFile={spotStore.currentSpot.streamFile}
thumbnail={spotStore.currentSpot.thumbnail}
/>
</div>
{!isFullScreen && spotPlayerStore.activePanel ? (
<div
style={{
height: panelHeight,
maxWidth: activeTab ? 'calc(100vw - 320px)' : '100vw',
width: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
<div
onMouseDown={handleResize}
className={
'w-full h-2 cursor-ns-resize absolute top-0 left-0 z-20'
}
/>
{spotPlayerStore.activePanel ? (
<div className={'w-full h-full bg-white'}>
{spotPlayerStore.activePanel === PANELS.CONSOLE ? (
<SpotConsole onClose={onPanelClose} />
) : null}
{spotPlayerStore.activePanel === PANELS.NETWORK ? (
<SpotNetwork
onClose={onPanelClose}
panelHeight={panelHeight}
/>
) : null}
</div>
) : null}
</div>
) : null}
<SpotTimeline />
{isFullScreen ? null : <SpotPlayerControls />}
</div>
<SpotPlayerSideBar
activeTab={activeTab}
onClose={closeTab}
comments={spotStore.currentSpot?.comments ?? []}
/>
</div>
</div>
);
}
function mapStateToProps(state: any) {
const userEmail = state.getIn(['user', 'account', 'name']);
const loggedIn = !!userEmail;
return {
userEmail,
loggedIn,
};
}
export default withPermissions(['SPOT'])(
connect(mapStateToProps)(observer(SpotPlayer))
);

View file

@ -0,0 +1,203 @@
import { DownOutlined, LinkOutlined, StopOutlined } from '@ant-design/icons';
import { Button, Dropdown, Segmented } from 'antd';
import copy from 'copy-to-clipboard';
import React from 'react';
import { useStore } from 'App/mstore';
import { confirm } from 'UI';
import { durationFormatted } from "../../../../date";
const HOUR_SECS = 60 * 60;
const DAY_SECS = 24 * HOUR_SECS;
const WEEK_SECS = 7 * DAY_SECS;
enum Intervals {
hour,
threeHours,
day,
week,
}
function AccessModal() {
const { spotStore } = useStore();
const [isCopied, setIsCopied] = React.useState(false);
const [isPublic, setIsPublic] = React.useState(!!spotStore.pubKey);
const [generated, setGenerated] = React.useState(!!spotStore.pubKey);
const expirationValues = {
[Intervals.hour]: HOUR_SECS,
[Intervals.threeHours]: 3 * HOUR_SECS,
[Intervals.day]: DAY_SECS,
[Intervals.week]: WEEK_SECS,
};
const spotId = spotStore.currentSpot!.spotId!;
const spotLink = `${window.location.origin}/view-spot/${spotId}${
spotStore.pubKey ? `?pub_key=${spotStore.pubKey.value}` : ''
}`;
const menuItems = [
{
key: Intervals.hour,
label: <div>One Hour</div>,
},
{
key: Intervals.threeHours,
label: <div>Three Hours</div>,
},
{
key: Intervals.day,
label: <div>One Day</div>,
},
{
key: Intervals.week,
label: <div>One Week</div>,
},
];
const onMenuClick = ({ key }: { key: Intervals }) => {
const val = expirationValues[key];
if (
spotStore.pubKey?.expiration &&
Math.abs(spotStore.pubKey?.expiration - val) / val < 0.1
) {
return;
}
void spotStore.generateKey(spotId, val);
};
const changeAccess = async (toPublic: boolean) => {
if (isPublic && !toPublic && spotStore.pubKey) {
if (
await confirm({
header: 'Confirm',
confirmButton: 'Disable',
confirmation:
'Are you sure you want to disable public sharing for this spot?',
})
) {
void spotStore.generateKey(spotId, 0);
}
}
setIsPublic(toPublic);
};
const revokeKey = async () => {
if (
await confirm({
header: 'Confirm',
confirmButton: 'Disable',
confirmation:
'Are you sure you want to disable public sharing for this spot?',
})
) {
void spotStore.generateKey(spotId, 0);
setGenerated(false);
setIsPublic(false);
}
};
const generateInitial = async () => {
const k = await spotStore.generateKey(
spotId,
expirationValues[Intervals.hour]
);
setGenerated(!!k);
};
const onCopy = () => {
setIsCopied(true);
copy(spotLink);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div
className={'flex flex-col gap-4 align-start'}
style={{ width: 420, height: generated ? 240 : 200 }}
>
<div>
<div className={'font-semibold mb-2'}>Who can access this Spot</div>
<Segmented
options={[
{
value: 'internal',
label: 'Internal',
},
{
value: 'public',
label: 'Public',
},
]}
value={isPublic ? 'public' : 'internal'}
onChange={(value) => changeAccess(value === 'public')}
/>
</div>
{!isPublic ? (
<>
<div>
<div className={'text-disabled-text'}>
All team members in your project will able to view this Spot
</div>
<div className={'px-2 py-1 border rounded bg-[#FAFAFA] whitespace-nowrap overflow-ellipsis overflow-hidden'}>
{spotLink}
</div>
</div>
<div className={'w-fit'}>
<Button
size={'small'}
onClick={onCopy}
type={'text'}
icon={<LinkOutlined />}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</>
) : !generated ? (
<div className={'w-fit'}>
<Button
loading={spotStore.isLoading}
onClick={generateInitial}
type={'primary'}
ghost
>
Enable Public Sharing
</Button>
</div>
) : (
<>
<div>
<div className={'text-disabled-text'}>Anyone with following link will be able to view this spot</div>
<div className={'px-2 py-1 border rounded bg-[#FAFAFA] whitespace-nowrap overflow-ellipsis overflow-hidden'}>
{spotLink}
</div>
</div>
<div className={'flex items-center gap-2'}>
<div>Link expires in</div>
<Dropdown menu={{ items: menuItems, onClick: onMenuClick }}>
<div>
{spotStore.isLoading ? 'Loading' : durationFormatted(spotStore.pubKey!.expiration * 1000)}
<DownOutlined />
</div>
</Dropdown>
</div>
<div className={'flex items-center gap-2'}>
<div className={'w-fit'}>
<Button
type={'primary'}
ghost
size={'small'}
onClick={onCopy}
icon={<LinkOutlined />}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
<Button type={'text'} icon={<StopOutlined />} onClick={revokeKey}>
Disable Public Sharing
</Button>
</div>
</>
)}
</div>
);
}
export default AccessModal;

View file

@ -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 (
<div
className={'h-full p-4 bg-white border border-gray-light'}
style={{ minWidth: 320, width: 320 }}
>
<div className={'flex items-center justify-between'}>
<div className={'font-semibold'}>Comments</div>
<div onClick={onClose} className={'p-1 cursor-pointer'}>
<X size={16} />
</div>
</div>
<div
className={'overflow-y-auto flex flex-col gap-4 mt-2'}
style={{ height: 'calc(100vh - 132px)' }}
>
{comments.map((comment) => (
<div key={comment.createdAt} className={'flex flex-col gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={
'w-8 h-8 bg-tealx rounded-full flex items-center justify-center color-white uppercase'
}
>
{comment.user[0]}
</div>
<div className={'font-semibold'}>{comment.user}</div>
</div>
<div>{comment.text}</div>
<div className={'text-disabled-text'}>
{resentOrDate(new Date(comment.createdAt).getTime())}
</div>
</div>
))}
<BottomSectionContainer disableComments={comments.length > 5} />
</div>
</div>
);
}
function BottomSection({ loggedIn, userEmail, disableComments }: { disableComments: boolean, loggedIn?: boolean, userEmail?: string }) {
const [commentText, setCommentText] = React.useState('');
const [userName, setUserName] = React.useState<string>(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 (
<div
className={cn(
'rounded-xl border p-4 mt-auto',
loggedIn ? 'bg-white' : 'bg-active-dark-blue'
)}
>
<div className={'flex items-center gap-2'}>
<div className={'flex flex-col w-full gap-2'}>
<Input
readOnly={loggedIn}
disabled={loggedIn}
placeholder={'Add a name'}
required
className={'w-full'}
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
<Input.TextArea
className={'w-full'}
rows={3}
autoSize={{ minRows: 3, maxRows: 3 }}
maxLength={120}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
/>
</div>
<Tooltip title={!disableComments ? "" : "Limited to 5 Messages. Join team to send more."}>
<Button
type={'primary'}
onClick={addComment}
disabled={disableSubmit}
icon={<SendOutlined />}
shape={"circle"}
/>
</Tooltip>
</div>
</div>
);
}
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?'];
//
// <div>
// <div className={'text-xl'}>{promoTitles[0]}</div>
// <div className={'my-2'}>
// With Spot, capture issues and provide your team with detailed insights for frictionless experiences.
// </div>
// <Button>
// Spot Your Issues Now
// </Button>
// </div>
// )}
export default observer(CommentsSection);

View file

@ -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<List>(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
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
rowIndex={index}
parent={parent}
>
{({ measure, registerChild }) => (
// @ts-ignore
<div ref={registerChild} style={style}>
<ConsoleRow
log={item}
jump={jump}
iconProps={getIconProps(item.level)}
renderWithNL={renderWithNL}
recalcHeight={measure}
/>
</div>
)}
</CellMeasurer>
);
};
return (
<BottomBlock>
<BottomBlock.Header onClose={onClose}>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Console</span>
<Tabs
tabs={TABS}
active={activeTab}
onClick={onTabClick}
border={false}
/>
</div>
</BottomBlock.Header>
<BottomBlock.Content className={'overflow-y-auto'}>
<NoContent
title={
<div className="capitalize flex items-center mt-16">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>
}
size="small"
show={filteredList.length === 0}
>
<AutoSizer>
{({ height, width }: any) => (
<List
ref={_list}
deferredMeasurementCache={cache}
overscanRowCount={5}
estimatedRowSize={24}
rowCount={Math.ceil(filteredList.length || 1)}
rowHeight={cache.rowHeight}
rowRenderer={_rowRenderer}
width={width}
height={height}
scrollToAlignment="center"
/>
)}
</AutoSizer>
</NoContent>
</BottomBlock.Content>
</BottomBlock>
);
}
export default observer(SpotConsole);

View file

@ -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 (
<NetworkPanelComp
panelHeight={panelHeight}
fetchList={list}
fetchListNow={listNow}
startedAt={spotPlayerStore.startTs}
zoomEnabled={false}
resourceList={[]}
resourceListNow={[]}
websocketList={[]}
websocketListNow={[]}
/* @ts-ignore */
player={{ jump: (t) => spotPlayerStore.setTime(t) }}
activeIndex={index}
onClose={onClose}
/>
);
}
export default observer(SpotNetwork);

View file

@ -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 (
<div
className={'h-full bg-white border border-gray-light'}
style={{ minWidth: 320, width: 320 }}
>
<div className={'flex items-center justify-between p-4'}>
<div className={'font-semibold'}>Activity</div>
<div onClick={onClose} className={'p-1 cursor-pointer'}>
<X size={16} />
</div>
</div>
<div
className={'overflow-y-auto'}
style={{ maxHeight: 'calc(100vh - 128px)' }}
>
{mixedEvents.map((event, i) => (
<div
key={event.time}
onClick={() => jump(event.time)}
className={'relative'}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1.5,
height: '100%',
backgroundColor: getShadowColor(i),
zIndex: 98,
}}
/>
{i === index ? (
<div
style={{
position: 'absolute',
top: '50%',
left: -10,
width: 10,
height: 10,
transform: 'rotate(45deg) translate(0, -50%)',
background: '#394EFF',
zIndex: 99,
borderRadius: '.15rem',
}}
/>
) : null}
{'label' in event ? (
// @ts-ignore
<ClickEv event={event} isCurrent={i === index} />
) : (
<LocationEv event={event} isCurrent={i === index} />
)}
</div>
))}
</div>
</div>
);
}
function LocationEv({
event,
isCurrent,
}: {
event: { time: number; location: string };
isCurrent?: boolean;
}) {
const locEvent = { ...event, type: TYPES.LOCATION, url: event.location };
return <Event showLoadInfo whiteBg event={locEvent} isCurrent={isCurrent} />;
}
function ClickEv({
event,
isCurrent,
}: {
event: { time: number; label: string };
isCurrent?: boolean;
}) {
const clickEvent = {
type: TYPES.CLICK,
label: event.label,
count: 1,
};
return <Event whiteBg event={clickEvent} isCurrent={isCurrent} />;
}
export default observer(SpotActivity);

View file

@ -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 (
<div className={'w-full bg-white border-b border-gray-lighter'}>
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
<Icon size="20" name="event/link" className="mr-1" />
<Tooltip title="Open in new tab">
<a href={currUrl} target="_blank" className="truncate">
{currUrl}
</a>
</Tooltip>
</div>
</div>
);
}
export default observer(SpotLocation);

View file

@ -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 (
<div className={'w-full p-4 flex items-center gap-4 bg-white'}>
<PlayButton togglePlay={togglePlay} state={playState} iconSize={36} />
<div
className={
'px-2 py-1 bg-white rounded font-semibold text-black flex items-center gap-2'
}
>
<PlayTime
isCustom
time={spotPlayerStore.time * 1000}
format={'mm:ss'}
/>
<span>/</span>
<div>{spotPlayerStore.durationString}</div>
</div>
<div
className="rounded ml-1 bg-white border-gray-lighter flex items-center"
style={{ gap: 1 }}
>
<JumpBack
backTenSeconds={back}
currentInterval={spotPlayerStore.skipInterval}
/>
<IntervalSelector
skipIntervals={SKIP_INTERVALS}
setSkipInterval={spotPlayerStore.setSkipInterval}
currentInterval={spotPlayerStore.skipInterval}
/>
<JumpForward
forthTenSeconds={forth}
currentInterval={spotPlayerStore.skipInterval}
/>
</div>
<SpeedOptions
toggleSpeed={changeSpeed}
disabled={false}
speed={spotPlayerStore.playbackRate}
/>
<div className={'ml-auto'} />
<ControlButton
label={'Console'}
onClick={() => togglePanel(PANELS.CONSOLE)}
active={spotPlayerStore.activePanel === PANELS.CONSOLE}
/>
<ControlButton
label={'Network'}
onClick={() => togglePanel(PANELS.NETWORK)}
active={spotPlayerStore.activePanel === PANELS.NETWORK}
/>
<FullScreenButton size={18} onClick={toggleFullScreen} />
</div>
);
}
export default observer(SpotPlayerControls);

View file

@ -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 (
<div
className={
'flex items-center gap-4 p-4 w-full bg-white border-b border-gray-light'
}
>
<div>
{isLoggedIn ? (
<Link to={spotLink}>
<div className={'flex items-center gap-2'}>
<ArrowLeftOutlined />
<div className={'font-semibold'}>All Spots</div>
</div>
</Link>
) : (
<>
<div className={'flex items-center gap-2'}>
<Icon name={'orSpot'} size={24} />
<div className={'text-lg font-semibold'}>Spot</div>
</div>
<div className={'text-disabled-text text-xs'}>by OpenReplay</div>
</>
)}
</div>
<div
className={'h-full rounded-xl bg-gray-light mx-2'}
style={{ width: 1 }}
/>
<div className={'flex items-center gap-2'}>
<Avatar seed={hashString(user)} />
<div>
<div>{title}</div>
<div className={'flex items-center gap-2 text-disabled-text'}>
<div>{user}</div>
<div>·</div>
<div>{date}</div>
{browserVersion && (
<>
<div>·</div>
<div>Chrome v{browserVersion}</div>
</>
)}
{resolution && (
<>
<div>·</div>
<div>{resolution}</div>
</>
)}
{platform && (
<>
<div>·</div>
<div>{platform}</div>
</>
)}
</div>
</div>
</div>
<div className={'ml-auto'} />
{isLoggedIn ? (
<>
<Button
size={'small'}
onClick={onCopy}
type={'primary'}
icon={<LinkOutlined />}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</Button>
{hasShareAccess ? (
<Popover open={dropdownOpen} content={<AccessModal />}>
<Button
size={'small'}
onClick={() => setDropdownOpen(!dropdownOpen)}
icon={<SettingOutlined />}
>
Manage Access
</Button>
</Popover>
) : null}
<div
className={'h-full rounded-xl bg-gray-light mx-2'}
style={{ width: 1 }}
/>
</>
) : null}
<Button
size={'small'}
disabled={activeTab === TABS.ACTIVITY}
onClick={() => setActiveTab(TABS.ACTIVITY)}
icon={<UserSwitchOutlined />}
>
Activity
</Button>
<Button
size={'small'}
disabled={activeTab === TABS.COMMENTS}
onClick={() => setActiveTab(TABS.COMMENTS)}
icon={<CommentOutlined />}
>
Comments
</Button>
</div>
);
}
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);

View file

@ -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 <CommentsSection comments={comments} onClose={onClose} />;
}
if (activeTab === TABS.ACTIVITY) {
return <SpotActivity onClose={onClose} />;
}
return null;
}
export default SpotPlayerSideBar

View file

@ -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 (
<>
<DraggableCircle left={leftPercent} onDrop={onDrop} />
<ProgressBar
scale={1}
live={false}
left={leftPercent}
time={leftPercent}
/>
</>
);
}
export default observer(SpotTimeTracker);

View file

@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
const offs = getOffset(e.nativeEvent.offsetX);
const time = spotPlayerStore.duration * offs;
spotPlayerStore.setTime(time);
};
return (
<div
ref={progressRef}
role={'button'}
className={cn(stl.progress, '-mb-1')}
onClick={jump}
>
<SpotTimeTracker onDrop={onDrop} />
<CustomDragLayer minX={0} onDrag={onDrag} maxX={maxWidth} />
<div className={stl.timeline} />
</div>
);
}
export default observer(SpotTimeline);

View file

@ -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<string>(videoURL);
const { spotStore } = useStore();
const [isLoaded, setLoaded] = React.useState(false);
const videoRef = React.useRef<HTMLVideoElement>(null);
const playbackTime = React.useRef(0);
const hlsRef = React.useRef<Hls | null>(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 (
<>
<video
ref={videoRef}
poster={thumbnail}
className={
'object-contain absolute top-0 left-0 w-full h-full bg-gray-lightest cursor-pointer'
}
onClick={() => spotPlayerStore.setIsPlaying(!spotPlayerStore.isPlaying)}
/>
{isLoaded ? null : (
<div
className={
'z-20 absolute top-0 left-0 w-full h-full flex items-center justify-center bg-figmaColors-outlined-border'
}
>
<div
className={'font-semibold color-white stroke-black animate-pulse'}
>
Loading your video...
</div>
</div>
)}
</>
);
}
export default observer(SpotVideoContainer);

View file

@ -0,0 +1,58 @@
import React from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
function VideoJS(props: {
options: Record<string, any>;
onReady: (pl: any) => void;
}) {
const videoRef = React.useRef<HTMLDivElement>(null);
const playerRef = React.useRef<ReturnType<typeof videojs>>(null);
const { options, onReady } = props;
React.useEffect(() => {
if (!playerRef.current) {
const videoElement = document.createElement('video-js');
videoElement.classList.add('vjs-big-play-centered');
if (videoRef.current) {
videoRef.current.appendChild(videoElement);
}
const player = (playerRef.current = videojs(videoElement, options, () => {
videojs.log('player is ready');
onReady && onReady(player);
}));
player.volume(1); // Set volume to maximum
player.muted(false)
} else {
const player = playerRef.current;
player.autoplay(options.autoplay);
player.src(options.sources);
player.volume(1); // Set volume to maximum
player.muted(false)
}
}, [options, videoRef]);
React.useEffect(() => {
const player = playerRef.current;
return () => {
if (player && !player.isDisposed()) {
player.dispose();
playerRef.current = null;
}
};
}, [playerRef]);
return (
<div
ref={videoRef}
data-vjs-player
style={{ height: '100%', width: '100%' }}
/>
);
}
export default VideoJS;

View file

@ -0,0 +1,6 @@
export const TABS = {
COMMENTS: 'comments',
ACTIVITY: 'activity',
} as const;
export type Tab = (typeof TABS)[keyof typeof TABS];

View file

@ -0,0 +1 @@
export { default } from './SpotPlayer';

View file

@ -0,0 +1,272 @@
import { makeAutoObservable } from 'mobx';
import { getResourceFromNetworkRequest } from 'App/player';
import { Log as PLog, ILog } from "App/player";
import { PlayingState } from 'App/player-ui'
interface Event {
time: number;
[key: string]: any;
}
interface Log extends Event {
level: 'log' | 'error' | 'warn' | 'info';
msg: string;
}
interface Location extends Event {
location: string;
}
interface Click extends Event {
label: string;
}
interface SpotNetworkRequest extends Event {
type: string;
statusCode: number;
url: string;
fromCache: boolean;
body: string;
encodedBodySize: number;
responseBodySize: number;
duration: number;
method: string;
}
const mapSpotNetworkToEv = (ev: SpotNetworkRequest): any => {
const { type, statusCode} = ev;
const mapType = (type: string) => {
switch (type) {
case 'xmlhttprequest':
return 'xhr';
case 'fetch':
return 'fetch';
case 'resource':
return 'resource';
default:
return 'other';
}
};
const request = JSON.stringify({
headers: ev.requestHeaders,
body: ev.body,
})
const response = JSON.stringify({
headers: ev.responseHeaders,
body: { warn: "Chrome Manifest V3 -- No response body available in Chrome 93+" }
})
return ({
...ev,
request,
response,
type: mapType(type),
status: statusCode,
})
};
export const PANELS = {
CONSOLE: 'CONSOLE',
NETWORK: 'NETWORK',
} as const;
export type PanelType = keyof typeof PANELS;
class SpotPlayerStore {
time = 0;
duration = 0;
durationString = '';
isPlaying = false;
state = PlayingState.Paused
isMuted = false;
volume = 1;
playbackRate = 1;
isFullScreen = false;
logs: typeof PLog[] = [];
locations: Location[] = [];
clicks: Click[] = [];
network: ReturnType<typeof getResourceFromNetworkRequest>[] = [];
startTs = 0;
activePanel: PanelType | null = null;
skipInterval = 10;
browserVersion: string | null = null;
resolution: string | null = null;
platform: string | null = null;
constructor() {
makeAutoObservable(this);
}
clearData = () => {
this.time = 0;
this.duration = 0;
this.durationString = '';
this.isPlaying = false;
this.isMuted = false;
this.volume = 1;
this.playbackRate = 1;
this.isFullScreen = false;
this.logs = [];
this.locations = [];
this.clicks = [];
this.network = [];
this.startTs = 0;
this.activePanel = null;
this.skipInterval = 10;
this.browserVersion = null;
this.resolution = null;
this.platform = null;
}
setDeviceData(browserVersion: string, resolution: string, platform: string) {
this.browserVersion = browserVersion;
this.resolution = resolution;
this.platform = platform;
}
setSkipInterval = (interval: number) => {
this.skipInterval = interval;
}
setActivePanel(panel: PanelType | null): void {
this.activePanel = panel;
}
setDuration(durString: string) {
const [minutes, seconds] = durString.split(':').map(Number);
this.durationString = durString;
this.duration = minutes * 60 + seconds;
}
setPlaybackRate(rate: number): void {
this.playbackRate = rate;
}
setStartTs(ts: number): void {
this.startTs = ts;
}
setTime(time: number): void {
this.time = Math.max(0, Math.min(time, this.duration));
}
setIsPlaying(isPlaying: boolean): void {
this.isPlaying = isPlaying;
this.state = isPlaying ? PlayingState.Playing : PlayingState.Paused;
}
onComplete = () => {
this.state = PlayingState.Completed;
}
setIsMuted(isMuted: boolean): void {
this.isMuted = isMuted;
}
setVolume(volume: number): void {
this.volume = volume;
}
setIsFullScreen(isFullScreen: boolean): void {
this.isFullScreen = isFullScreen;
}
setEvents(
logs: Log[],
locations: Location[],
clicks: Click[],
network: SpotNetworkRequest[]
): void {
this.logs = logs.map((log) => PLog({
...log,
time: log.time - this.startTs,
value: log.msg,
}));
this.locations = locations.map((location) => ({
...location,
time: location.time - this.startTs,
fcpTime: location.navTiming.fcpTime,
timeToInteractive: location.navTiming.timeToInteractive,
visuallyComplete: location.navTiming.visuallyComplete,
}));
this.clicks = clicks.map((click) => ({
...click,
time: click.time - this.startTs,
}));
this.network = network.map((request) => {
const ev = { ...request, timestamp: request.time };
return getResourceFromNetworkRequest(
mapSpotNetworkToEv(ev),
this.startTs
);
});
}
get currentLogIndex() {
return this.logs.findIndex((log) => log.time >= this.time);
}
getHighlightedEvent<T extends Log | Location | Click | SpotNetworkRequest>(
time: number,
events: T[]
): { event: T | null; index: number } {
if (!events.length) {
return { event: null, index: 0 };
}
let highlightedEvent = events[0];
const currentTs = time * 1000;
let index = 0;
for (let i = 0; i < events.length; i++) {
const event = events[i];
const nextEvent = events[i + 1];
if (
currentTs >= event.time &&
(!nextEvent || currentTs < nextEvent.time)
) {
highlightedEvent = event;
index = i;
break;
}
}
return { event: highlightedEvent, index };
}
getClosestLocation(time: number): Location {
const { event } = this.getHighlightedEvent(time, this.locations);
return event ?? { location: 'loading', time: 0 };
}
getClosestClick(time: number): Click {
const { event } = this.getHighlightedEvent(time, this.clicks);
return event ?? { label: 'loading', time: 0 };
}
getClosestNetworkIndex(time: number): SpotNetworkRequest {
// @ts-ignore
const event = this.getHighlightedEvent(time, this.network);
// @ts-ignore
return event ?? { type: 'loading', time: 0 };
}
getClosestLog(time: number): Log {
const { event } = this.getHighlightedEvent(time, this.logs);
return (
event ?? {
msg: 'loading',
time: 0,
level: 'info',
}
);
}
}
const spotPlayerStore = new SpotPlayerStore();
export default spotPlayerStore;

View file

@ -0,0 +1,58 @@
import React from 'react';
import { Modal, Form, Input } from 'UI';
import { Button } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
interface Props {
onSave: (newName: string) => void;
onClose: () => void;
itemName: string;
}
function EditItemModal(props: Props) {
const [name, setName] = React.useState(props.itemName);
const saveResult = () => {
props.onSave(name);
}
return (
<Modal open onClose={props.onClose}>
<Modal.Header className="flex items-center justify-between">
<div>{'Edit Spot'}</div>
<Button
type='text'
name="close"
onClick={props.onClose}
icon={<CloseOutlined />}
/>
</Modal.Header>
<Modal.Content>
<Form onSubmit={saveResult}>
<label>Title</label>
<Input
className=""
name="title"
value={ name }
onChange={({ target: { value } }) => setName(value)}
placeholder="Title"
maxLength={100}
autoFocus
/>
</Form>
</Modal.Content>
<Modal.Footer>
<div className="-mx-2 px-2">
<Button
type="primary"
onClick={ saveResult }
className="float-left mr-2"
>
Save
</Button>
<Button type='default' onClick={ props.onClose }>{ 'Cancel' }</Button>
</div>
</Modal.Footer>
</Modal>
)
}
export default EditItemModal;

View file

@ -0,0 +1,170 @@
import { CopyOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, GlobalOutlined, MessageOutlined, MoreOutlined, SlackOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown } from 'antd';
import copy from 'copy-to-clipboard';
import React from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Spot } from 'App/mstore/types/spot';
import { spot as spotUrl, withSiteId } from 'App/routes';
import EditItemModal from "./EditItemModal";
interface ISpotListItem {
spot: Spot;
onRename: (id: string, title: string) => void;
onDelete: () => void;
onVideo: (id: string) => Promise<{ url: string }>;
onSelect: (selected: boolean) => void;
}
function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotListItem) {
const [isEdit, setIsEdit] = React.useState(false)
const history = useHistory();
const { siteId } = useParams<{ siteId: string }>();
const menuItems = [
{
key: 'rename',
icon: <EditOutlined />,
label: 'Rename',
},
{
key: 'download',
label: 'Download Video',
icon: <DownloadOutlined />,
},
{
key: 'copy',
label: 'Copy Spot URL',
icon: <CopyOutlined />,
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: 'Delete',
},
];
React.useEffect(() => {
menuItems.splice(1, 0, {
key: 'slack',
icon: <SlackOutlined />,
label: 'Share via Slack',
});
}, []);
const onMenuClick = async ({ key }: any) => {
switch (key) {
case 'rename':
return setIsEdit(true)
case 'download':
const { url } = await onVideo(spot.spotId)
await downloadFile(url, `${spot.title}.webm`)
return;
case 'copy':
copy(`${window.location.origin}${withSiteId(spotUrl(spot.spotId.toString()), siteId)}`);
return toast.success('Spot URL copied to clipboard');
case 'delete':
return onDelete();
case 'slack':
break;
default:
break;
}
};
const onSpotClick = (e: any) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
const spotLink = withSiteId(spotUrl(spot.spotId.toString()), siteId);
const fullLink = `${window.location.origin}${spotLink}`;
window.open(fullLink, '_blank');
} else {
history.push(withSiteId(spotUrl(spot.spotId.toString()), siteId));
}
};
const onSave = (newName: string) => {
onRename(spot.spotId, newName);
setIsEdit(false);
}
return (
<div
className={
'border rounded-xl overflow-hidden flex flex-col items-start hover:shadow'
}
>
{isEdit ? (
<EditItemModal onSave={onSave} onClose={() => setIsEdit(false)} itemName={spot.title} />
) : null}
<div style={{ cursor: 'pointer', width: '100%', height: 180, position: 'relative' }} onClick={onSpotClick}>
<img
src={spot.thumbnail}
alt={spot.title}
className={'w-full h-full object-cover'}
/>
<div
className={
'absolute bottom-4 right-4 bg-black text-white p-1 rounded'
}
>
{spot.duration}
</div>
</div>
<div className={'px-2 py-4 w-full'}>
<div className={'flex items-center gap-2'}>
<div>
<Checkbox onChange={({ target: { checked }}) => onSelect(checked)} />
</div>
<div className={'cursor-pointer'} onClick={onSpotClick}>{spot.title}</div>
</div>
<div
className={'flex items-center gap-2 text-disabled-text leading-4'}
style={{ fontSize: 12 }}
>
<div>
<GlobalOutlined />
</div>
<div>{spot.user}</div>
<div>
<MessageOutlined />
</div>
<div>{spot.createdAt}</div>
<div className={'ml-auto'}>
<Dropdown
menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']}
>
<Button type="text" icon={<MoreOutlined />} size={'small'} />
</Dropdown>
</div>
</div>
</div>
</div>
);
}
async function downloadFile(url: string, fileName: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Error downloading file:', error);
}
}
export default SpotListItem;

View file

@ -0,0 +1,241 @@
import { DownOutlined } from '@ant-design/icons';
import { Button, Dropdown, Input } from 'antd';
import { Pin, Puzzle, Share2 } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { useStore } from 'App/mstore';
import { numberWithCommas } from 'App/utils';
import { Icon, Loader, Pagination } from "UI";
import withPermissions from "../../hocs/withPermissions";
import SpotListItem from './SpotListItem';
const visibilityOptions = {
all: 'All Spots',
own: 'My Spots',
} as const;
function SpotsListHeader({
disableButton,
onDelete,
}: {
disableButton: boolean;
onDelete: () => void;
}) {
const dropdownProps = {
items: [
{
label: 'All Spots',
key: 'all',
},
{
label: 'My Spots',
key: 'own',
},
],
onClick: ({ key }: any) => onFilterChange(key),
};
const { spotStore } = useStore();
const onSearch = (value: string) => {
spotStore.setQuery(value);
void spotStore.fetchSpots();
};
const onFilterChange = (key: 'all' | 'own') => {
spotStore.setFilter(key);
void spotStore.fetchSpots();
};
return (
<div className={'flex items-center px-4 gap-4 pb-4'}>
<Icon name={'orSpot'} size={24} />
<div className={'text-2xl capitalize mr-2'}>Spots</div>
<div className={'ml-auto'}>
<Button size={'small'} disabled={disableButton} onClick={onDelete}>
Delete Selected
</Button>
</div>
<Dropdown menu={dropdownProps}>
<div className={'cursor-pointer flex items-center justify-end gap-2'}>
<div>{visibilityOptions[spotStore.filter]}</div>
<DownOutlined />
</div>
</Dropdown>
<div style={{ width: 210 }}>
<Input.Search
value={spotStore.query}
allowClear
name="spot-search"
placeholder="Filter by title"
onChange={(e) => spotStore.setQuery(e.target.value)}
onSearch={(value) => onSearch(value)}
/>
</div>
</div>
);
}
function SpotsList() {
const [selectedSpots, setSelectedSpots] = React.useState<string[]>([]);
const { spotStore } = useStore();
React.useEffect(() => {
void spotStore.fetchSpots();
}, []);
const onPageChange = (page: number) => {
spotStore.setPage(page);
void spotStore.fetchSpots();
};
const onDelete = (spotId: string) => {
void spotStore.deleteSpot([spotId]);
};
const batchDelete = () => {
void spotStore.deleteSpot(selectedSpots);
setSelectedSpots([]);
};
const onRename = (id: string, newName: string) => {
return spotStore.updateSpot(id, { name: newName });
};
const onVideo = (id: string) => {
return spotStore.getVideo(id);
};
return (
<div className={'w-full'}>
<div
className={'mx-auto bg-white rounded border py-4'}
style={{ maxWidth: 1360 }}
>
<SpotsListHeader
disableButton={selectedSpots.length === 0}
onDelete={batchDelete}
/>
{spotStore.total === 0 ? (
spotStore.isLoading ? <Loader /> : <EmptyPage />
) : (
<>
<div
className={
'py-2 px-0.5 border-t border-b border-gray-lighter grid grid-cols-3 gap-2'
}
>
{spotStore.spots.map((spot, index) => (
<SpotListItem
key={index}
spot={spot}
onDelete={() => onDelete(spot.spotId)}
onRename={onRename}
onVideo={onVideo}
onSelect={(checked: boolean) => {
if (checked) {
setSelectedSpots([...selectedSpots, spot.spotId]);
} else {
setSelectedSpots(
selectedSpots.filter((s) => s !== spot.spotId)
);
}
}}
/>
))}
</div>
<div className="flex items-center justify-between p-5 w-full">
<div>
Showing{' '}
<span className="font-medium">
{(spotStore.page - 1) * spotStore.limit + 1}
</span>{' '}
to{' '}
<span className="font-medium">
{(spotStore.page - 1) * spotStore.limit +
spotStore.spots.length}
</span>{' '}
of{' '}
<span className="font-medium">
{numberWithCommas(spotStore.total)}
</span>{' '}
spots.
</div>
<Pagination
page={spotStore.page}
total={spotStore.total}
onPageChange={onPageChange}
limit={spotStore.limit}
debounceRequest={500}
/>
</div>
</>
)}
</div>
</div>
);
}
function EmptyPage() {
return (
<div className={'flex flex-col gap-4 items-center w-full border-t pt-2'}>
<div className={'font-semibold text-xl'}>Spot your first bug</div>
<div className={'text-disabled-text w-1/2'}>
Spot is a browser extension by OpenReplay, that captures detailed bug
reports including screen recordings and technical details that
developers need to troubleshoot an issue efficiently.
</div>
<div className={'flex gap-4 mt-4'}>
<img src={'assets/img/spot1.jpg'} alt={'pin spot'} width={200} />
<div className={'flex flex-col gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={
'-ml-2 h-8 w-8 bg-[#FFF7E6] rounded-full flex items-center justify-center'
}
>
<span>1</span>
</div>
<div className={'font-semibold'}>Pin Spot extension (Optional)</div>
</div>
<div className={'flex items-center gap-2'}>
<Puzzle size={16} strokeWidth={1} />
<div>Open installed extensions</div>
</div>
<div className={'flex items-center gap-2'}>
<Pin size={16} strokeWidth={1} />
<div>Pin Spot, for easy access.</div>
</div>
</div>
</div>
<div className={'flex gap-4 mt-4'}>
<img src={'assets/img/spot2.jpg'} alt={'start recording'} width={200} />
<div className={'flex flex-col gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={
'-ml-2 h-8 w-8 bg-[#FFF7E6] rounded-full flex items-center justify-center'
}
>
<span>2</span>
</div>
<div className={'font-semibold'}>Capture and share a bug</div>
</div>
<div className={'flex items-center gap-2'}>
<Icon name={'orSpot'} size={16} />
<div>Click the Spot icon to log bugs!</div>
</div>
<div className={'flex items-center gap-2'}>
<Share2 size={16} strokeWidth={1} />
<div>Share it with your team</div>
</div>
</div>
</div>
</div>
);
}
export default withPermissions(['SPOT'])(observer(SpotsList))

View file

@ -9,6 +9,7 @@ const Header = ({
children,
className,
closeBottomBlock,
onClose,
onFilterChange,
showClose = true,
...props
@ -18,11 +19,12 @@ const Header = ({
closeBottomBlock?: () => void;
onFilterChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
showClose?: boolean;
onClose?: () => void;
}) => (
<div className={ cn("relative border-r border-l py-1", stl.header) } >
<div className={ cn("w-full h-full flex justify-between items-center", className) } >
<div className="w-full flex items-center justify-between">{ children }</div>
{ showClose && <CloseButton onClick={ closeBottomBlock } size="18" className="ml-2" /> }
{ showClose && <CloseButton onClick={ onClose ? onClose : closeBottomBlock } size="18" className="ml-2" /> }
</div>
</div>
);

View file

@ -5,4 +5,7 @@ import Content from './Content';
BottomBlock.Header = Header;
BottomBlock.Content = Content;
export default BottomBlock;
export default BottomBlock as typeof BottomBlock & {
Header: typeof Header;
Content: typeof Content;
};

View file

@ -29,9 +29,9 @@ const LEVEL_TAB = {
[LogLevel.DEBUG]: INFO,
} as const;
const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab }));
export const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab }));
function renderWithNL(s: string | null = '') {
export function renderWithNL(s: string | null = '') {
if (typeof s !== 'string') return '';
return s.split('\n').map((line, i) => (
<div key={i + line.slice(0, 6)} className={cn({ 'ml-20': i !== 0 })}>
@ -40,7 +40,7 @@ function renderWithNL(s: string | null = '') {
));
}
const getIconProps = (level: LogLevel) => {
export const getIconProps = (level: LogLevel) => {
switch (level) {
case LogLevel.INFO:
case LogLevel.LOG:

View file

@ -10,7 +10,7 @@ interface Props {
renderWithNL?: any;
style?: any;
recalcHeight?: () => void;
onClick: () => void;
onClick?: () => void;
}
function ConsoleRow(props: Props) {
const { log, iconProps, jump, renderWithNL, style, recalcHeight } = props;
@ -42,7 +42,7 @@ function ConsoleRow(props: Props) {
'cursor-pointer': clickable,
}
)}
onClick={clickable ? () => (!!log.errorId ? props.onClick() : toggleExpand()) : undefined}
onClick={clickable ? () => (!!log.errorId ? props.onClick?.() : toggleExpand()) : undefined}
>
<div className="mr-2">
<Icon size="14" {...iconProps} />

View file

@ -43,7 +43,7 @@ const TYPE_TO_TAB = {
};
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS] as const;
const TABS = TAP_KEYS.map((tab) => ({
export const NETWORK_TABS = TAP_KEYS.map((tab) => ({
text: tab === 'xhr' ? 'Fetch/XHR' : tab,
key: tab,
}));
@ -272,12 +272,13 @@ interface Props {
startedAt: number;
isMobile?: boolean;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
zoomStartTs?: number;
zoomEndTs?: number;
panelHeight: number;
onClose?: () => void;
}
const NetworkPanelComp = observer(
export const NetworkPanelComp = observer(
({
loadTime,
domBuildingTime,
@ -294,6 +295,7 @@ const NetworkPanelComp = observer(
zoomEnabled,
zoomStartTs,
zoomEndTs,
onClose,
}: Props) => {
const { showModal } = useModal();
const [sortBy, setSortBy] = useState('time');
@ -360,7 +362,7 @@ const NetworkPanelComp = observer(
transferredBodySize: 0,
}))
)
.filter((req) => (zoomEnabled ? req.time >= zoomStartTs && req.time <= zoomEndTs : true))
.filter((req) => (zoomEnabled ? req.time >= zoomStartTs! && req.time <= zoomEndTs! : true))
.sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length, socketList]
);
@ -462,20 +464,19 @@ const NetworkPanelComp = observer(
};
return (
<React.Fragment>
<BottomBlock
style={{ height: '100%' }}
className="border"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<BottomBlock.Header>
<BottomBlock.Header onClose={onClose}>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Network</span>
{isMobile ? null : (
<Tabs
className="uppercase"
tabs={TABS}
tabs={NETWORK_TABS}
active={activeTab}
onClick={onTabClick}
border={false}
@ -606,7 +607,6 @@ const NetworkPanelComp = observer(
</NoContent>
</BottomBlock.Content>
</BottomBlock>
</React.Fragment>
);
}
);

View file

@ -84,6 +84,7 @@ function FetchTabs({ resource }: Props) {
useEffect(() => {
const { request, response } = resource;
console.log(resource, request, response)
parseRequestResponse(
request,
setRequestHeaders,

View file

@ -2,7 +2,7 @@ import React from 'react'
import { Icon } from 'UI';
import stl from './escapeButton.module.css'
function EscapeButton({ onClose = () => null }) {
function EscapeButton({ onClose }) {
return (
<div className={ stl.closeWrapper } onClick={ onClose }>
<Icon name="close" size="16" />

View file

@ -434,6 +434,7 @@ export { default as No_dashboard } from './no_dashboard';
export { default as No_metrics_chart } from './no_metrics_chart';
export { default as No_metrics } from './no_metrics';
export { default as No_recordings } from './no_recordings';
export { default as Orspot } from './orSpot';
export { default as Os_android } from './os_android';
export { default as Os_chrome_os } from './os_chrome_os';
export { default as Os_fedora } from './os_fedora';

View file

@ -0,0 +1,19 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Orspot(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 24 24" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3.313 22.657V1.937L21.38 12.213 3.312 22.657Z" fill="#fff"/><path d="M19.657 11.982 4.376 3.014V20.95l15.281-8.968Zm2.085-1.854a2.14 2.14 0 0 1 1.063 1.854 2.14 2.14 0 0 1-1.063 1.854L4.99 23.67c-1.369.804-3.246-.115-3.246-1.854V2.148C1.743.408 3.62-.51 4.99.294l16.753 9.834Z" fill="#122AF5"/><path d="M13.36 11.488a.57.57 0 0 1 0 .988L8.843 15.1c-.369.214-.875-.03-.875-.495V9.36c0-.464.506-.71.875-.495l4.519 2.623Z" fill="#3EAAAF"/><g filter="url(#a)"><circle cx="13.964" cy="5.629" fill="#C00" r="3.158"/><circle cx="13.964" cy="5.629" stroke="#fff" strokeWidth="1.501" r="3.158"/></g><defs><filter id="a" x="10.056" y="1.72" width="7.817" height="9.067" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dy="1.25"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_239_4096"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_239_4096" result="shape"/></filter></defs></svg>
);
}
export default Orspot;

File diff suppressed because one or more lines are too long

View file

@ -18,7 +18,9 @@ const initialState = Map({
{ text: 'Dashboard', value: 'METRICS' },
{ text: 'Assist (Live)', value: 'ASSIST_LIVE' },
{ text: 'Assist (Call)', value: 'ASSIST_CALL' },
{ text: 'Feature Flags', value: 'FEATURE_FLAGS' }
{ text: 'Feature Flags', value: 'FEATURE_FLAGS' },
{ text: 'Spots', value: "SPOT" },
{ text: 'Change Spot Visibility', value: 'SPOT_PUBLIC' }
]),
});

View file

@ -22,7 +22,7 @@ interface Props {
function Layout(props: Props) {
const { hideHeader, siteId } = props;
const isPlayer = /\/(session|assist)\//.test(window.location.pathname);
const isPlayer = /\/(session|assist|view-spot)\//.test(window.location.pathname);
const { settingsStore } = useStore();
// const lastFetchedSiteIdRef = React.useRef<string | null>(null);

View file

@ -112,6 +112,7 @@ function SideMenu(props: Props) {
[MENU.FEATURE_FLAGS]: () => withSiteId(routes.fflags(), siteId),
[MENU.PREFERENCES]: () => client(CLIENT_DEFAULT_TAB),
[MENU.USABILITY_TESTS]: () => withSiteId(routes.usabilityTesting(), siteId),
[MENU.SPOTS]: () => withSiteId(routes.spotsList(), siteId),
[PREFERENCES_MENU.ACCOUNT]: () => client(CLIENT_TABS.PROFILE),
[PREFERENCES_MENU.SESSION_LISTING]: () => client(CLIENT_TABS.SESSIONS_LISTING),
[PREFERENCES_MENU.INTEGRATIONS]: () => client(CLIENT_TABS.INTEGRATIONS),

View file

@ -53,6 +53,7 @@ export const enum MENU {
PREFERENCES = 'preferences',
SUPPORT = 'support',
EXIT = 'exit',
SPOTS = 'spots',
}
export const categories: Category[] = [
@ -67,6 +68,13 @@ export const categories: Category[] = [
{ label: 'Notes', key: MENU.NOTES, icon: 'stickies' }
]
},
{
title: '',
key: 'spot',
items: [
{ label: 'Spots', key: MENU.SPOTS, icon: 'orSpot' },
]
},
{
title: '',
key: 'assist',

View file

@ -21,6 +21,8 @@ import UxtestingStore from './uxtestingStore';
import TagWatchStore from './tagWatchStore';
import AiSummaryStore from "./aiSummaryStore";
import AiFiltersStore from "./aiFiltersStore";
import SpotStore from "./spotStore";
import LoginStore from "./loginStore";
export class RootStore {
dashboardStore: DashboardStore;
@ -43,6 +45,8 @@ export class RootStore {
tagWatchStore: TagWatchStore;
aiSummaryStore: AiSummaryStore;
aiFiltersStore: AiFiltersStore;
spotStore: SpotStore;
loginStore: LoginStore;
constructor() {
this.dashboardStore = new DashboardStore();
@ -65,6 +69,8 @@ export class RootStore {
this.tagWatchStore = new TagWatchStore();
this.aiSummaryStore = new AiSummaryStore();
this.aiFiltersStore = new AiFiltersStore();
this.spotStore = new SpotStore();
this.loginStore = new LoginStore();
}
initClient() {

View file

@ -0,0 +1,87 @@
import { makeAutoObservable } from 'mobx';
import { loginService } from "../services";
const spotTokenKey = "___$or_spotToken$___"
class LoginStore {
email = '';
password = '';
captchaResponse?: string;
spotJWT?: string;
constructor() {
makeAutoObservable(this);
const token = localStorage.getItem(spotTokenKey);
if (token) {
this.spotJWT = token;
}
}
getSpotJWT = async (): Promise<string | null> => {
if (this.spotJwtPending) {
let tries = 0
return new Promise<string | null>((resolve) => {
const interval = setInterval(() => {
if (!this.spotJwtPending && this.spotJWT) {
clearInterval(interval)
resolve(this.spotJWT)
}
if (tries > 50) {
clearInterval(interval)
resolve(null)
}
}, 100)
})
}
return this.spotJWT ?? null
}
setEmail = (email: string) => {
this.email = email;
}
setPassword = (password: string) => {
this.password = password;
}
setCaptchaResponse = (captchaResponse: string) => {
this.captchaResponse = captchaResponse;
}
setSpotJWT = (spotJWT: string) => {
this.spotJWT = spotJWT;
localStorage.setItem(spotTokenKey, spotJWT);
}
spotJwtPending = false
setSpotJwtPending = (pending: boolean) => {
this.spotJwtPending = pending
}
generateSpotJWT = async (onSuccess: (jwt:string) => void) => {
if (this.spotJwtPending) {
return
}
this.setSpotJwtPending(true)
try {
const resp = await loginService.spotLogin({
email: this.email,
password: this.password,
captchaResponse: this.captchaResponse
})
this.setSpotJWT(resp.jwt)
onSuccess(resp.jwt)
} catch (e) {
console.error(e)
} finally {
this.setSpotJwtPending(false)
}
}
invalidateSpotJWT = () => {
this.spotJWT = undefined
localStorage.removeItem(spotTokenKey)
}
}
export default LoginStore;

View file

@ -1,7 +1,5 @@
import { makeAutoObservable } from "mobx";
import { notesService } from "App/services";
import { Note, NotesFilter, WriteNote, iTag } from 'App/services/NotesService';

View file

@ -0,0 +1,166 @@
import { makeAutoObservable } from 'mobx';
import { spotService } from 'App/services';
import { UpdateSpotRequest } from 'App/services/spotService';
import { Spot } from './types/spot';
export default class SpotStore {
isLoading: boolean = false;
spots: Spot[] = [];
currentSpot: Spot | null = null;
page: number = 1;
filter: 'all' | 'own' = 'all';
query: string = '';
total: number = 0;
limit: number = 9;
accessKey: string | undefined = undefined;
pubKey: { value: string; expiration: number } | null = null;
readonly order = 'desc';
constructor() {
makeAutoObservable(this);
}
clearCurrent = () => {
this.currentSpot = null;
this.pubKey = null;
this.accessKey = undefined;
};
withLoader<T>(fn: () => Promise<T>): Promise<T> {
this.setLoading(true);
return fn().finally(() => {
this.setLoading(false);
});
}
setAccessKey(key: string) {
this.accessKey = key;
}
setSpots(spots: Spot[]) {
this.spots = spots;
}
setCurrentSpot(spot: Spot) {
this.currentSpot = spot;
}
setFilter(filter: 'all' | 'own') {
this.filter = filter;
}
setQuery(query: string) {
this.query = query;
}
setPage(page: number) {
this.page = page;
}
setLoading(loading: boolean) {
this.isLoading = loading;
}
setTotal(total: number) {
this.total = total;
}
async fetchSpots() {
const filters = {
page: this.page,
filterBy: this.filter,
query: this.query,
order: this.order,
limit: this.limit,
} as const;
const response = await this.withLoader(() =>
spotService.fetchSpots(filters)
);
this.setSpots(response.spots.map((spot: any) => new Spot(spot)));
this.setTotal(response.total);
}
async fetchSpotById(id: string) {
const response = await this.withLoader(() =>
spotService.fetchSpot(id, this.accessKey)
);
const spotInst = new Spot({ ...response.spot, id });
this.setCurrentSpot(spotInst);
return spotInst;
}
async addComment(spotId: string, comment: string, userName: string) {
await this.withLoader(async () => {
await spotService.addComment(spotId, { comment, userName });
const spot = this.currentSpot;
if (spot) {
spot.comments!.push({
text: comment,
user: userName,
createdAt: new Date().toISOString(),
});
this.setCurrentSpot(spot);
}
});
}
async deleteSpot(spotIds: string[]) {
await this.withLoader(() => spotService.deleteSpot(spotIds));
this.spots = this.spots.filter(
(spot) => spotIds.findIndex((s) => s === spot.spotId) === -1
);
this.total = this.total - spotIds.length;
await this.fetchSpots();
}
async updateSpot(spotId: string, data: UpdateSpotRequest) {
await this.withLoader(() => spotService.updateSpot(spotId, data));
if (data.name !== undefined) {
const updatedSpots = this.spots.map((s) => {
if (s.spotId === spotId) {
s.title = data.name!;
}
return s;
});
this.setSpots(updatedSpots);
}
}
async getVideo(id: string) {
return await this.withLoader(() => spotService.getVideo(id));
}
setPubKey(key: { value: string; expiration: number }) {
this.pubKey = key;
}
/**
* @param expiration - in seconds
* @param id - spot id string
* */
async generateKey(id: string, expiration: number) {
try {
const { key } = await this.withLoader(() =>
spotService.generateKey(id, expiration)
);
this.setPubKey(key);
return key;
} catch (e) {
console.error('couldnt generate pubkey')
}
}
async getPubKey(id: string) {
try {
const { key } = await this.withLoader(() => spotService.getKey(id));
this.setPubKey(key);
} catch (e) {
console.error('no pubkey', e)
}
}
}

View file

@ -4,7 +4,7 @@ import { Duration } from 'luxon';
const HASH_MOD = 1610612741;
const HASH_P = 53;
function hashString(s: string): number {
export function hashString(s: string): number {
let mul = 1;
let hash = 0;
for (let i = 0; i < s.length; i++) {

View file

@ -0,0 +1,34 @@
import { resentOrDate, shortDurationFromMs } from "App/date";
import { makeAutoObservable } from "mobx";
export class Spot {
thumbnail: string;
title: string;
createdAt: string;
user: string;
duration: string;
spotId: string;
mobURL?: string;
videoURL?: string;
streamFile?: string;
comments?: { user: string, text: string, createdAt: string }[] = []
/** public access key to add to url */
key?: { key: string, expirationDate: string } | null = null
constructor(data: Record<string, any>) {
makeAutoObservable(this)
this.setAdditionalData(data)
this.comments = data.comments ?? [];
this.thumbnail = data.previewURL
this.title = data.name;
this.createdAt = resentOrDate(new Date(data.createdAt).getTime());
this.user = data.userEmail;
this.duration = shortDurationFromMs(data.duration);
this.spotId = data.id
}
setAdditionalData(data: Record<string, any>) {
Object.assign(this, data)
}
}

View file

@ -6,15 +6,16 @@ import { PlaySessionInFullscreenShortcut } from 'Components/Session_/Player/Cont
interface IProps {
size: number;
onClick: () => void;
customClasses: string;
customClasses?: string;
noShortcut?: boolean;
}
export function FullScreenButton({ size = 18, onClick }: IProps) {
export function FullScreenButton({ size = 18, onClick, noShortcut }: IProps) {
return (
<Popover
content={
<div className={'flex gap-2 items-center'}>
<PlaySessionInFullscreenShortcut />
{!noShortcut ? <PlaySessionInFullscreenShortcut /> : null}
<div>Play In Fullscreen</div>
</div>
}

View file

@ -53,6 +53,7 @@ export const setQueryParams = (location: Location, params: Record<string, any>):
};
export const login = (): string => '/login';
export const spotLogin = (): string => '/spot-login';
export const signup = (): string => '/signup';
export const forgotPassword = (): string => '/reset-password';
@ -141,6 +142,9 @@ export const usabilityTestingCreate = () => usabilityTesting() + '/create';
export const usabilityTestingEdit = (id = ':testId', hash?: string | number): string => hashed(`/usability-testing/edit/${id}`, hash);
export const usabilityTestingView = (id = ':testId', hash?: string | number): string => hashed(`/usability-testing/view/${id}`, hash);
export const spotsList = (): string => '/spots';
export const spot = (id = ':spotId', hash?: string | number): string => hashed(`/view-spot/${id}`, hash);
const REQUIRED_SITE_ID_ROUTES = [
liveSession(''),
session(''),

View file

@ -1,21 +1,24 @@
import DashboardService from './DashboardService';
import MetricService from './MetricService';
import FunnelService from './FunnelService';
import SessionService from './SessionService';
import UserService from './UserService';
import AiService from 'App/services/AiService';
import FFlagsService from 'App/services/FFlagsService';
import TagWatchService from 'App/services/TagWatchService';
import AlertsService from './AlertsService';
import AssistStatsService from './AssistStatsService';
import AuditService from './AuditService';
import ConfigService from './ConfigService';
import DashboardService from './DashboardService';
import ErrorService from './ErrorService';
import FunnelService from './FunnelService';
import HealthService from './HealthService';
import MetricService from './MetricService';
import NotesService from './NotesService';
import RecordingsService from './RecordingsService';
import ConfigService from './ConfigService';
import AlertsService from './AlertsService';
import WebhookService from './WebhookService';
import HealthService from './HealthService';
import FFlagsService from 'App/services/FFlagsService';
import AssistStatsService from './AssistStatsService';
import SessionService from './SessionService';
import UserService from './UserService';
import UxtestingService from './UxtestingService';
import TagWatchService from 'App/services/TagWatchService';
import AiService from "App/services/AiService";
import WebhookService from './WebhookService';
import SpotService from './spotService';
import LoginService from "./loginService";
export const dashboardService = new DashboardService();
export const metricService = new MetricService();
@ -29,18 +32,14 @@ export const recordingsService = new RecordingsService();
export const configService = new ConfigService();
export const alertsService = new AlertsService();
export const webhookService = new WebhookService();
export const healthService = new HealthService();
export const fflagsService = new FFlagsService();
export const assistStatsService = new AssistStatsService();
export const uxtestingService = new UxtestingService();
export const tagWatchService = new TagWatchService();
export const aiService = new AiService();
export const spotService = new SpotService();
export const loginService = new LoginService();
export const services = [
dashboardService,
@ -61,4 +60,6 @@ export const services = [
uxtestingService,
tagWatchService,
aiService,
spotService,
loginService,
];

View file

@ -0,0 +1,19 @@
import BaseService from "./BaseService";
export default class LoginService extends BaseService {
public async spotLogin({ email, password, captchaResponse }: { email: string, password: string, captchaResponse?: string }) {
return this.client.post('/spot/login', {
email: email.trim(),
password,
'g-recaptcha-response': captchaResponse,
})
.then((r) => {
if (r.ok) {
return r.json();
}
})
.catch((e) => {
throw e;
});
}
}

View file

@ -0,0 +1,101 @@
import BaseService from "./BaseService";
export interface SpotInfo {
name: string;
duration: number;
comments: SpotComment[];
mobURL: string;
videoURL: string;
createdAt: string;
userID: number;
}
export interface SpotComment {
user: string;
text: string;
createdAt: string;
}
interface GetSpotResponse {
spot: SpotInfo;
}
export interface UpdateSpotRequest {
name?: string;
/** timestamp of public key expiration */
keyExpiration?: number;
}
interface AddCommentRequest {
userName: string;
comment: string;
}
interface GetSpotsResponse {
spots: SpotInfo[];
total: number;
}
interface GetSpotsRequest {
query?: string;
filterBy: "own" | "all" | "shared";
/** @default desc, order by created date */
order: "asc" | "desc";
page: number;
limit: number;
}
export default class SpotService extends BaseService {
async fetchSpots(filters: GetSpotsRequest): Promise<GetSpotsResponse> {
return this.client.get('/spot/v1/spots', filters)
.then(r => r.json())
.catch(console.error)
}
async fetchSpot(id: string, accessKey?: string): Promise<GetSpotResponse> {
return this.client.get(`/spot/v1/spots/${id}${accessKey ? `?key=${accessKey}` : ''}`)
.then(r => r.json())
.catch(console.error)
}
async updateSpot(id: string, filter: UpdateSpotRequest) {
return this.client.patch(`/spot/v1/spots/${id}`, filter)
.then(r => r.json())
.catch(console.error)
}
async deleteSpot(spotIDs: string[]) {
return this.client.delete(`/spot/v1/spots`, {
spotIDs
})
.then(r => r.json())
.catch(console.error)
}
async addComment(id: string, data: AddCommentRequest) {
return this.client.post(`/spot/v1/spots/${id}/comment`, data)
.then(r => r.json())
.catch(console.error)
}
async getVideo(id:string) {
return this.client.get(`/spot/v1/spots/${id}/video`)
.then(r => r.json())
.catch(console.error)
}
/**
* @param id - spot id string
* @param expiration - in seconds, 0 if removing
* */
async generateKey(id: string, expiration: number): Promise<{ key: { value: string, expiration: number }}> {
return this.client.patch(`/spot/v1/spots/${id}/public-key`, { expiration })
.then(r => r.json())
.catch(console.error)
}
async getKey(id: string): Promise<{ key: { value: string, expiration: number }}> {
return this.client.get(`/spot/v1/spots/${id}/public-key`)
.then(r => r.json())
.catch(console.error)
}
}

View file

@ -0,0 +1,22 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="orSpot">
<path id="Vector 3" d="M3.3125 22.6568V1.93646L21.3807 12.2138L3.3125 22.6568Z" fill="white"/>
<path id="Combined-Shape" d="M19.6572 11.9819L4.37603 3.01376V20.9501L19.6572 11.9819ZM21.7424 10.1281C22.3994 10.5087 22.8048 11.216 22.8048 11.9819C22.8048 12.7479 22.3994 13.4552 21.7424 13.8358L4.98948 23.6696C3.62039 24.474 1.74316 23.555 1.74316 21.8158V2.14811C1.74316 0.408813 3.62039 -0.510111 4.98948 0.294281L21.7424 10.1281Z" fill="#122AF5"/>
<path id="Path-Copy" d="M13.3606 11.4876C13.5378 11.5891 13.6471 11.7777 13.6471 11.9819C13.6471 12.1862 13.5378 12.3748 13.3606 12.4763L8.84233 15.0986C8.47309 15.3131 7.9668 15.0681 7.9668 14.6043V9.35957C7.9668 8.89576 8.47309 8.65071 8.84233 8.86522L13.3606 11.4876Z" fill="#3EAAAF"/>
<g id="Ellipse 4" filter="url(#filter0_d_239_4096)">
<ellipse cx="13.9645" cy="5.62884" rx="3.1579" ry="3.15789" fill="#CC0000"/>
<ellipse cx="13.9645" cy="5.62884" rx="3.1579" ry="3.15789" stroke="white" stroke-width="1.50147"/>
</g>
</g>
<defs>
<filter id="filter0_d_239_4096" x="10.0557" y="1.72021" width="7.81738" height="9.06726" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.25"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_239_4096"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_239_4096" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -40,6 +40,7 @@
"country-flag-icons": "^1.5.7",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"hls.js": "^1.5.13",
"html-to-image": "^1.9.0",
"html2canvas": "^1.4.1",
"immutable": "^4.0.0-rc.12",
@ -86,7 +87,8 @@
"semantic-ui-react": "^2.1.2",
"socket.io-client": "^4.4.1",
"source-map": "^0.7.3",
"syncod": "^0.0.1"
"syncod": "^0.0.1",
"video.js": "^8.16.1"
},
"devDependencies": {
"@babel/cli": "^7.23.0",
@ -164,10 +166,10 @@
"ts-jest": "^29.0.5",
"ts-node": "^10.7.0",
"typescript": "^4.6.4",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.9.1",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.0",
"webpack": "^5.92.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4",
"workbox-webpack-plugin": "^6.5.1"
},
"engines": {

View file

@ -53,7 +53,7 @@ const config: Configuration = {
},
{
test: /\.css$/i,
exclude: /node_modules/,
// exclude: /node_modules/,
use: [
stylesHandler,
{

File diff suppressed because it is too large Load diff