Spots UI (#2385)
* feat ui: login flow for spot extension * spot list, store and service created * some fixing for header * start work on single spot * spot player start * header for player, comments, icons, etc * split stuff into compoennts, create player state manager * player controls, activity panel etc etc * comments, empty page, rename and stuff * interval buttons etc * access modal * pubkey support * fix tooltip * limit 10 -> 9 * hls lib instead of videojs * some warnings * fix date display for exp * change public links * display more client data * fix cleaning, init comment * map network to replay player network ev * stream support, console panel, close panels on X * fixing streaming, destroy on leave * fix autoplay * show notification on spot login * fix spot login * backup player added, fix audio issue * show thumbnail when no video, add spot roles * add poster thumbnail * some fixes to video check * fix events jump * fix play btn * try catch over pubkey * icons * spot login pinging * move spot login flow to login comp, use separate spot login path for unique jwt * invalidate spot jwt on logout * add visual data on page load event * typo fix * issue to copy change * share spot url f
This commit is contained in:
parent
42eb4b5040
commit
b17c3ab8d7
66 changed files with 3882 additions and 435 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
BIN
frontend/app/assets/img/spot1.jpg
Normal file
BIN
frontend/app/assets/img/spot1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/app/assets/img/spot2.jpg
Normal file
BIN
frontend/app/assets/img/spot2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ function ReplayWindow({ videoURL, userDevice, screenHeight, screenWidth, isAndro
|
|||
});
|
||||
|
||||
videoRef.current = videoEl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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'}>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@
|
|||
border: 1px solid $gray-lighter;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
258
frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx
Normal file
258
frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx
Normal 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))
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
58
frontend/app/components/Spots/SpotPlayer/components/Vjs.tsx
Normal file
58
frontend/app/components/Spots/SpotPlayer/components/Vjs.tsx
Normal 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;
|
||||
6
frontend/app/components/Spots/SpotPlayer/consts.ts
Normal file
6
frontend/app/components/Spots/SpotPlayer/consts.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const TABS = {
|
||||
COMMENTS: 'comments',
|
||||
ACTIVITY: 'activity',
|
||||
} as const;
|
||||
|
||||
export type Tab = (typeof TABS)[keyof typeof TABS];
|
||||
1
frontend/app/components/Spots/SpotPlayer/index.tsx
Normal file
1
frontend/app/components/Spots/SpotPlayer/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SpotPlayer';
|
||||
272
frontend/app/components/Spots/SpotPlayer/spotPlayerStore.ts
Normal file
272
frontend/app/components/Spots/SpotPlayer/spotPlayerStore.ts
Normal 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;
|
||||
58
frontend/app/components/Spots/SpotsList/EditItemModal.tsx
Normal file
58
frontend/app/components/Spots/SpotsList/EditItemModal.tsx
Normal 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;
|
||||
170
frontend/app/components/Spots/SpotsList/SpotListItem.tsx
Normal file
170
frontend/app/components/Spots/SpotsList/SpotListItem.tsx
Normal 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;
|
||||
241
frontend/app/components/Spots/SpotsList/index.tsx
Normal file
241
frontend/app/components/Spots/SpotsList/index.tsx
Normal 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))
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ function FetchTabs({ resource }: Props) {
|
|||
|
||||
useEffect(() => {
|
||||
const { request, response } = resource;
|
||||
console.log(resource, request, response)
|
||||
parseRequestResponse(
|
||||
request,
|
||||
setRequestHeaders,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
19
frontend/app/components/ui/Icons/orSpot.tsx
Normal file
19
frontend/app/components/ui/Icons/orSpot.tsx
Normal 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
|
|
@ -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' }
|
||||
]),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
87
frontend/app/mstore/loginStore.ts
Normal file
87
frontend/app/mstore/loginStore.ts
Normal 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;
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
|
||||
|
||||
import { notesService } from "App/services";
|
||||
import { Note, NotesFilter, WriteNote, iTag } from 'App/services/NotesService';
|
||||
|
||||
|
|
|
|||
166
frontend/app/mstore/spotStore.ts
Normal file
166
frontend/app/mstore/spotStore.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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++) {
|
||||
|
|
|
|||
34
frontend/app/mstore/types/spot.ts
Normal file
34
frontend/app/mstore/types/spot.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(''),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
19
frontend/app/services/loginService.ts
Normal file
19
frontend/app/services/loginService.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
101
frontend/app/services/spotService.ts
Normal file
101
frontend/app/services/spotService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
frontend/app/svg/icons/orSpot.svg
Normal file
22
frontend/app/svg/icons/orSpot.svg
Normal 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 |
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue