feat spot: init commit for extension (#2452)
* feat spot: init commit for extension * nvmrc * fix login flow * Spots Gridview Updates (#2422) * 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 * 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 * Various updates * Update SVG.tsx * Update SideMenu.tsx * SpotList & Menu updates * 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 * Spot List & Player Updates * 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 * Spot Listing improvements post review. * Update SpotListItem.tsx * Improved Spot List and Item Details * Minor improvements * More improvements * Public player header improvements * Moved formatExpirationTime to utils * fixes after merge --------- Co-authored-by: nick-delirium <nikita@openreplay.com> * set sso link to <a>? * some small perf fixes * login duck reformat... * Update frontend.yaml * add observer to spot list header * split list header * update spotjwt param in router * fix toast in router * fix async fetch, move ctx * capture space btn ev * fix header link * public sharing error msg * fix err msg for unsuccessful rec start * fix list alignment * Caching assets. Finally!!! * fix typing in comment field * add pubkey to comments, fix console jump btn * no content comp * change refresh token logic * move thumbnail ts * move thumbnail ts * fix tab change * switch up toggler * early exit if no jwt present * regenerate icons * fix location str * fix ctx * change thumnail res, return autoplay for video player * parse links in console rows, fix injected method parse? * remove ts from js * fix console parsing order? * fixes for autoplay * xray for spot player * move to spot list after login; esc to cancel; fix signup link; move ux commit * kb sc for skipping; xray for spot ext * track aborted requests * tooltip for readability * fixing empty state * New blank state + various minor improvements (#2471) * New blank state + various minor improvements * apres merge --------- Co-authored-by: nick-delirium <nikita@openreplay.com> * rm temp v * init or card * empty state debug * empty state debug * empty state debug * fix initor img * spotonly scope support * Improved Spot dead-end pages (#2475) * Improved Spot dead-end pages * Initiate OpenReplay Setup and some more * get scope changes * fix crash * scope upgrade/downgrade * scope setup flow * ping for backend * upgrade wxt deps * cancel ping int on expiration * check rec status * fix ping * check video processing state * check video processing state * fix xray close, network highlight, fcp rounding * update wxt, move open spot stuff to settings * fix some history issues * fix spot login flow * fix spot login again * fix spot login again * don't send two requests * limit messages for logged users * limit messages for logged users * fix public ignore * microphone stuff * microphone stuff * Various improvements (#2509) * Various improvements - Updated icons in mic settings - Included prefix in Spot title - Save recording notification has been updated - Other minor UI improvements * Inline declaration of spot name field, and settings UI * str f --------- Co-authored-by: nick-delirium <nikita@openreplay.com> * UI changes in player header, spot list (#2510) * Added UI elements in player page - Badge with counts for comments - Download and Delete dropdown in player - Spot selection -- UI improvement * Minor copy updates * completing changes --------- Co-authored-by: nick-delirium <nikita@openreplay.com> * rm cmt * fix cellmeasurer * thumbnail dur * fix download * Minor fixes (#2512) - Spot delete confirmation - Spot comments UI update - Minor copy updates * limit number of notif messages * add spot title to doc title, add cache groups for webpack * drop mic controls from recording popup view * fix for webpack compress * fix for auto mic pickup * change status banners * move svgs around, remove undefined check * refactor svgs * fix timetable scaling * fix error popup * self contain css * pre-select spot on spot onboarding --------- Co-authored-by: Sudheer Salavadi <connect.uxmaster@gmail.com> Co-authored-by: Rajesh Rajendran <rjshrjndrn@users.noreply.github.com>
8
.github/workflows/frontend.yaml
vendored
|
|
@ -19,10 +19,12 @@ jobs:
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: |
|
||||||
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
|
/home/runner/work/openreplay/openreplay/frontend/node_modules
|
||||||
|
/home/runner/work/openreplay/openreplay/frontend/.yarn
|
||||||
|
key: ${{ runner.OS }}-build-${{ hashFiles('frontend/yarn.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.OS }}-build-
|
${{ runner.OS }}-build-
|
||||||
${{ runner.OS }}-
|
${{ runner.OS }}-
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Loader } from 'UI';
|
||||||
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
||||||
|
|
||||||
import APIClient from './api_client';
|
import APIClient from './api_client';
|
||||||
|
import { getScope } from "./duck/user";
|
||||||
import * as routes from './routes';
|
import * as routes from './routes';
|
||||||
import { OB_DEFAULT_TAB } from 'App/routes';
|
import { OB_DEFAULT_TAB } from 'App/routes';
|
||||||
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
|
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
|
||||||
|
|
@ -28,6 +29,7 @@ const components: any = {
|
||||||
UsabilityTestOverviewPure: lazy(() => import('Components/UsabilityTesting/TestOverview')),
|
UsabilityTestOverviewPure: lazy(() => import('Components/UsabilityTesting/TestOverview')),
|
||||||
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
|
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
|
||||||
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
|
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
|
||||||
|
ScopeSetup: lazy(() => import('Components/ScopeForm')),
|
||||||
};
|
};
|
||||||
|
|
||||||
const enhancedComponents: any = {
|
const enhancedComponents: any = {
|
||||||
|
|
@ -47,6 +49,7 @@ const enhancedComponents: any = {
|
||||||
UsabilityTestOverview: withSiteIdUpdater(components.UsabilityTestOverviewPure),
|
UsabilityTestOverview: withSiteIdUpdater(components.UsabilityTestOverviewPure),
|
||||||
SpotsList: withSiteIdUpdater(components.SpotsListPure),
|
SpotsList: withSiteIdUpdater(components.SpotsListPure),
|
||||||
Spot: components.SpotPure,
|
Spot: components.SpotPure,
|
||||||
|
ScopeSetup: components.ScopeSetup
|
||||||
};
|
};
|
||||||
|
|
||||||
const withSiteId = routes.withSiteId;
|
const withSiteId = routes.withSiteId;
|
||||||
|
|
@ -92,6 +95,7 @@ const USABILITY_TESTING_VIEW_PATH = routes.usabilityTestingView();
|
||||||
|
|
||||||
const SPOTS_LIST_PATH = routes.spotsList();
|
const SPOTS_LIST_PATH = routes.spotsList();
|
||||||
const SPOT_PATH = routes.spot();
|
const SPOT_PATH = routes.spot();
|
||||||
|
const SCOPE_SETUP = routes.scopeSetup();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isEnterprise: boolean;
|
isEnterprise: boolean;
|
||||||
|
|
@ -100,6 +104,7 @@ interface Props {
|
||||||
jwt: string;
|
jwt: string;
|
||||||
sites: Map<string, any>;
|
sites: Map<string, any>;
|
||||||
onboarding: boolean;
|
onboarding: boolean;
|
||||||
|
spotOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrivateRoutes(props: Props) {
|
function PrivateRoutes(props: Props) {
|
||||||
|
|
@ -107,6 +112,7 @@ function PrivateRoutes(props: Props) {
|
||||||
const redirectToOnboarding =
|
const redirectToOnboarding =
|
||||||
!onboarding && localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true';
|
!onboarding && localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true';
|
||||||
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
|
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
|
||||||
<Switch key="content">
|
<Switch key="content">
|
||||||
|
|
@ -115,149 +121,157 @@ function PrivateRoutes(props: Props) {
|
||||||
path={withSiteId(ONBOARDING_PATH, siteIdList)}
|
path={withSiteId(ONBOARDING_PATH, siteIdList)}
|
||||||
component={enhancedComponents.Onboarding}
|
component={enhancedComponents.Onboarding}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/integrations/"
|
|
||||||
render={({ location }) => {
|
|
||||||
const client = new APIClient();
|
|
||||||
switch (location.pathname) {
|
|
||||||
case '/integrations/slack':
|
|
||||||
client.post('integrations/slack/add', {
|
|
||||||
code: location.search.split('=')[1],
|
|
||||||
state: props.tenantId,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case '/integrations/msteams':
|
|
||||||
client.post('integrations/msteams/add', {
|
|
||||||
code: location.search.split('=')[1],
|
|
||||||
state: props.tenantId,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return <Redirect to={CLIENT_PATH} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{redirectToOnboarding && <Redirect to={withSiteId(ONBOARDING_REDIRECT_PATH, siteId)} />}
|
|
||||||
|
|
||||||
{/* DASHBOARD and Metrics */}
|
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
strict
|
strict
|
||||||
path={[
|
path={SPOTS_LIST_PATH}
|
||||||
withSiteId(ALERTS_PATH, siteIdList),
|
|
||||||
withSiteId(ALERT_EDIT_PATH, siteIdList),
|
|
||||||
withSiteId(ALERT_CREATE_PATH, siteIdList),
|
|
||||||
withSiteId(METRICS_PATH, siteIdList),
|
|
||||||
withSiteId(METRICS_DETAILS, siteIdList),
|
|
||||||
withSiteId(METRICS_DETAILS_SUB, siteIdList),
|
|
||||||
withSiteId(DASHBOARD_PATH, siteIdList),
|
|
||||||
withSiteId(DASHBOARD_SELECT_PATH, siteIdList),
|
|
||||||
withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList),
|
|
||||||
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList),
|
|
||||||
]}
|
|
||||||
component={enhancedComponents.Dashboard}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(USABILITY_TESTING_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.UsabilityTesting}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(USABILITY_TESTING_EDIT_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.UsabilityTestEdit}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(USABILITY_TESTING_VIEW_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.UsabilityTestOverview}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
path={withSiteId(MULTIVIEW_INDEX_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.Multiview}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={withSiteId(MULTIVIEW_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.Multiview}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(ASSIST_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.Assist}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(RECORDINGS_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.Assist}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(FUNNEL_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.FunnelPage}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(FUNNEL_CREATE_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.FunnelsDetails}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(FUNNEL_ISSUE_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.FunnelIssue}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={[
|
|
||||||
withSiteId(SESSIONS_PATH, siteIdList),
|
|
||||||
withSiteId(FFLAGS_PATH, siteIdList),
|
|
||||||
withSiteId(FFLAG_PATH, siteIdList),
|
|
||||||
withSiteId(FFLAG_READ_PATH, siteIdList),
|
|
||||||
withSiteId(FFLAG_CREATE_PATH, siteIdList),
|
|
||||||
withSiteId(NOTES_PATH, siteIdList),
|
|
||||||
withSiteId(BOOKMARKS_PATH, siteIdList),
|
|
||||||
]}
|
|
||||||
component={enhancedComponents.SessionsOverview}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(SESSION_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.Session}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.LiveSession}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
strict
|
|
||||||
path={withSiteId(SPOTS_LIST_PATH, siteIdList)}
|
|
||||||
component={enhancedComponents.SpotsList}
|
component={enhancedComponents.SpotsList}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
strict
|
strict
|
||||||
path={withSiteId(SPOT_PATH, siteIdList)}
|
path={SPOT_PATH}
|
||||||
component={enhancedComponents.Spot}
|
component={enhancedComponents.Spot}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={SCOPE_SETUP}
|
||||||
|
component={enhancedComponents.ScopeSetup}
|
||||||
|
/>
|
||||||
|
{props.spotOnly ? null : <>
|
||||||
|
<Route
|
||||||
|
path="/integrations/"
|
||||||
|
render={({ location }) => {
|
||||||
|
const client = new APIClient();
|
||||||
|
switch (location.pathname) {
|
||||||
|
case "/integrations/slack":
|
||||||
|
client.post("integrations/slack/add", {
|
||||||
|
code: location.search.split("=")[1],
|
||||||
|
state: props.tenantId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "/integrations/msteams":
|
||||||
|
client.post("integrations/msteams/add", {
|
||||||
|
code: location.search.split("=")[1],
|
||||||
|
state: props.tenantId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return <Redirect to={CLIENT_PATH} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{redirectToOnboarding && <Redirect to={withSiteId(ONBOARDING_REDIRECT_PATH, siteId)} />}
|
||||||
|
|
||||||
{Object.entries(routes.redirects).map(([fr, to]) => (
|
{/* DASHBOARD and Metrics */}
|
||||||
<Redirect key={fr} exact strict from={fr} to={to} />
|
<Route
|
||||||
))}
|
exact
|
||||||
<AdditionalRoutes redirect={withSiteId(routes.sessions(), siteId)} />
|
strict
|
||||||
|
path={[
|
||||||
|
withSiteId(ALERTS_PATH, siteIdList),
|
||||||
|
withSiteId(ALERT_EDIT_PATH, siteIdList),
|
||||||
|
withSiteId(ALERT_CREATE_PATH, siteIdList),
|
||||||
|
withSiteId(METRICS_PATH, siteIdList),
|
||||||
|
withSiteId(METRICS_DETAILS, siteIdList),
|
||||||
|
withSiteId(METRICS_DETAILS_SUB, siteIdList),
|
||||||
|
withSiteId(DASHBOARD_PATH, siteIdList),
|
||||||
|
withSiteId(DASHBOARD_SELECT_PATH, siteIdList),
|
||||||
|
withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList),
|
||||||
|
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList)
|
||||||
|
]}
|
||||||
|
component={enhancedComponents.Dashboard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(USABILITY_TESTING_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.UsabilityTesting}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(USABILITY_TESTING_EDIT_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.UsabilityTestEdit}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(USABILITY_TESTING_VIEW_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.UsabilityTestOverview}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={withSiteId(MULTIVIEW_INDEX_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.Multiview}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={withSiteId(MULTIVIEW_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.Multiview}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(ASSIST_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.Assist}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(RECORDINGS_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.Assist}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(FUNNEL_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.FunnelPage}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(FUNNEL_CREATE_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.FunnelsDetails}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(FUNNEL_ISSUE_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.FunnelIssue}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={[
|
||||||
|
withSiteId(SESSIONS_PATH, siteIdList),
|
||||||
|
withSiteId(FFLAGS_PATH, siteIdList),
|
||||||
|
withSiteId(FFLAG_PATH, siteIdList),
|
||||||
|
withSiteId(FFLAG_READ_PATH, siteIdList),
|
||||||
|
withSiteId(FFLAG_CREATE_PATH, siteIdList),
|
||||||
|
withSiteId(NOTES_PATH, siteIdList),
|
||||||
|
withSiteId(BOOKMARKS_PATH, siteIdList)
|
||||||
|
]}
|
||||||
|
component={enhancedComponents.SessionsOverview}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(SESSION_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.Session}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.LiveSession}
|
||||||
|
/>
|
||||||
|
{Object.entries(routes.redirects).map(([fr, to]) => (
|
||||||
|
<Redirect key={fr} exact strict from={fr} to={to} />
|
||||||
|
))}
|
||||||
|
<AdditionalRoutes redirect={withSiteId(routes.sessions(), siteId)} />
|
||||||
|
</>}
|
||||||
|
{props.spotOnly ? <Redirect to={withSiteId(SPOTS_LIST_PATH, siteId)} /> : null}
|
||||||
</Switch>
|
</Switch>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
@ -269,6 +283,7 @@ export default connect((state: any) => ({
|
||||||
sites: state.getIn(['site', 'list']),
|
sites: state.getIn(['site', 'list']),
|
||||||
siteId: state.getIn(['site', 'siteId']),
|
siteId: state.getIn(['site', 'siteId']),
|
||||||
jwt: state.getIn(['user', 'jwt']),
|
jwt: state.getIn(['user', 'jwt']),
|
||||||
|
spotOnly: getScope(state) === 'spot',
|
||||||
tenantId: state.getIn(['user', 'account', 'tenantId']),
|
tenantId: state.getIn(['user', 'account', 'tenantId']),
|
||||||
isEnterprise:
|
isEnterprise:
|
||||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function PublicRoutes(props: Props) {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact strict path={SPOT_PATH} component={Spot} />
|
<Route exact strict path={SPOT_PATH} component={Spot} />
|
||||||
<Route exact strict path={FORGOT_PASSWORD} component={ForgotPassword} />
|
<Route exact strict path={FORGOT_PASSWORD} component={ForgotPassword} />
|
||||||
<Route exact strict path={LOGIN_PATH} component={props.changePassword ? UpdatePassword : Login} />
|
<Route exact strict path={LOGIN_PATH} component={Login} />
|
||||||
<Route exact strict path={SIGNUP_PATH} component={Signup} />
|
<Route exact strict path={SIGNUP_PATH} component={Signup} />
|
||||||
<Redirect to={LOGIN_PATH} />
|
<Redirect to={LOGIN_PATH} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import PublicRoutes from 'App/PublicRoutes';
|
||||||
import {
|
import {
|
||||||
GLOBAL_DESTINATION_PATH,
|
GLOBAL_DESTINATION_PATH,
|
||||||
IFRAME,
|
IFRAME,
|
||||||
JWT_PARAM,
|
JWT_PARAM, SPOT_ONBOARDING
|
||||||
} from 'App/constants/storageKeys';
|
} from "App/constants/storageKeys";
|
||||||
import Layout from 'App/layout/Layout';
|
import Layout from 'App/layout/Layout';
|
||||||
import { withStore } from "App/mstore";
|
import { withStore } from "App/mstore";
|
||||||
import { checkParam } from 'App/utils';
|
import { checkParam } from 'App/utils';
|
||||||
|
|
@ -23,7 +23,9 @@ import { init as initSite } from 'Duck/site';
|
||||||
import { fetchUserInfo, setJwt } from 'Duck/user';
|
import { fetchUserInfo, setJwt } from 'Duck/user';
|
||||||
import { fetchTenants } from 'Duck/user';
|
import { fetchTenants } from 'Duck/user';
|
||||||
import { Loader } from 'UI';
|
import { Loader } from 'UI';
|
||||||
|
import { spotsList } from "./routes";
|
||||||
import * as routes from './routes';
|
import * as routes from './routes';
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
|
||||||
interface RouterProps
|
interface RouterProps
|
||||||
extends RouteComponentProps,
|
extends RouteComponentProps,
|
||||||
|
|
@ -43,9 +45,11 @@ interface RouterProps
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
mstore: any;
|
mstore: any;
|
||||||
setJwt: (jwt: string) => any;
|
setJwt: (params: { jwt: string, spotJwt: string | null }) => any;
|
||||||
fetchMetadata: (siteId: string) => void;
|
fetchMetadata: (siteId: string) => void;
|
||||||
initSite: (site: any) => void;
|
initSite: (site: any) => void;
|
||||||
|
scopeSetup: boolean;
|
||||||
|
localSpotJwt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Router: React.FC<RouterProps> = (props) => {
|
const Router: React.FC<RouterProps> = (props) => {
|
||||||
|
|
@ -58,18 +62,62 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
fetchSiteList,
|
fetchSiteList,
|
||||||
history,
|
history,
|
||||||
match: {
|
|
||||||
params: { siteId: siteIdFromPath },
|
|
||||||
},
|
|
||||||
setSessionPath,
|
setSessionPath,
|
||||||
|
scopeSetup,
|
||||||
|
localSpotJwt,
|
||||||
} = props;
|
} = props;
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const spotCb = params.get('spotCallback');
|
||||||
|
const spotReqSent = React.useRef(false)
|
||||||
const [isIframe, setIsIframe] = React.useState(false);
|
const [isIframe, setIsIframe] = React.useState(false);
|
||||||
const [isJwt, setIsJwt] = React.useState(false);
|
const [isJwt, setIsJwt] = React.useState(false);
|
||||||
const handleJwtFromUrl = () => {
|
const handleJwtFromUrl = () => {
|
||||||
const urlJWT = new URLSearchParams(location.search).get('jwt');
|
const params = new URLSearchParams(location.search)
|
||||||
if (urlJWT) {
|
const urlJWT = params.get('jwt');
|
||||||
props.setJwt(urlJWT);
|
const spotJwt = params.get('spotJwt');
|
||||||
|
if (spotJwt) {
|
||||||
|
handleSpotLogin(spotJwt);
|
||||||
}
|
}
|
||||||
|
if (urlJWT) {
|
||||||
|
props.setJwt({ jwt: urlJWT, spotJwt: spotJwt ?? null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpotLogin = (jwt: string) => {
|
||||||
|
if (spotReqSent.current) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
spotReqSent.current = true;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 handleDestinationPath = () => {
|
const handleDestinationPath = () => {
|
||||||
|
|
@ -123,8 +171,20 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevIsLoggedIn !== isLoggedIn && isLoggedIn) {
|
if (prevIsLoggedIn !== isLoggedIn && isLoggedIn) {
|
||||||
handleUserLogin();
|
handleUserLogin();
|
||||||
|
if (scopeSetup) {
|
||||||
|
history.push(routes.scopeSetup())
|
||||||
|
} else if (spotCb) {
|
||||||
|
history.push(spotsList())
|
||||||
|
localStorage.setItem(SPOT_ONBOARDING, 'true')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn, scopeSetup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn && location.pathname.includes('login') && localSpotJwt) {
|
||||||
|
handleSpotLogin(localSpotJwt);
|
||||||
|
}
|
||||||
|
}, [location, isLoggedIn, localSpotJwt])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
|
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
|
||||||
|
|
@ -153,7 +213,9 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
location.pathname.includes('/assist/') ||
|
location.pathname.includes('/assist/') ||
|
||||||
location.pathname.includes('multiview') ||
|
location.pathname.includes('multiview') ||
|
||||||
location.pathname.includes('/view-spot/') ||
|
location.pathname.includes('/view-spot/') ||
|
||||||
location.pathname.includes('/spots/');
|
location.pathname.includes('/spots/') ||
|
||||||
|
location.pathname.includes('/scope-setup')
|
||||||
|
|
||||||
|
|
||||||
if (isIframe) {
|
if (isIframe) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -164,7 +226,7 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
return isLoggedIn ? (
|
return isLoggedIn ? (
|
||||||
<NewModalProvider>
|
<NewModalProvider>
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
<Loader loading={loading || !siteId} className="flex-1">
|
<Loader loading={loading} className="flex-1">
|
||||||
<Layout hideHeader={hideHeader} siteId={siteId}>
|
<Layout hideHeader={hideHeader} siteId={siteId}>
|
||||||
<PrivateRoutes />
|
<PrivateRoutes />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
@ -186,14 +248,17 @@ const mapStateToProps = (state: Map<string, any>) => {
|
||||||
'loading',
|
'loading',
|
||||||
]);
|
]);
|
||||||
const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']);
|
const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']);
|
||||||
|
const scopeSetup = state.getIn(['user', 'scopeSetup'])
|
||||||
|
const loading = Boolean(userInfoLoading) || Boolean(sitesLoading)
|
||||||
return {
|
return {
|
||||||
siteId,
|
siteId,
|
||||||
changePassword,
|
changePassword,
|
||||||
sites: state.getIn(['site', 'list']),
|
sites: state.getIn(['site', 'list']),
|
||||||
jwt,
|
jwt,
|
||||||
|
localSpotJwt: state.getIn(['user', 'spotJwt']),
|
||||||
isLoggedIn: jwt !== null && !changePassword,
|
isLoggedIn: jwt !== null && !changePassword,
|
||||||
loading: siteId === null || userInfoLoading || sitesLoading,
|
scopeSetup,
|
||||||
|
loading,
|
||||||
email: state.getIn(['user', 'account', 'email']),
|
email: state.getIn(['user', 'account', 'email']),
|
||||||
account: state.getIn(['user', 'account']),
|
account: state.getIn(['user', 'account']),
|
||||||
organisation: state.getIn(['user', 'account', 'name']),
|
organisation: state.getIn(['user', 'account', 'name']),
|
||||||
|
|
|
||||||
|
|
@ -201,11 +201,11 @@ export default class APIClient {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const refreshedJwt = data.jwt;
|
const refreshedJwt = data.jwt;
|
||||||
store.dispatch(setJwt(refreshedJwt));
|
store.dispatch(setJwt({ jwt: refreshedJwt, }));
|
||||||
return refreshedJwt;
|
return refreshedJwt;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing token:', error);
|
console.error('Error refreshing token:', error);
|
||||||
store.dispatch(setJwt(null));
|
store.dispatch(setJwt({ jwt: null }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
frontend/app/assets/img/chrome.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="1.53 1.5 21 21">
|
||||||
|
<path d="M12.0005 1.5029C12.0005 1.5029 18.1896 1.22615 21.471 7.42495H11.4739C11.4739 7.42495 9.58723 7.36436 7.97561 9.64499C7.51266 10.6022 7.01503 11.5883 7.57347 13.5316C6.76901 12.1735 3.30263 6.15929 3.30263 6.15929C3.30263 6.15929 5.74763 1.74796 12.0004 1.5029H12.0005Z" fill="#EF3F36"/>
|
||||||
|
<path d="M21.1497 17.2392C21.1497 17.2392 18.2938 22.7201 11.2684 22.4491C12.1365 20.9527 16.2684 13.8227 16.2684 13.8227C16.2684 13.8227 17.2666 12.2254 16.089 9.69398C15.49 8.81461 14.8794 7.89488 12.9119 7.40468C14.4947 7.39036 21.4535 7.40468 21.4535 7.40468C21.4535 7.40468 24.0605 11.7209 21.1497 17.2392Z" fill="#FCD900"/>
|
||||||
|
<path d="M2.89453 17.2825C2.89453 17.2825 -0.441698 12.0784 3.30826 6.15057C4.17344 7.64698 8.30533 14.7771 8.30533 14.7771C8.30533 14.7771 9.19656 16.4378 11.983 16.6857C13.045 16.6079 14.1502 16.5415 15.5623 15.0913C14.7839 16.4638 11.2913 22.4608 11.2913 22.4608C11.2913 22.4608 6.23355 22.553 2.89445 17.2825H2.89453Z" fill="#61BC5B"/>
|
||||||
|
<path d="M11.2656 22.501L12.6718 16.654C12.6718 16.654 14.2169 16.5328 15.5133 15.1172C14.7088 16.5272 11.2656 22.501 11.2656 22.501V22.501Z" fill="#5AB055"/>
|
||||||
|
<path d="M7.28989 12.0668C7.28989 9.48931 9.38771 7.39899 11.9745 7.39899C14.5612 7.39899 16.659 9.48931 16.659 12.0668C16.659 14.6444 14.5612 16.7346 11.9745 16.7346C9.38771 16.7317 7.28989 14.6444 7.28989 12.0668V12.0668Z" fill="white"/>
|
||||||
|
<path d="M8.07399 12.0668C8.07399 9.92177 9.81882 8.1803 11.9745 8.1803C14.1272 8.1803 15.8749 9.9189 15.8749 12.0668C15.8749 14.2119 14.1302 15.9534 11.9745 15.9534C9.8217 15.9534 8.07399 14.2119 8.07399 12.0668V12.0668Z" fill="url(#paint0_linear_1313_1054)"/>
|
||||||
|
<path d="M21.4506 7.40767L15.6607 9.1001C15.6607 9.1001 14.7869 7.82279 12.9091 7.40767C14.5381 7.39899 21.4506 7.40767 21.4506 7.40767V7.40767Z" fill="#EACA05"/>
|
||||||
|
<path d="M7.46065 13.3182C6.64748 11.9141 3.30263 6.15924 3.30263 6.15924L7.59081 10.386C7.59081 10.386 7.15094 11.2885 7.31594 12.5801L7.46056 13.3182H7.46065Z" fill="#DF3A32"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_1313_1054" x1="11.9743" y1="8.23518" x2="11.9743" y2="15.7194" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#86BBE5"/>
|
||||||
|
<stop offset="1" stop-color="#1072BA"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
13
frontend/app/assets/img/chromeStore.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<svg width="23" height="20" viewBox="0 0 23 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.1784 4.38745H9.12144C8.56244 4.38745 8.10944 3.93445 8.10944 3.37545C8.10944 2.81745 8.56244 2.36445 9.12144 2.36445H14.1784C14.7374 2.36445 15.1904 2.81745 15.1904 3.37545C15.1904 3.93445 14.7374 4.38745 14.1784 4.38745ZM0.523438 0.340454V18.0425C0.523438 18.8765 1.20644 19.5595 2.04044 19.5595H21.2594C22.0934 19.5595 22.7764 18.8765 22.7764 18.0425V0.340454H0.523438Z" fill="#EEEEEE"/>
|
||||||
|
<path d="M11.6504 8.4325C8.25838 8.4325 5.27738 10.1895 3.56738 12.8435V19.5585H8.25238L11.6504 13.6725H20.2114C18.6204 10.5625 15.3834 8.4325 11.6504 8.4325Z" fill="#DB4437"/>
|
||||||
|
<path d="M3.56453 12.8474C2.60053 14.3454 2.04053 16.1284 2.04053 18.0424C2.04053 18.5584 2.08153 19.0654 2.16053 19.5594H7.47853L3.56453 12.8474Z" fill="#0F9D58"/>
|
||||||
|
<path d="M21.2594 18.0425C21.2594 16.4695 20.8814 14.9845 20.2114 13.6735H11.6504L15.0484 19.5595H21.1394C21.2184 19.0655 21.2594 18.5585 21.2594 18.0425Z" fill="#FFCD40"/>
|
||||||
|
<path d="M11.6505 13.6735C9.23747 13.6735 7.28247 15.6295 7.28247 18.0425C7.28247 18.5765 7.37847 19.0875 7.55347 19.5595H8.50147C8.27947 19.1005 8.15547 18.5855 8.15547 18.0425C8.15547 16.1115 9.72047 14.5475 11.6505 14.5475C13.5805 14.5475 15.1455 16.1115 15.1455 18.0425C15.1455 18.5865 15.0215 19.1005 14.7995 19.5595H15.7475C15.9225 19.0865 16.0185 18.5755 16.0185 18.0425C16.0185 15.6295 14.0625 13.6735 11.6505 13.6735Z" fill="#F1F1F1"/>
|
||||||
|
<path d="M11.6505 14.5475C9.72052 14.5475 8.15552 16.1115 8.15552 18.0425C8.15552 18.5865 8.27952 19.1005 8.50152 19.5595H14.7995C15.0215 19.1005 15.1455 18.5855 15.1455 18.0425C15.1445 16.1115 13.5805 14.5475 11.6505 14.5475Z" fill="#4285F4"/>
|
||||||
|
<path opacity="0.05" d="M0.523438 0.340454V9.95045H22.7764V0.340454H0.523438ZM14.1784 4.38745H9.12144C8.56244 4.38745 8.10944 3.93445 8.10944 3.37545C8.10944 2.81745 8.56244 2.36445 9.12144 2.36445H14.1794C14.7384 2.36445 15.1914 2.81745 15.1914 3.37545C15.1904 3.93445 14.7374 4.38745 14.1784 4.38745Z" fill="#212121"/>
|
||||||
|
<path opacity="0.02" d="M22.7764 9.82349H0.523438V9.95049H22.7764V9.82349Z" fill="#212121"/>
|
||||||
|
<path opacity="0.05" d="M22.7764 9.95044H0.523438V10.0774H22.7764V9.95044Z" fill="white"/>
|
||||||
|
<path opacity="0.02" d="M0.523438 0.340454V0.467454H22.7764V0.340454H0.523438ZM14.1784 4.38745H9.12144C8.58444 4.38745 8.14544 3.96745 8.11344 3.43945C8.11044 3.46045 8.10944 3.48145 8.10944 3.50245C8.10944 4.06145 8.56244 4.51345 9.12144 4.51345H14.1784C14.7374 4.51345 15.1904 4.06145 15.1904 3.50245C15.1904 3.48145 15.1894 3.46045 15.1864 3.43945C15.1544 3.96645 14.7164 4.38745 14.1784 4.38745Z" fill="#212121"/>
|
||||||
|
<path opacity="0.1" d="M21.2594 19.4324H2.04044C1.20644 19.4324 0.523438 18.7505 0.523438 17.9155V18.0424C0.523438 18.8764 1.20644 19.5595 2.04044 19.5595H21.2594C22.0934 19.5595 22.7764 18.8764 22.7764 18.0424V17.9155C22.7764 18.7505 22.0934 19.4324 21.2594 19.4324ZM9.12144 2.36345H14.1784C14.7154 2.36345 15.1544 2.78345 15.1864 3.31145C15.1874 3.29045 15.1904 3.26945 15.1904 3.24845C15.1904 2.68945 14.7374 2.23645 14.1784 2.23645H9.12144C8.56244 2.23645 8.10944 2.68945 8.10944 3.24845C8.10944 3.26945 8.11044 3.29045 8.11344 3.31145C8.14544 2.78345 8.58344 2.36345 9.12144 2.36345Z" fill="#231F20"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/app/assets/img/init-or.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
55
frontend/app/assets/img/spot1.svg
Normal file
|
After Width: | Height: | Size: 24 KiB |
25
frontend/app/assets/img/spot2.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<svg width="522" height="226" viewBox="0 0 522 226" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_595_7038)">
|
||||||
|
<path d="M-246 8C-246 3.85786 -242.642 0.5 -238.5 0.5H481.773V198.5C481.773 202.642 478.415 206 474.273 206H-238.5C-242.642 206 -246 202.642 -246 198.5V8Z" fill="white"/>
|
||||||
|
<rect x="-222.546" y="79.7954" width="486.682" height="46.9091" rx="23.4545" fill="#F1F3F4"/>
|
||||||
|
<path d="M241.056 99.8631L235.144 99.3501L232.835 93.914C232.419 92.9245 231.002 92.9245 230.587 93.914L228.278 99.3623L222.378 99.8631C221.303 99.9487 220.863 101.292 221.682 102.001L226.165 105.886L224.821 111.651C224.577 112.702 225.713 113.533 226.641 112.971L231.711 109.917L236.781 112.983C237.709 113.545 238.845 112.714 238.601 111.664L237.257 105.886L241.74 102.001C242.559 101.292 242.131 99.9487 241.056 99.8631V99.8631ZM231.711 107.632L227.118 110.405L228.339 105.177L224.284 101.659L229.634 101.195L231.711 96.2717L233.8 101.207L239.15 101.671L235.095 105.189L236.316 110.418L231.711 107.632Z" fill="#6B6C6E"/>
|
||||||
|
<rect x="287.591" y="79.4546" width="47.5909" height="47.5909" rx="23.7955" fill="#E2E4F6"/>
|
||||||
|
<path d="M303.526 116.571V90.6705L326.112 103.517L303.526 116.571Z" fill="white"/>
|
||||||
|
<path d="M323.957 103.227L304.856 92.0172V114.438L323.957 103.227ZM326.563 100.91C327.385 101.386 327.891 102.27 327.891 103.227C327.891 104.185 327.385 105.069 326.563 105.545L305.622 117.837C303.911 118.842 301.564 117.694 301.564 115.52V90.9351C301.564 88.761 303.911 87.6124 305.622 88.6179L326.563 100.91Z" fill="#122AF5"/>
|
||||||
|
<path d="M316.086 102.609C316.307 102.736 316.444 102.972 316.444 103.227C316.444 103.483 316.307 103.718 316.086 103.845L310.438 107.123C309.977 107.391 309.344 107.085 309.344 106.505V99.9494C309.344 99.3697 309.977 99.0634 310.438 99.3315L316.086 102.609Z" fill="#3EAAAF"/>
|
||||||
|
<ellipse cx="316.842" cy="95.286" rx="3.94737" ry="3.94737" fill="#CC0000" stroke="white" stroke-width="1.87683"/>
|
||||||
|
<path d="M389.309 101.576H387.743V94.6667H380.833V93.1005C380.833 91.5918 379.75 90.2214 378.253 90.0717C376.526 89.899 375.075 91.2464 375.075 92.9392V94.6667H368.177V101.346H369.777C371.286 101.346 372.656 102.36 372.944 103.834C373.324 105.814 371.816 107.565 369.892 107.565H368.165V114.244H374.844V112.632C374.844 111.123 375.858 109.753 377.332 109.465C379.313 109.085 381.063 110.594 381.063 112.517V114.244H387.743V107.335H389.47C391.163 107.335 392.51 105.883 392.338 104.156C392.188 102.659 390.806 101.576 389.309 101.576V101.576Z" fill="#6B6C6E"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M451.722 92.2556H429.733V114.244H451.722V92.2556ZM443.869 95.3968H432.875V111.103H443.869V95.3968Z" fill="#6B6C6E"/>
|
||||||
|
<path d="M319.134 140.28L321.833 143.226L321.098 132.179L321.833 121.133L325.148 120.274L327.848 121.133L329.076 130.215H332.512L335.581 130.829L336.931 132.179L341.349 130.829L344.172 133.775H346.627H350.8V137.457V146.662L346.627 153.904L336.931 157.831L325.884 156.85L317.047 149.731L313.488 143.226L315.452 140.28H319.134Z" fill="white"/>
|
||||||
|
<path d="M351 142.4C351 146.378 349.42 150.193 346.607 153.007C343.794 155.82 339.978 157.4 336 157.4" stroke="black" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M343.5 136.775V134.9C343.5 133.905 343.105 132.951 342.402 132.248C341.698 131.545 340.745 131.15 339.75 131.15C338.755 131.15 337.802 131.545 337.098 132.248C336.395 132.951 336 133.905 336 134.9" stroke="black" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M336 134.9V133.025C336 132.03 335.605 131.076 334.902 130.373C334.198 129.67 333.245 129.275 332.25 129.275C331.255 129.275 330.302 129.67 329.598 130.373C328.895 131.076 328.5 132.03 328.5 133.025V134.9" stroke="black" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M328.5 133.962V123.65C328.5 122.655 328.105 121.701 327.402 120.998C326.698 120.295 325.745 119.9 324.75 119.9C323.755 119.9 322.802 120.295 322.098 120.998C321.395 121.701 321 122.655 321 123.65V142.4" stroke="black" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M343.501 136.775C343.501 135.78 343.896 134.826 344.599 134.123C345.302 133.42 346.256 133.025 347.251 133.025C348.245 133.025 349.199 133.42 349.902 134.123C350.606 134.826 351.001 135.78 351.001 136.775V142.4C351.001 146.378 349.42 150.193 346.607 153.006C343.794 155.819 339.979 157.4 336.001 157.4H332.251C327.001 157.4 323.813 155.787 321.019 153.012L314.269 146.262C313.624 145.548 313.279 144.613 313.304 143.65C313.329 142.688 313.724 141.772 314.406 141.093C315.088 140.413 316.005 140.022 316.967 140C317.93 139.978 318.863 140.327 319.576 140.975L322.876 144.275" stroke="black" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_595_7038">
|
||||||
|
<rect width="521.25" height="225" fill="white" transform="translate(0 0.5)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
55
frontend/app/assets/img/spotThumbBg.svg
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<svg width="400" height="226" viewBox="0 0 400 226" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_292_84)">
|
||||||
|
<rect width="400" height="225" transform="translate(0 0.5)" fill="#3B9C9F" fill-opacity="0.2"/>
|
||||||
|
<path d="M34.1101 26.134C34.7768 26.5189 34.7768 27.4811 34.1101 27.866L25.6949 32.7245C25.0283 33.1094 24.1949 32.6283 24.1949 31.8585L24.1949 22.1415C24.1949 21.3717 25.0283 20.8906 25.6949 21.2755L34.1101 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M77.1101 26.134C77.7768 26.5189 77.7768 27.4811 77.1101 27.866L68.6949 32.7245C68.0283 33.1094 67.1949 32.6283 67.1949 31.8585L67.1949 22.1415C67.1949 21.3717 68.0283 20.8906 68.6949 21.2755L77.1101 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M120.11 26.134C120.777 26.5189 120.777 27.4811 120.11 27.866L111.695 32.7245C111.028 33.1094 110.195 32.6283 110.195 31.8585L110.195 22.1415C110.195 21.3717 111.028 20.8906 111.695 21.2755L120.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M163.11 26.134C163.777 26.5189 163.777 27.4811 163.11 27.866L154.695 32.7245C154.028 33.1094 153.195 32.6283 153.195 31.8585L153.195 22.1415C153.195 21.3717 154.028 20.8906 154.695 21.2755L163.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M206.11 26.134C206.777 26.5189 206.777 27.4811 206.11 27.866L197.695 32.7245C197.028 33.1094 196.195 32.6283 196.195 31.8585L196.195 22.1415C196.195 21.3717 197.028 20.8906 197.695 21.2755L206.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M249.11 26.134C249.777 26.5189 249.777 27.4811 249.11 27.866L240.695 32.7245C240.028 33.1094 239.195 32.6283 239.195 31.8585L239.195 22.1415C239.195 21.3717 240.028 20.8906 240.695 21.2755L249.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M292.11 26.134C292.777 26.5189 292.777 27.4811 292.11 27.866L283.695 32.7245C283.028 33.1094 282.195 32.6283 282.195 31.8585L282.195 22.1415C282.195 21.3717 283.028 20.8906 283.695 21.2755L292.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M335.11 26.134C335.777 26.5189 335.777 27.4811 335.11 27.866L326.695 32.7245C326.028 33.1094 325.195 32.6283 325.195 31.8585L325.195 22.1415C325.195 21.3717 326.028 20.8906 326.695 21.2755L335.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M378.11 26.134C378.777 26.5189 378.777 27.4811 378.11 27.866L369.695 32.7245C369.028 33.1094 368.195 32.6283 368.195 31.8585L368.195 22.1415C368.195 21.3717 369.028 20.8906 369.695 21.2755L378.11 26.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M34.1101 69.134C34.7768 69.5189 34.7768 70.4811 34.1101 70.866L25.6949 75.7245C25.0283 76.1094 24.1949 75.6283 24.1949 74.8585L24.1949 65.1415C24.1949 64.3717 25.0283 63.8906 25.6949 64.2755L34.1101 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M77.1101 69.134C77.7768 69.5189 77.7768 70.4811 77.1101 70.866L68.6949 75.7245C68.0283 76.1094 67.1949 75.6283 67.1949 74.8585L67.1949 65.1415C67.1949 64.3717 68.0283 63.8906 68.6949 64.2755L77.1101 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M120.11 69.134C120.777 69.5189 120.777 70.4811 120.11 70.866L111.695 75.7245C111.028 76.1094 110.195 75.6283 110.195 74.8585L110.195 65.1415C110.195 64.3717 111.028 63.8906 111.695 64.2755L120.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M163.11 69.134C163.777 69.5189 163.777 70.4811 163.11 70.866L154.695 75.7245C154.028 76.1094 153.195 75.6283 153.195 74.8585L153.195 65.1415C153.195 64.3717 154.028 63.8906 154.695 64.2755L163.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M206.11 69.134C206.777 69.5189 206.777 70.4811 206.11 70.866L197.695 75.7245C197.028 76.1094 196.195 75.6283 196.195 74.8585L196.195 65.1415C196.195 64.3717 197.028 63.8906 197.695 64.2755L206.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M249.11 69.134C249.777 69.5189 249.777 70.4811 249.11 70.866L240.695 75.7245C240.028 76.1094 239.195 75.6283 239.195 74.8585L239.195 65.1415C239.195 64.3717 240.028 63.8906 240.695 64.2755L249.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M292.11 69.134C292.777 69.5189 292.777 70.4811 292.11 70.866L283.695 75.7245C283.028 76.1094 282.195 75.6283 282.195 74.8585L282.195 65.1415C282.195 64.3717 283.028 63.8906 283.695 64.2755L292.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M335.11 69.134C335.777 69.5189 335.777 70.4811 335.11 70.866L326.695 75.7245C326.028 76.1094 325.195 75.6283 325.195 74.8585L325.195 65.1415C325.195 64.3717 326.028 63.8906 326.695 64.2755L335.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M378.11 69.134C378.777 69.5189 378.777 70.4811 378.11 70.866L369.695 75.7245C369.028 76.1094 368.195 75.6283 368.195 74.8585L368.195 65.1415C368.195 64.3717 369.028 63.8906 369.695 64.2755L378.11 69.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M34.1101 112.134C34.7768 112.519 34.7768 113.481 34.1101 113.866L25.6949 118.725C25.0283 119.109 24.1949 118.628 24.1949 117.858L24.1949 108.142C24.1949 107.372 25.0283 106.891 25.6949 107.275L34.1101 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M77.1101 112.134C77.7768 112.519 77.7768 113.481 77.1101 113.866L68.6949 118.725C68.0283 119.109 67.1949 118.628 67.1949 117.858L67.1949 108.142C67.1949 107.372 68.0283 106.891 68.6949 107.275L77.1101 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M120.11 112.134C120.777 112.519 120.777 113.481 120.11 113.866L111.695 118.725C111.028 119.109 110.195 118.628 110.195 117.858L110.195 108.142C110.195 107.372 111.028 106.891 111.695 107.275L120.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M163.11 112.134C163.777 112.519 163.777 113.481 163.11 113.866L154.695 118.725C154.028 119.109 153.195 118.628 153.195 117.858L153.195 108.142C153.195 107.372 154.028 106.891 154.695 107.275L163.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M206.11 112.134C206.777 112.519 206.777 113.481 206.11 113.866L197.695 118.725C197.028 119.109 196.195 118.628 196.195 117.858L196.195 108.142C196.195 107.372 197.028 106.891 197.695 107.275L206.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M249.11 112.134C249.777 112.519 249.777 113.481 249.11 113.866L240.695 118.725C240.028 119.109 239.195 118.628 239.195 117.858L239.195 108.142C239.195 107.372 240.028 106.891 240.695 107.275L249.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M292.11 112.134C292.777 112.519 292.777 113.481 292.11 113.866L283.695 118.725C283.028 119.109 282.195 118.628 282.195 117.858L282.195 108.142C282.195 107.372 283.028 106.891 283.695 107.275L292.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M335.11 112.134C335.777 112.519 335.777 113.481 335.11 113.866L326.695 118.725C326.028 119.109 325.195 118.628 325.195 117.858L325.195 108.142C325.195 107.372 326.028 106.891 326.695 107.275L335.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M378.11 112.134C378.777 112.519 378.777 113.481 378.11 113.866L369.695 118.725C369.028 119.109 368.195 118.628 368.195 117.858L368.195 108.142C368.195 107.372 369.028 106.891 369.695 107.275L378.11 112.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M34.1101 155.134C34.7768 155.519 34.7768 156.481 34.1101 156.866L25.6949 161.725C25.0283 162.109 24.1949 161.628 24.1949 160.858L24.1949 151.142C24.1949 150.372 25.0283 149.891 25.6949 150.275L34.1101 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M77.1101 155.134C77.7768 155.519 77.7768 156.481 77.1101 156.866L68.6949 161.725C68.0283 162.109 67.1949 161.628 67.1949 160.858L67.1949 151.142C67.1949 150.372 68.0283 149.891 68.6949 150.275L77.1101 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M120.11 155.134C120.777 155.519 120.777 156.481 120.11 156.866L111.695 161.725C111.028 162.109 110.195 161.628 110.195 160.858L110.195 151.142C110.195 150.372 111.028 149.891 111.695 150.275L120.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M163.11 155.134C163.777 155.519 163.777 156.481 163.11 156.866L154.695 161.725C154.028 162.109 153.195 161.628 153.195 160.858L153.195 151.142C153.195 150.372 154.028 149.891 154.695 150.275L163.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M206.11 155.134C206.777 155.519 206.777 156.481 206.11 156.866L197.695 161.725C197.028 162.109 196.195 161.628 196.195 160.858L196.195 151.142C196.195 150.372 197.028 149.891 197.695 150.275L206.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M249.11 155.134C249.777 155.519 249.777 156.481 249.11 156.866L240.695 161.725C240.028 162.109 239.195 161.628 239.195 160.858L239.195 151.142C239.195 150.372 240.028 149.891 240.695 150.275L249.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M292.11 155.134C292.777 155.519 292.777 156.481 292.11 156.866L283.695 161.725C283.028 162.109 282.195 161.628 282.195 160.858L282.195 151.142C282.195 150.372 283.028 149.891 283.695 150.275L292.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M335.11 155.134C335.777 155.519 335.777 156.481 335.11 156.866L326.695 161.725C326.028 162.109 325.195 161.628 325.195 160.858L325.195 151.142C325.195 150.372 326.028 149.891 326.695 150.275L335.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M378.11 155.134C378.777 155.519 378.777 156.481 378.11 156.866L369.695 161.725C369.028 162.109 368.195 161.628 368.195 160.858L368.195 151.142C368.195 150.372 369.028 149.891 369.695 150.275L378.11 155.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M34.1101 198.134C34.7768 198.519 34.7768 199.481 34.1101 199.866L25.6949 204.725C25.0283 205.109 24.1949 204.628 24.1949 203.858L24.1949 194.142C24.1949 193.372 25.0283 192.891 25.6949 193.275L34.1101 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M77.1101 198.134C77.7768 198.519 77.7768 199.481 77.1101 199.866L68.6949 204.725C68.0283 205.109 67.1949 204.628 67.1949 203.858L67.1949 194.142C67.1949 193.372 68.0283 192.891 68.6949 193.275L77.1101 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M120.11 198.134C120.777 198.519 120.777 199.481 120.11 199.866L111.695 204.725C111.028 205.109 110.195 204.628 110.195 203.858L110.195 194.142C110.195 193.372 111.028 192.891 111.695 193.275L120.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M163.11 198.134C163.777 198.519 163.777 199.481 163.11 199.866L154.695 204.725C154.028 205.109 153.195 204.628 153.195 203.858L153.195 194.142C153.195 193.372 154.028 192.891 154.695 193.275L163.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M206.11 198.134C206.777 198.519 206.777 199.481 206.11 199.866L197.695 204.725C197.028 205.109 196.195 204.628 196.195 203.858L196.195 194.142C196.195 193.372 197.028 192.891 197.695 193.275L206.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M249.11 198.134C249.777 198.519 249.777 199.481 249.11 199.866L240.695 204.725C240.028 205.109 239.195 204.628 239.195 203.858L239.195 194.142C239.195 193.372 240.028 192.891 240.695 193.275L249.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M292.11 198.134C292.777 198.519 292.777 199.481 292.11 199.866L283.695 204.725C283.028 205.109 282.195 204.628 282.195 203.858L282.195 194.142C282.195 193.372 283.028 192.891 283.695 193.275L292.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M335.11 198.134C335.777 198.519 335.777 199.481 335.11 199.866L326.695 204.725C326.028 205.109 325.195 204.628 325.195 203.858L325.195 194.142C325.195 193.372 326.028 192.891 326.695 193.275L335.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
<path d="M378.11 198.134C378.777 198.519 378.777 199.481 378.11 199.866L369.695 204.725C369.028 205.109 368.195 204.628 368.195 203.858L368.195 194.142C368.195 193.372 369.028 192.891 369.695 193.275L378.11 198.134Z" fill="white" fill-opacity="0.25"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_292_84">
|
||||||
|
<rect width="400" height="225" fill="white" transform="translate(0 0.5)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
27
frontend/app/assets/img/videoProcessing.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svg width="100px" height="100px" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill="#FFFFFF">
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
|
||||||
|
<circle cx="50" cy="50" r="45" stroke="#FFFFFF" stroke-width="4" fill="rgba(44,29,184,0.3)"/>
|
||||||
|
<g transform="translate(26, 26) scale(2)">
|
||||||
|
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
|
||||||
|
<rect x="2" y="6" width="14" height="12" rx="2" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="dots">
|
||||||
|
<circle cx="50" cy="12" r="3" fill="#FFFFFF">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="50" cy="88" r="3" fill="#FFFFFF" opacity="0.6">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="88" cy="50" r="3" fill="#FFFFFF" opacity="0.3">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="12" cy="50" r="3" fill="#FFFFFF" opacity="0.1">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -9,8 +9,14 @@ import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { forgotPassword, signup } from 'App/routes';
|
import { forgotPassword, signup, spotsList } from 'App/routes';
|
||||||
import { fetchTenants, loginSuccess, setJwt } from 'Duck/user';
|
import {
|
||||||
|
fetchTenants,
|
||||||
|
loadingLogin,
|
||||||
|
loginFailure,
|
||||||
|
loginSuccess,
|
||||||
|
setJwt,
|
||||||
|
} from 'Duck/user';
|
||||||
import { Button, Form, Icon, Input, Link, Loader, Tooltip } from 'UI';
|
import { Button, Form, Icon, Input, Link, Loader, Tooltip } from 'UI';
|
||||||
|
|
||||||
import Copyright from 'Shared/Copyright';
|
import Copyright from 'Shared/Copyright';
|
||||||
|
|
@ -27,6 +33,8 @@ interface LoginProps {
|
||||||
loginSuccess: typeof loginSuccess;
|
loginSuccess: typeof loginSuccess;
|
||||||
setJwt: typeof setJwt;
|
setJwt: typeof setJwt;
|
||||||
fetchTenants: typeof fetchTenants;
|
fetchTenants: typeof fetchTenants;
|
||||||
|
loadingLogin: typeof loadingLogin;
|
||||||
|
loginFailure: typeof loginFailure;
|
||||||
location: Location;
|
location: Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +46,8 @@ const Login: React.FC<LoginProps> = ({
|
||||||
setJwt,
|
setJwt,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
location,
|
location,
|
||||||
|
loadingLogin,
|
||||||
|
loginFailure,
|
||||||
}) => {
|
}) => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
@ -60,8 +70,12 @@ const Login: React.FC<LoginProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTenants();
|
fetchTenants();
|
||||||
const jwt = params.get('jwt');
|
const jwt = params.get('jwt');
|
||||||
|
const spotJwt = params.get('spotJwt');
|
||||||
|
if (spotJwt) {
|
||||||
|
handleSpotLogin(spotJwt);
|
||||||
|
}
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
setJwt(jwt);
|
setJwt({ jwt, spotJwt });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -99,18 +113,27 @@ const Login: React.FC<LoginProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (token?: string) => {
|
const handleSubmit = (token?: string) => {
|
||||||
|
if (!email || !password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingLogin();
|
||||||
loginStore.setEmail(email.trim());
|
loginStore.setEmail(email.trim());
|
||||||
loginStore.setPassword(password);
|
loginStore.setPassword(password);
|
||||||
if (token) {
|
if (token) {
|
||||||
loginStore.setCaptchaResponse(token);
|
loginStore.setCaptchaResponse(token);
|
||||||
}
|
}
|
||||||
loginStore.generateJWT().then((resp) => {
|
loginStore
|
||||||
if (resp) {
|
.generateJWT()
|
||||||
handleSpotLogin(resp.spotJwt);
|
.then((resp) => {
|
||||||
}
|
if (resp) {
|
||||||
loginSuccess(resp)
|
loginSuccess({ ...resp, spotJwt: resp.spotJwt ?? null });
|
||||||
setJwt(resp.jwt)
|
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
|
||||||
})
|
handleSpotLogin(resp.spotJwt);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
loginFailure(e);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
|
@ -122,14 +145,10 @@ const Login: React.FC<LoginProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSSOClick = () => {
|
const ssoLink =
|
||||||
if (window !== window.top) {
|
window !== window.top
|
||||||
// if in iframe
|
? `${window.location.origin}/api/sso/saml2?iFrame=true&spot=true`
|
||||||
window.parent.location.href = `${window.location.origin}/api/sso/saml2?iFrame=true`;
|
: `${window.location.origin}/api/sso/saml2?spot=true`;
|
||||||
} else {
|
|
||||||
window.location.href = `${window.location.origin}/api/sso/saml2`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
|
@ -223,7 +242,7 @@ const Login: React.FC<LoginProps> = ({
|
||||||
|
|
||||||
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
|
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
|
||||||
{authDetails.sso ? (
|
{authDetails.sso ? (
|
||||||
<a href="#" rel="noopener noreferrer" onClick={onSSOClick}>
|
<a href={ssoLink} rel="noopener noreferrer">
|
||||||
<Button variant="text-primary" type="submit">
|
<Button variant="text-primary" type="submit">
|
||||||
{`Login with SSO ${
|
{`Login with SSO ${
|
||||||
authDetails.ssoProvider
|
authDetails.ssoProvider
|
||||||
|
|
@ -269,7 +288,7 @@ const Login: React.FC<LoginProps> = ({
|
||||||
hidden: !authDetails.enforceSSO,
|
hidden: !authDetails.enforceSSO,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a href="#" rel="noopener noreferrer" onClick={onSSOClick}>
|
<a href={ssoLink} rel="noopener noreferrer">
|
||||||
<Button variant="primary">{`Login with SSO ${
|
<Button variant="primary">{`Login with SSO ${
|
||||||
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
|
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
|
||||||
}`}</Button>
|
}`}</Button>
|
||||||
|
|
@ -294,6 +313,8 @@ const mapDispatchToProps = {
|
||||||
loginSuccess,
|
loginSuccess,
|
||||||
setJwt,
|
setJwt,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
|
loadingLogin,
|
||||||
|
loginFailure,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withPageTitle('Login - OpenReplay')(
|
export default withPageTitle('Login - OpenReplay')(
|
||||||
|
|
|
||||||
85
frontend/app/components/ScopeForm/ScopeForm.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Card, Radio } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { upgradeScope, downgradeScope } from "App/duck/user";
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import * as routes from 'App/routes'
|
||||||
|
import { SPOT_ONBOARDING } from "../../constants/storageKeys";
|
||||||
|
|
||||||
|
const Scope = {
|
||||||
|
FULL: 'full',
|
||||||
|
SPOT: 'spot',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ScopeForm({
|
||||||
|
upgradeScope,
|
||||||
|
downgradeScope,
|
||||||
|
}: any) {
|
||||||
|
const [scope, setScope] = React.useState(Scope.FULL);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const isSpotSetup = localStorage.getItem(SPOT_ONBOARDING)
|
||||||
|
if (isSpotSetup) {
|
||||||
|
setScope(Scope.SPOT)
|
||||||
|
localStorage.removeItem(SPOT_ONBOARDING)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const history = useHistory();
|
||||||
|
const onContinue = () => {
|
||||||
|
if (scope === Scope.FULL) {
|
||||||
|
upgradeScope();
|
||||||
|
history.replace(routes.onboarding())
|
||||||
|
} else {
|
||||||
|
downgradeScope();
|
||||||
|
history.replace(routes.spotsList())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center justify-center w-screen h-screen'}>
|
||||||
|
<Card
|
||||||
|
style={{ width: 540 }}
|
||||||
|
title={'👋 Welcome to OpenReplay'}
|
||||||
|
classNames={{
|
||||||
|
header: 'text-2xl font-semibold text-center',
|
||||||
|
body: 'flex flex-col gap-2',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={'font-semibold'}>
|
||||||
|
How will you primarily use OpenReplay?{' '}
|
||||||
|
</div>
|
||||||
|
<div className={'text-disabled-text'}>
|
||||||
|
<div>
|
||||||
|
You will have access to all OpenReplay features regardless of your
|
||||||
|
choice.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Your preference will simply help us tailor your onboarding experience.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Radio.Group
|
||||||
|
value={scope}
|
||||||
|
onChange={(e) => setScope(e.target.value)}
|
||||||
|
className={'flex flex-col gap-2 mt-4 '}
|
||||||
|
>
|
||||||
|
<Radio value={'full'}>
|
||||||
|
Session Replay & Debugging, Customer Support and more
|
||||||
|
</Radio>
|
||||||
|
<Radio value={'spot'}>Report bugs via Spot</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
<div className={'self-end'}>
|
||||||
|
<Button
|
||||||
|
type={'primary'}
|
||||||
|
onClick={() => onContinue()}
|
||||||
|
icon={<ArrowRightOutlined />}
|
||||||
|
iconPosition={'end'}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(null, { upgradeScope, downgradeScope })(ScopeForm);
|
||||||
1
frontend/app/components/ScopeForm/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './ScopeForm';
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Tooltip } from 'UI';
|
import { Icon, Tooltip } from 'UI';
|
||||||
|
import {Link2} from 'lucide-react';
|
||||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
|
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
|
||||||
|
|
@ -19,8 +20,8 @@ function SubHeader() {
|
||||||
{location && (
|
{location && (
|
||||||
<div className={'w-full bg-white border-b border-gray-lighter'}>
|
<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">
|
<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" />
|
<Link2 className="mx-2" size={16} />
|
||||||
<Tooltip title="Open in new tab" delay={0}>
|
<Tooltip title="Open in new tab" delay={0} placement='bottom'>
|
||||||
<a href={location} target="_blank">
|
<a href={location} target="_blank">
|
||||||
{location}
|
{location}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@ const Header = ({
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
showClose = true,
|
showClose = true,
|
||||||
customStyle,
|
customStyle,
|
||||||
|
customClose,
|
||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<div className={ cn("relative border-r border-l py-1", stl.header) } style={customStyle} >
|
<div className={ cn("relative border-r border-l py-1", stl.header) } style={customStyle} >
|
||||||
<div className={ cn("w-full h-full flex justify-between items-center", className) } >
|
<div className={ cn("w-full h-full flex justify-between items-center", className) } >
|
||||||
<div className="w-full flex items-center justify-between">{ children }</div>
|
<div className="w-full flex items-center justify-between">{ children }</div>
|
||||||
{ showClose && <CloseButton onClick={ closeBottomBlock } size="18" className="ml-2" /> }
|
{ showClose && <CloseButton onClick={ customClose ? customClose : closeBottomBlock } size="18" className="ml-2" /> }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import { addMessage } from 'Duck/assignments';
|
||||||
class IssueCommentForm extends React.PureComponent {
|
class IssueCommentForm extends React.PureComponent {
|
||||||
state = { comment: '' }
|
state = { comment: '' }
|
||||||
|
|
||||||
write = ({ target: { name, value } }) => this.setState({ comment: value });
|
write = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const { target: { name, value } } = e
|
||||||
|
this.setState({ comment: value });
|
||||||
|
}
|
||||||
|
|
||||||
addComment = () => {
|
addComment = () => {
|
||||||
const { comment } = this.state;
|
const { comment } = this.state;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import FeatureSelection, {
|
||||||
import OverviewPanelContainer from './components/OverviewPanelContainer';
|
import OverviewPanelContainer from './components/OverviewPanelContainer';
|
||||||
import TimelinePointer from './components/TimelinePointer';
|
import TimelinePointer from './components/TimelinePointer';
|
||||||
import TimelineScale from './components/TimelineScale';
|
import TimelineScale from './components/TimelineScale';
|
||||||
import VerticalPointerLine from './components/VerticalPointerLine';
|
import VerticalPointerLine, { VerticalPointerLineComp } from './components/VerticalPointerLine';
|
||||||
|
|
||||||
function MobileOverviewPanelCont({
|
function MobileOverviewPanelCont({
|
||||||
issuesList,
|
issuesList,
|
||||||
|
|
@ -210,6 +210,35 @@ function WebOverviewPanelCont({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SpotOverviewPanelCont({
|
||||||
|
resourceList,
|
||||||
|
exceptionsList,
|
||||||
|
spotTime,
|
||||||
|
spotEndTime,
|
||||||
|
onClose,
|
||||||
|
}: any) {
|
||||||
|
const selectedFeatures = ['ERRORS', 'NETWORK'];
|
||||||
|
const fetchPresented = false; // TODO
|
||||||
|
const endTime = 0; // TODO
|
||||||
|
const resources = {
|
||||||
|
NETWORK: resourceList,
|
||||||
|
ERRORS: exceptionsList,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelComponent
|
||||||
|
resources={resources}
|
||||||
|
endTime={endTime}
|
||||||
|
selectedFeatures={selectedFeatures}
|
||||||
|
fetchPresented={fetchPresented}
|
||||||
|
isSpot
|
||||||
|
spotTime={spotTime}
|
||||||
|
spotEndTime={spotEndTime}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PanelComponent({
|
function PanelComponent({
|
||||||
selectedFeatures,
|
selectedFeatures,
|
||||||
endTime,
|
endTime,
|
||||||
|
|
@ -224,11 +253,15 @@ function PanelComponent({
|
||||||
sessionId,
|
sessionId,
|
||||||
zoomTab,
|
zoomTab,
|
||||||
setZoomTab,
|
setZoomTab,
|
||||||
|
isSpot,
|
||||||
|
spotTime,
|
||||||
|
spotEndTime,
|
||||||
|
onClose,
|
||||||
}: any) {
|
}: any) {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<BottomBlock style={{ height: '100%' }}>
|
<BottomBlock style={{ height: '100%' }}>
|
||||||
<BottomBlock.Header>
|
<BottomBlock.Header customClose={onClose}>
|
||||||
<div className="mr-4 flex items-center gap-2">
|
<div className="mr-4 flex items-center gap-2">
|
||||||
<span className={'font-semibold text-black'}>X-Ray</span>
|
<span className={'font-semibold text-black'}>X-Ray</span>
|
||||||
{showSummary ? (
|
{showSummary ? (
|
||||||
|
|
@ -265,13 +298,15 @@ function PanelComponent({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-20 mr-4 gap-2">
|
{isSpot ? null : (
|
||||||
<TimelineZoomButton />
|
<div className="flex items-center h-20 mr-4 gap-2">
|
||||||
<FeatureSelection
|
<TimelineZoomButton />
|
||||||
list={selectedFeatures}
|
<FeatureSelection
|
||||||
updateList={setSelectedFeatures}
|
list={selectedFeatures}
|
||||||
/>
|
updateList={setSelectedFeatures}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</BottomBlock.Header>
|
</BottomBlock.Header>
|
||||||
<BottomBlock.Content className={'overflow-y-auto'}>
|
<BottomBlock.Content className={'overflow-y-auto'}>
|
||||||
{summaryChecked ? <SummaryBlock sessionId={sessionId} /> : null}
|
{summaryChecked ? <SummaryBlock sessionId={sessionId} /> : null}
|
||||||
|
|
@ -291,7 +326,7 @@ function PanelComponent({
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<VerticalPointerLine />
|
{isSpot ? <VerticalPointerLineComp time={spotTime} endTime={spotEndTime} /> : <VerticalPointerLine />}
|
||||||
{selectedFeatures.map((feature: any, index: number) => (
|
{selectedFeatures.map((feature: any, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={feature}
|
key={feature}
|
||||||
|
|
@ -310,7 +345,7 @@ function PanelComponent({
|
||||||
fetchPresented={fetchPresented}
|
fetchPresented={fetchPresented}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
endTime={endTime}
|
endTime={isSpot ? spotEndTime : endTime}
|
||||||
message={HELP_MESSAGE[feature]}
|
message={HELP_MESSAGE[feature]}
|
||||||
/>
|
/>
|
||||||
{isMobile && feature === 'PERFORMANCE' ? (
|
{isMobile && feature === 'PERFORMANCE' ? (
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ function VerticalPointerLine() {
|
||||||
const { store } = React.useContext(PlayerContext)
|
const { store } = React.useContext(PlayerContext)
|
||||||
|
|
||||||
const { time, endTime } = store.get();
|
const { time, endTime } = store.get();
|
||||||
const scale = 100 / endTime;
|
return <VerticalPointerLineComp time={time} endTime={endTime} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerticalPointerLineComp ({ time, endTime }: { time: number, endTime: number }) {
|
||||||
|
const scale = 100 / endTime;
|
||||||
const left = time * scale;
|
const left = time * scale;
|
||||||
|
|
||||||
return <VerticalLine left={left} className="border-teal" />;
|
return <VerticalLine left={left} className="border-teal" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export { default } from './VerticalPointerLine'
|
export { default, VerticalPointerLineComp } from './VerticalPointerLine'
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp';
|
import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
|
import {Link2} from 'lucide-react';
|
||||||
import QueueControls from './QueueControls';
|
import QueueControls from './QueueControls';
|
||||||
import Bookmark from 'Shared/Bookmark';
|
import Bookmark from 'Shared/Bookmark';
|
||||||
import SharePopup from '../shared/SharePopup/SharePopup';
|
import SharePopup from '../shared/SharePopup/SharePopup';
|
||||||
|
|
@ -141,8 +142,8 @@ function SubHeader(props) {
|
||||||
{locationTruncated && (
|
{locationTruncated && (
|
||||||
<div className={'w-full bg-white border-b border-gray-lighter'}>
|
<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">
|
<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" />
|
<Link2 className="mx-2" size={16} />
|
||||||
<Tooltip title="Open in new tab" delay={0}>
|
<Tooltip title="Open in new tab" delay={0} placement='bottom'>
|
||||||
<a href={currentLocation} target="_blank" className="truncate">
|
<a href={currentLocation} target="_blank" className="truncate">
|
||||||
{locationTruncated}
|
{locationTruncated}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Button, Card } from 'antd';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
@ -5,12 +6,15 @@ import { connect } from 'react-redux';
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { EscapeButton, Loader } from 'UI';
|
import { EscapeButton, Icon, Loader } from 'UI';
|
||||||
|
|
||||||
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
debounceUpdate,
|
debounceUpdate,
|
||||||
getDefaultPanelHeight,
|
getDefaultPanelHeight,
|
||||||
} from '../../Session/Player/ReplayPlayer/PlayerInst';
|
} from '../../Session/Player/ReplayPlayer/PlayerInst';
|
||||||
|
import { SpotOverviewPanelCont } from '../../Session_/OverviewPanel/OverviewPanel';
|
||||||
import withPermissions from '../../hocs/withPermissions';
|
import withPermissions from '../../hocs/withPermissions';
|
||||||
import SpotConsole from './components/Panels/SpotConsole';
|
import SpotConsole from './components/Panels/SpotConsole';
|
||||||
import SpotNetwork from './components/Panels/SpotNetwork';
|
import SpotNetwork from './components/Panels/SpotNetwork';
|
||||||
|
|
@ -32,6 +36,11 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
const { spotId } = useParams<{ spotId: string }>();
|
const { spotId } = useParams<{ spotId: string }>();
|
||||||
const [activeTab, setActiveTab] = React.useState<Tab | null>(null);
|
const [activeTab, setActiveTab] = React.useState<Tab | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (spotStore.currentSpot) {
|
||||||
|
document.title = spotStore.currentSpot.title + ' - OpenReplay'
|
||||||
|
}
|
||||||
|
}, [spotStore.currentSpot])
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
const query = new URLSearchParams(window.location.search);
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -98,6 +107,12 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const ev = (e: KeyboardEvent) => {
|
const ev = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
spotPlayerStore.setIsFullScreen(false);
|
spotPlayerStore.setIsFullScreen(false);
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +131,19 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
const highest = 16;
|
const highest = 16;
|
||||||
spotPlayerStore.setPlaybackRate(Math.min(highest, current * 2));
|
spotPlayerStore.setPlaybackRate(Math.min(highest, current * 2));
|
||||||
}
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
spotPlayerStore.setTime(
|
||||||
|
Math.min(
|
||||||
|
spotPlayerStore.duration,
|
||||||
|
spotPlayerStore.time + spotPlayerStore.skipInterval
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
spotPlayerStore.setTime(
|
||||||
|
Math.max(0, spotPlayerStore.time - spotPlayerStore.skipInterval)
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', ev);
|
document.addEventListener('keydown', ev);
|
||||||
|
|
@ -127,8 +155,47 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
}, []);
|
}, []);
|
||||||
if (!spotStore.currentSpot) {
|
if (!spotStore.currentSpot) {
|
||||||
return (
|
return (
|
||||||
<div className={'w-screen h-screen flex items-center justify-center'}>
|
<div
|
||||||
<Loader />
|
className={
|
||||||
|
'w-screen h-screen flex items-center justify-center flex-col gap-2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{spotStore.accessError ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full block ">
|
||||||
|
<div className="flex bg-white border-b text-center justify-center py-4">
|
||||||
|
<a href="https://openreplay.com/spot" target="_blank">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="orSpotBranding flex gap-1 items-center"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<Icon name={'orSpot'} size={28} />
|
||||||
|
<div className="flex flex-row gap-2 items-center text-start">
|
||||||
|
<div className={'text-3xl font-semibold '}>Spot</div>
|
||||||
|
<div className={'text-disabled-text text-xs mt-3'}>
|
||||||
|
by OpenReplay
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Card className="w-1/2 mx-auto rounded-b-full shadow-sm text-center flex flex-col justify-center items-center z-50 min-h-60">
|
||||||
|
<div className={'font-semibold text-xl'}>
|
||||||
|
The Spot link has expired.
|
||||||
|
</div>
|
||||||
|
<p className="text-lg">
|
||||||
|
Contact the person who shared it to re-spot.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<div className="rotate-180 -z-10 w-fit mx-auto -mt-5 hover:mt-2 transition-all ease-in-out hover:rotate-0 hover:transition-all hover:ease-in-out duration-500 hover:duration-150">
|
||||||
|
<AnimatedSVG name={ICONS.NO_RECORDINGS} size={60} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +233,6 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
// }]
|
// }]
|
||||||
// };
|
// };
|
||||||
|
|
||||||
console.log(spotStore.currentSpot)
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -198,6 +264,7 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
videoURL={spotStore.currentSpot.videoURL!}
|
videoURL={spotStore.currentSpot.videoURL!}
|
||||||
streamFile={spotStore.currentSpot.streamFile}
|
streamFile={spotStore.currentSpot.streamFile}
|
||||||
thumbnail={spotStore.currentSpot.thumbnail}
|
thumbnail={spotStore.currentSpot.thumbnail}
|
||||||
|
checkReady={() => spotStore.checkIsProcessed(spotId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isFullScreen && spotPlayerStore.activePanel ? (
|
{!isFullScreen && spotPlayerStore.activePanel ? (
|
||||||
|
|
@ -227,6 +294,9 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
panelHeight={panelHeight}
|
panelHeight={panelHeight}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{spotPlayerStore.activePanel === PANELS.OVERVIEW ? (
|
||||||
|
<SpotOverviewConnector />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -244,6 +314,30 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SpotOverviewConnector = observer(() => {
|
||||||
|
const endTime = spotPlayerStore.duration * 1000;
|
||||||
|
const time = spotPlayerStore.time * 1000;
|
||||||
|
const resourceList = spotPlayerStore.network
|
||||||
|
.filter((r: any) => r.isRed || r.isYellow || (r.status && r.status >= 400))
|
||||||
|
.filter((i: any) => i.type === 'xhr');
|
||||||
|
const exceptionsList = spotPlayerStore.logs.filter(
|
||||||
|
(l) => l.level === 'error'
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
spotPlayerStore.setActivePanel(null);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SpotOverviewPanelCont
|
||||||
|
exceptionsList={exceptionsList}
|
||||||
|
resourceList={resourceList}
|
||||||
|
spotTime={time}
|
||||||
|
spotEndTime={endTime}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function mapStateToProps(state: any) {
|
function mapStateToProps(state: any) {
|
||||||
const userEmail = state.getIn(['user', 'account', 'name']);
|
const userEmail = state.getIn(['user', 'account', 'name']);
|
||||||
const loggedIn = !!userEmail;
|
const loggedIn = !!userEmail;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { DownOutlined, LinkOutlined, StopOutlined } from '@ant-design/icons';
|
import { DownOutlined, CopyOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
import { Button, Dropdown, Segmented } from 'antd';
|
import { Button, Dropdown, Menu, Segmented, Modal } from 'antd';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { confirm } from 'UI';
|
import { formatExpirationTime, HOUR_SECS, DAY_SECS, WEEK_SECS } from 'App/utils/index';
|
||||||
import { durationFormatted } from "../../../../date";
|
|
||||||
|
|
||||||
const HOUR_SECS = 60 * 60;
|
|
||||||
const DAY_SECS = 24 * HOUR_SECS;
|
|
||||||
const WEEK_SECS = 7 * DAY_SECS;
|
|
||||||
|
|
||||||
enum Intervals {
|
enum Intervals {
|
||||||
hour,
|
hour,
|
||||||
|
|
@ -20,9 +15,11 @@ enum Intervals {
|
||||||
|
|
||||||
function AccessModal() {
|
function AccessModal() {
|
||||||
const { spotStore } = useStore();
|
const { spotStore } = useStore();
|
||||||
const [isCopied, setIsCopied] = React.useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const [isPublic, setIsPublic] = React.useState(!!spotStore.pubKey);
|
const [isPublic, setIsPublic] = useState(!!spotStore.pubKey);
|
||||||
const [generated, setGenerated] = React.useState(!!spotStore.pubKey);
|
const [generated, setGenerated] = useState(!!spotStore.pubKey);
|
||||||
|
const [selectedInterval, setSelectedInterval] = useState<Intervals>(Intervals.hour);
|
||||||
|
const [loadingKey, setLoadingKey] = useState(false);
|
||||||
|
|
||||||
const expirationValues = {
|
const expirationValues = {
|
||||||
[Intervals.hour]: HOUR_SECS,
|
[Intervals.hour]: HOUR_SECS,
|
||||||
|
|
@ -37,68 +34,52 @@ function AccessModal() {
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
key: Intervals.hour,
|
key: Intervals.hour.toString(),
|
||||||
label: <div>One Hour</div>,
|
label: <div>1 Hour</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: Intervals.threeHours,
|
key: Intervals.threeHours.toString(),
|
||||||
label: <div>Three Hours</div>,
|
label: <div>3 Hours</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: Intervals.day,
|
key: Intervals.day.toString(),
|
||||||
label: <div>One Day</div>,
|
label: <div>1 Day</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: Intervals.week,
|
key: Intervals.week.toString(),
|
||||||
label: <div>One Week</div>,
|
label: <div>1 Week</div>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const onMenuClick = ({ key }: { key: Intervals }) => {
|
|
||||||
const val = expirationValues[key];
|
const onMenuClick = async (info: { key: string }) => {
|
||||||
if (
|
const val = expirationValues[Number(info.key) as Intervals];
|
||||||
spotStore.pubKey?.expiration &&
|
setSelectedInterval(Number(info.key) as Intervals);
|
||||||
Math.abs(spotStore.pubKey?.expiration - val) / val < 0.1
|
await spotStore.generateKey(spotId, val);
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void spotStore.generateKey(spotId, val);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeAccess = async (toPublic: boolean) => {
|
const changeAccess = async (toPublic: boolean) => {
|
||||||
if (isPublic && !toPublic && spotStore.pubKey) {
|
if (isPublic && !toPublic && spotStore.pubKey) {
|
||||||
if (
|
await spotStore.generateKey(spotId, 0);
|
||||||
await confirm({
|
setIsPublic(toPublic);
|
||||||
header: 'Confirm',
|
} else {
|
||||||
confirmButton: 'Disable',
|
setIsPublic(toPublic);
|
||||||
confirmation:
|
|
||||||
'Are you sure you want to disable public sharing for this spot?',
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
void spotStore.generateKey(spotId, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setIsPublic(toPublic);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const revokeKey = async () => {
|
const revokeKey = async () => {
|
||||||
if (
|
await spotStore.generateKey(spotId, 0);
|
||||||
await confirm({
|
setGenerated(false);
|
||||||
header: 'Confirm',
|
setIsPublic(false);
|
||||||
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 generateInitial = async () => {
|
||||||
|
setLoadingKey(true);
|
||||||
const k = await spotStore.generateKey(
|
const k = await spotStore.generateKey(
|
||||||
spotId,
|
spotId,
|
||||||
expirationValues[Intervals.hour]
|
expirationValues[Intervals.hour]
|
||||||
);
|
);
|
||||||
setGenerated(!!k);
|
setGenerated(!!k);
|
||||||
|
setLoadingKey(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopy = () => {
|
const onCopy = () => {
|
||||||
|
|
@ -108,12 +89,8 @@ function AccessModal() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={'flex flex-col gap-4 align-start w-96 p-1'} >
|
||||||
className={'flex flex-col gap-4 align-start'}
|
|
||||||
style={{ width: 420, height: generated ? 240 : 200 }}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div className={'font-semibold mb-2'}>Who can access this Spot</div>
|
|
||||||
<Segmented
|
<Segmented
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
|
|
@ -132,10 +109,10 @@ function AccessModal() {
|
||||||
{!isPublic ? (
|
{!isPublic ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className={'text-disabled-text'}>
|
<div className={'text-black/50'}>
|
||||||
All team members in your project will able to view this Spot
|
Link for internal team members
|
||||||
</div>
|
</div>
|
||||||
<div className={'px-2 py-1 border rounded bg-[#FAFAFA] whitespace-nowrap overflow-ellipsis overflow-hidden'}>
|
<div className={'px-2 py-1 rounded-lg bg-indigo-50 whitespace-nowrap overflow-ellipsis overflow-hidden'}>
|
||||||
{spotLink}
|
{spotLink}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,56 +120,60 @@ function AccessModal() {
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
type={'text'}
|
type={'default'}
|
||||||
icon={<LinkOutlined />}
|
icon={<CopyOutlined />}
|
||||||
>
|
>
|
||||||
{isCopied ? 'Copied!' : 'Copy Link'}
|
{isCopied ? 'Copied!' : 'Copy Link'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : !generated ? (
|
) : !generated ? (
|
||||||
<div className={'w-fit'}>
|
<div className={'w-fit p-1'}>
|
||||||
<Button
|
<Button
|
||||||
loading={spotStore.isLoading}
|
loading={spotStore.isLoading}
|
||||||
onClick={generateInitial}
|
onClick={generateInitial}
|
||||||
type={'primary'}
|
type={'primary'}
|
||||||
ghost
|
ghost
|
||||||
|
size='small'
|
||||||
|
className='mt-1'
|
||||||
>
|
>
|
||||||
Enable Public Sharing
|
Enable Public Sharing
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className='flex flex-col gap-4 px-1'>
|
||||||
<div className={'text-disabled-text'}>Anyone with following link will be able to view this spot</div>
|
<div>
|
||||||
<div className={'px-2 py-1 border rounded bg-[#FAFAFA] whitespace-nowrap overflow-ellipsis overflow-hidden'}>
|
<div className={'text-black/50'}>Anyone with the following link can access this Spot</div>
|
||||||
{spotLink}
|
<div className={'px-2 py-1 rounded-lg bg-indigo-50 whitespace-nowrap overflow-ellipsis overflow-hidden'}>
|
||||||
</div>
|
{spotLink}
|
||||||
</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>
|
</div>
|
||||||
</Dropdown>
|
</div>
|
||||||
</div>
|
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div className={'w-fit'}>
|
<div>Link expires in</div>
|
||||||
<Button
|
<Dropdown overlay={<Menu items={menuItems} onClick={onMenuClick} />}>
|
||||||
type={'primary'}
|
<div className='flex items-center cursor-pointer'>
|
||||||
ghost
|
{loadingKey ? 'Loading' : formatExpirationTime(expirationValues[selectedInterval])}
|
||||||
size={'small'}
|
<DownOutlined />
|
||||||
onClick={onCopy}
|
</div>
|
||||||
icon={<LinkOutlined />}
|
</Dropdown>
|
||||||
>
|
</div>
|
||||||
{isCopied ? 'Copied!' : 'Copy Link'}
|
<div className={'flex items-center gap-2'}>
|
||||||
|
<div className={'w-fit'}>
|
||||||
|
<Button
|
||||||
|
type={'default'}
|
||||||
|
size={'small'}
|
||||||
|
onClick={onCopy}
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
>
|
||||||
|
{isCopied ? 'Copied!' : 'Copy Link'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button type={'text'} size='small' icon={<StopOutlined />} onClick={revokeKey}>
|
||||||
|
Disable Public Sharing
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button type={'text'} icon={<StopOutlined />} onClick={revokeKey}>
|
|
||||||
Disable Public Sharing
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -200,4 +181,4 @@ function AccessModal() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AccessModal;
|
export default observer(AccessModal);
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,143 @@
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { SendOutlined } from '@ant-design/icons';
|
||||||
import { Button, Input, Tooltip } from 'antd';
|
import { Button, Input, Tooltip } from 'antd';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { X } from 'lucide-react';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { resentOrDate } from 'App/date';
|
import { resentOrDate } from 'App/date';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
|
||||||
import { SendOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
function CommentsSection({
|
function CommentsSection({ onClose }: { onClose?: () => void }) {
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { spotStore } = useStore();
|
const { spotStore } = useStore();
|
||||||
const comments = spotStore.currentSpot?.comments ?? [];
|
const comments = spotStore.currentSpot?.comments ?? [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={'h-full p-4 bg-white border border-gray-light'}
|
className={'h-full p-4 bg-white border-l'}
|
||||||
style={{ minWidth: 320, width: 320 }}
|
style={{ minWidth: 320, width: 320 }}
|
||||||
>
|
>
|
||||||
<div className={'flex items-center justify-between'}>
|
<div className={'flex items-center justify-between mb-2'}>
|
||||||
<div className={'font-semibold'}>Comments</div>
|
<div className={'font-medium text-lg'}>Comments</div>
|
||||||
<div onClick={onClose} className={'p-1 cursor-pointer'}>
|
<Button onClick={onClose} type="text" size="small">
|
||||||
<X size={16} />
|
<CloseOutlined />
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={'overflow-y-auto flex flex-col gap-4 mt-2'}
|
className={'overflow-y-auto flex flex-col gap-4 mt-2'}
|
||||||
style={{ height: 'calc(100vh - 132px)' }}
|
style={{ height: 'calc(100vh - 132px)' }}
|
||||||
>
|
>
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<div key={comment.createdAt} className={'flex flex-col gap-2'}>
|
<div
|
||||||
|
key={comment.createdAt}
|
||||||
|
className={'flex flex-col gap-2 border-b border-dotted pb-2'}
|
||||||
|
>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'w-8 h-8 bg-tealx rounded-full flex items-center justify-center color-white uppercase'
|
'w-9 h-9 text-xs bg-tealx rounded-full flex items-center justify-center color-white uppercase'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{comment.user[0]}
|
{comment.user[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className={'font-semibold'}>{comment.user}</div>
|
<div className={'font-medium flex flex-col '}>
|
||||||
|
{comment.user}
|
||||||
|
<div className={'text-xs text-disabled-text font-normal'}>
|
||||||
|
{resentOrDate(new Date(comment.createdAt).getTime())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{comment.text}</div>
|
<div>{comment.text}</div>
|
||||||
<div className={'text-disabled-text'}>
|
|
||||||
{resentOrDate(new Date(comment.createdAt).getTime())}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<BottomSectionContainer disableComments={comments.length > 5} />
|
<BottomSectionContainer
|
||||||
|
unloggedLimit={comments.length > 5}
|
||||||
|
loggedLimit={comments.length > 25}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BottomSection({ loggedIn, userEmail, disableComments }: { disableComments: boolean, loggedIn?: boolean, userEmail?: string }) {
|
function BottomSection({
|
||||||
|
loggedIn,
|
||||||
|
userEmail,
|
||||||
|
unloggedLimit,
|
||||||
|
loggedLimit,
|
||||||
|
}: {
|
||||||
|
loggedLimit: boolean;
|
||||||
|
unloggedLimit: boolean;
|
||||||
|
loggedIn?: boolean;
|
||||||
|
userEmail?: string;
|
||||||
|
}) {
|
||||||
const [commentText, setCommentText] = React.useState('');
|
const [commentText, setCommentText] = React.useState('');
|
||||||
const [userName, setUserName] = React.useState<string>(userEmail ?? '');
|
const [userName, setUserName] = React.useState<string>(userEmail ?? '');
|
||||||
const { spotStore } = useStore();
|
const { spotStore } = useStore();
|
||||||
|
|
||||||
const addComment = async () => {
|
const addComment = async () => {
|
||||||
await spotStore.addComment(
|
try {
|
||||||
spotStore.currentSpot!.spotId,
|
await spotStore.addComment(
|
||||||
commentText,
|
spotStore.currentSpot!.spotId,
|
||||||
userName
|
commentText,
|
||||||
);
|
userName
|
||||||
setCommentText('');
|
);
|
||||||
|
setCommentText('');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Failed to add comment; Try again later');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableSubmit = commentText.trim().length === 0 || userName.trim().length === 0 || disableComments
|
const unlogged = userName.trim().length === 0 && unloggedLimit
|
||||||
|
const disableSubmit =
|
||||||
|
commentText.trim().length === 0 ||
|
||||||
|
unlogged || loggedLimit;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-4 mt-auto',
|
'mt-auto border-t p-2',
|
||||||
loggedIn ? 'bg-white' : 'bg-active-dark-blue'
|
loggedIn ? 'bg-white' : 'bg-active-dark-blue'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div className={'flex flex-col w-full gap-2'}>
|
<div className={'flex flex-col w-full gap-2'}>
|
||||||
<Input
|
<Input
|
||||||
readOnly={loggedIn}
|
readOnly={loggedIn}
|
||||||
disabled={loggedIn}
|
disabled={loggedIn}
|
||||||
placeholder={'Add a name'}
|
placeholder={'Add a name'}
|
||||||
required
|
required
|
||||||
className={'w-full'}
|
className={'w-full disabled:hidden'}
|
||||||
value={userName}
|
value={userName}
|
||||||
onChange={(e) => setUserName(e.target.value)}
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
className={'w-full'}
|
className={'w-full'}
|
||||||
rows={3}
|
rows={3}
|
||||||
autoSize={{ minRows: 3, maxRows: 3 }}
|
autoSize={{ minRows: 3, maxRows: 3 }}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={(e) => {
|
||||||
/>
|
e.preventDefault();
|
||||||
</div>
|
e.stopPropagation();
|
||||||
<Tooltip title={!disableComments ? "" : "Limited to 5 Messages. Join team to send more."}>
|
setCommentText(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
!disableSubmit
|
||||||
|
? ''
|
||||||
|
: unlogged ? 'Limited to 5 Messages. Join team to send more.' : 'Limited to 25 Messages.'
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type={'primary'}
|
type={'primary'}
|
||||||
onClick={addComment}
|
onClick={addComment}
|
||||||
disabled={disableSubmit}
|
disabled={disableSubmit}
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined className="ps-0.5" />}
|
||||||
shape={"circle"}
|
shape={'circle'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ import spotPlayerStore from '../../spotPlayerStore';
|
||||||
function SpotConsole({ onClose }: { onClose: () => void }) {
|
function SpotConsole({ onClose }: { onClose: () => void }) {
|
||||||
const [activeTab, setActiveTab] = React.useState(TABS[0]);
|
const [activeTab, setActiveTab] = React.useState(TABS[0]);
|
||||||
const _list = React.useRef<VListHandle>(null);
|
const _list = React.useRef<VListHandle>(null);
|
||||||
const onTabClick = (tab: any) => {
|
|
||||||
setActiveTab(tab);
|
const onTabClick = (tab: string) => {
|
||||||
|
const newTab = TABS.find((t) => t.text === tab);
|
||||||
|
setActiveTab(newTab);
|
||||||
};
|
};
|
||||||
const logs = spotPlayerStore.logs;
|
const logs = spotPlayerStore.logs;
|
||||||
const filteredList = React.useMemo(() => {
|
const filteredList = React.useMemo(() => {
|
||||||
|
|
@ -29,7 +31,7 @@ function SpotConsole({ onClose }: { onClose: () => void }) {
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const jump = (t: number) => {
|
const jump = (t: number) => {
|
||||||
spotPlayerStore.setTime(t);
|
spotPlayerStore.setTime(t / 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -48,7 +50,7 @@ function SpotConsole({ onClose }: { onClose: () => void }) {
|
||||||
<BottomBlock.Content className={'overflow-y-auto'}>
|
<BottomBlock.Content className={'overflow-y-auto'}>
|
||||||
<NoContent
|
<NoContent
|
||||||
title={
|
title={
|
||||||
<div className="capitalize flex items-center mt-16">
|
<div className="capitalize flex items-center">
|
||||||
<Icon name="info-circle" className="mr-2" size="18" />
|
<Icon name="info-circle" className="mr-2" size="18" />
|
||||||
No Data
|
No Data
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function SpotNetwork({ panelHeight, onClose }: { panelHeight: number, onClose: (
|
||||||
websocketListNow={[]}
|
websocketListNow={[]}
|
||||||
/* @ts-ignore */
|
/* @ts-ignore */
|
||||||
player={{ jump: (t) => spotPlayerStore.setTime(t) }}
|
player={{ jump: (t) => spotPlayerStore.setTime(t) }}
|
||||||
activeIndex={index}
|
activeOutsideIndex={index}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
import { TYPES } from 'Types/session/event';
|
import { TYPES } from 'Types/session/event';
|
||||||
import { X } from 'lucide-react';
|
import { Button } from 'antd';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
|
@ -28,14 +29,14 @@ function SpotActivity({ onClose }: { onClose: () => void }) {
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={'h-full bg-white border border-gray-light'}
|
className={'h-full bg-white border-l'}
|
||||||
style={{ minWidth: 320, width: 320 }}
|
style={{ minWidth: 320, width: 320 }}
|
||||||
>
|
>
|
||||||
<div className={'flex items-center justify-between p-4'}>
|
<div className={'flex items-center justify-between p-4'}>
|
||||||
<div className={'font-semibold'}>Activity</div>
|
<div className={'font-medium text-lg'}>Activity</div>
|
||||||
<div onClick={onClose} className={'p-1 cursor-pointer'}>
|
<Button type="text" size="small" onClick={onClose}>
|
||||||
<X size={16} />
|
<CloseOutlined />
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={'overflow-y-auto'}
|
className={'overflow-y-auto'}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,21 @@ import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
|
import {Link2} from 'lucide-react';
|
||||||
import spotPlayerStore from '../spotPlayerStore';
|
import spotPlayerStore from '../spotPlayerStore';
|
||||||
|
|
||||||
function SpotLocation() {
|
function SpotLocation() {
|
||||||
const currUrl = spotPlayerStore.getClosestLocation(
|
const currUrl = spotPlayerStore.getClosestLocation(
|
||||||
spotPlayerStore.time
|
spotPlayerStore.time
|
||||||
)?.location;
|
)?.location;
|
||||||
|
const displayUrl = currUrl.length > 170 ? `${currUrl.slice(0, 170)}...` : currUrl;
|
||||||
return (
|
return (
|
||||||
<div className={'w-full bg-white border-b border-gray-lighter'}>
|
<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">
|
<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" />
|
<Link2 className="mx-2" size={16} />
|
||||||
<Tooltip title="Open in new tab">
|
<Tooltip title="Open in new tab" placement='bottom'>
|
||||||
<a href={currUrl} target="_blank" className="truncate">
|
<a href={currUrl} target="_blank" className="truncate">
|
||||||
{currUrl}
|
{displayUrl}
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,11 @@ function SpotPlayerControls() {
|
||||||
|
|
||||||
<div className={'ml-auto'} />
|
<div className={'ml-auto'} />
|
||||||
|
|
||||||
|
<ControlButton
|
||||||
|
label={'X-Ray'}
|
||||||
|
onClick={() => togglePanel(PANELS.OVERVIEW)}
|
||||||
|
active={spotPlayerStore.activePanel === PANELS.OVERVIEW}
|
||||||
|
/>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
label={'Console'}
|
label={'Console'}
|
||||||
onClick={() => togglePanel(PANELS.CONSOLE)}
|
onClick={() => togglePanel(PANELS.CONSOLE)}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
import {
|
import { ArrowLeftOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined, MoreOutlined, SettingOutlined, UserSwitchOutlined } from '@ant-design/icons';
|
||||||
ArrowLeftOutlined,
|
import { Badge, Button, Dropdown, MenuProps, Popover, Tooltip, message } from 'antd';
|
||||||
CommentOutlined,
|
|
||||||
LinkOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
UserSwitchOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { Button, Popover } from 'antd';
|
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import React from 'react';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React, { useState } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
import { spotsList } from 'App/routes';
|
import { spotsList } from 'App/routes';
|
||||||
import { hashString } from 'App/types/session/session';
|
import { hashString } from 'App/types/session/session';
|
||||||
import { Avatar, Icon } from 'UI';
|
import { Avatar, Icon } from 'UI';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { TABS, Tab } from '../consts';
|
import { TABS, Tab } from '../consts';
|
||||||
import AccessModal from './AccessModal';
|
import AccessModal from './AccessModal';
|
||||||
|
|
||||||
|
|
||||||
const spotLink = spotsList();
|
const spotLink = spotsList();
|
||||||
|
|
||||||
function SpotPlayerHeader({
|
function SpotPlayerHeader({
|
||||||
|
|
@ -43,53 +44,96 @@ function SpotPlayerHeader({
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
hasShareAccess: boolean;
|
hasShareAccess: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [isCopied, setIsCopied] = React.useState(false);
|
const { spotStore } = useStore();
|
||||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
const comments = spotStore.currentSpot?.comments ?? [];
|
||||||
|
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const onCopy = () => {
|
const onCopy = () => {
|
||||||
setIsCopied(true);
|
|
||||||
copy(window.location.href);
|
copy(window.location.href);
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
message.success('Internal sharing link copied to clipboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigateToSpotsList = () => {
|
||||||
|
history.push(spotLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
icon: <DownloadOutlined />,
|
||||||
|
label: 'Download Video',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
label: 'Delete',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const onMenuClick = async ({ key }: { key: string }) => {
|
||||||
|
if (key === '1') {
|
||||||
|
const { url } = await spotStore.getVideo(spotStore.currentSpot!.spotId);
|
||||||
|
await downloadFile(url, `${spotStore.currentSpot!.title}.webm`)
|
||||||
|
} else if (key === '2') {
|
||||||
|
spotStore.deleteSpot([spotStore.currentSpot!.spotId]).then(() => {
|
||||||
|
history.push(spotsList());
|
||||||
|
message.success('Spot deleted successfully');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={'flex items-center gap-1 p-2 py-1 w-full bg-white border-b'}
|
||||||
'flex items-center gap-4 p-4 w-full bg-white border-b border-gray-light'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<Link to={spotLink}>
|
<Button
|
||||||
<div className={'flex items-center gap-2'}>
|
type="text"
|
||||||
<ArrowLeftOutlined />
|
onClick={navigateToSpotsList}
|
||||||
<div className={'font-semibold'}>All Spots</div>
|
icon={<ArrowLeftOutlined />}
|
||||||
</div>
|
className="px-2"
|
||||||
</Link>
|
>
|
||||||
|
All Spots
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={'flex items-center gap-2'}>
|
<a href="https://openreplay.com/spot" target="_blank">
|
||||||
<Icon name={'orSpot'} size={24} />
|
<Button
|
||||||
<div className={'text-lg font-semibold'}>Spot</div>
|
type="text"
|
||||||
</div>
|
className="orSpotBranding flex gap-1 items-center py-2"
|
||||||
<div className={'text-disabled-text text-xs'}>by OpenReplay</div>
|
>
|
||||||
|
<Icon name={'orSpot'} size={28} />
|
||||||
|
<div className="flex flex-col justify-start text-start">
|
||||||
|
<div className={'text-lg font-semibold'}>Spot</div>
|
||||||
|
<div className={'text-disabled-text text-xs -mt-1'}>
|
||||||
|
by OpenReplay
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={'h-full rounded-xl border-l mr-2'} style={{ width: 1 }} />
|
||||||
className={'h-full rounded-xl bg-gray-light mx-2'}
|
|
||||||
style={{ width: 1 }}
|
|
||||||
/>
|
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<Avatar seed={hashString(user)} />
|
<Avatar seed={hashString(user)} />
|
||||||
<div>
|
<div>
|
||||||
<div>{title}</div>
|
<Tooltip title={title}>
|
||||||
<div className={'flex items-center gap-2 text-disabled-text'}>
|
<div className="w-9/12 text-ellipsis truncate cursor-normal">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div className={'flex items-center gap-2 text-black/50 text-sm'}>
|
||||||
<div>{user}</div>
|
<div>{user}</div>
|
||||||
<div>·</div>
|
<div>·</div>
|
||||||
<div>{date}</div>
|
<div className="capitalize">{date}</div>
|
||||||
{browserVersion && (
|
{browserVersion && (
|
||||||
<>
|
<>
|
||||||
<div>·</div>
|
<div>·</div>
|
||||||
<div>Chrome v{browserVersion}</div>
|
<div className="capitalize">Chrome v{browserVersion}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{resolution && (
|
{resolution && (
|
||||||
|
|
@ -101,7 +145,7 @@ function SpotPlayerHeader({
|
||||||
{platform && (
|
{platform && (
|
||||||
<>
|
<>
|
||||||
<div>·</div>
|
<div>·</div>
|
||||||
<div>{platform}</div>
|
<div className="capitalize">{platform}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,24 +157,30 @@ function SpotPlayerHeader({
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
type={'primary'}
|
type={'default'}
|
||||||
icon={<LinkOutlined />}
|
icon={<CopyOutlined />}
|
||||||
>
|
>
|
||||||
{isCopied ? 'Copied!' : 'Copy Link'}
|
Copy
|
||||||
</Button>
|
</Button>
|
||||||
{hasShareAccess ? (
|
{hasShareAccess ? (
|
||||||
<Popover open={dropdownOpen} content={<AccessModal />}>
|
<Popover trigger={'click'} content={<AccessModal />}>
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
>
|
>
|
||||||
Manage Access
|
Manage Access
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items, onClick: onMenuClick }}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button icon={<MoreOutlined />} size={'small'}></Button>
|
||||||
|
</Dropdown>
|
||||||
<div
|
<div
|
||||||
className={'h-full rounded-xl bg-gray-light mx-2'}
|
className={'h-full rounded-xl border-l mx-2'}
|
||||||
style={{ width: 1 }}
|
style={{ width: 1 }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
@ -143,18 +193,44 @@ function SpotPlayerHeader({
|
||||||
>
|
>
|
||||||
Activity
|
Activity
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
disabled={activeTab === TABS.COMMENTS}
|
disabled={activeTab === TABS.COMMENTS}
|
||||||
onClick={() => setActiveTab(TABS.COMMENTS)}
|
onClick={() => setActiveTab(TABS.COMMENTS)}
|
||||||
icon={<CommentOutlined />}
|
icon={<CommentOutlined />}
|
||||||
>
|
>
|
||||||
Comments
|
Comments
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<Badge count={comments.length} className="mr-2" style={{ fontSize: '10px' }} size='small' color='#454545' />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</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 connect((state: any) => {
|
export default connect((state: any) => {
|
||||||
const jwt = state.getIn(['user', 'jwt']);
|
const jwt = state.getIn(['user', 'jwt']);
|
||||||
const isEE = state.getIn(['user', 'account', 'edition']) === 'ee';
|
const isEE = state.getIn(['user', 'account', 'edition']) === 'ee';
|
||||||
|
|
@ -163,4 +239,4 @@ export default connect((state: any) => {
|
||||||
|
|
||||||
const hasShareAccess = isEE ? permissions.includes('SPOT_PUBLIC') : true;
|
const hasShareAccess = isEE ? permissions.includes('SPOT_PUBLIC') : true;
|
||||||
return { isLoggedIn: !!jwt, hasShareAccess };
|
return { isLoggedIn: !!jwt, hasShareAccess };
|
||||||
})(SpotPlayerHeader);
|
})(observer(SpotPlayerHeader));
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Hls from 'hls.js';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
|
@ -19,84 +20,109 @@ function SpotVideoContainer({
|
||||||
videoURL,
|
videoURL,
|
||||||
streamFile,
|
streamFile,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
checkReady,
|
||||||
}: {
|
}: {
|
||||||
videoURL: string;
|
videoURL: string;
|
||||||
streamFile?: string;
|
streamFile?: string;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
|
checkReady: () => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const [videoLink, setVideoLink] = React.useState<string>(videoURL);
|
const [prevIsProcessing, setPrevIsProcessing] = React.useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
const { spotStore } = useStore();
|
|
||||||
const [isLoaded, setLoaded] = React.useState(false);
|
const [isLoaded, setLoaded] = React.useState(false);
|
||||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||||
const playbackTime = React.useRef(0);
|
const playbackTime = React.useRef(0);
|
||||||
const hlsRef = React.useRef<Hls | null>(null);
|
const hlsRef = React.useRef<Hls | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
import('hls.js').then(({ default: Hls }) => {
|
const startPlaying = () => {
|
||||||
if (Hls.isSupported() && videoRef.current) {
|
if (spotPlayerStore.isPlaying && videoRef.current) {
|
||||||
videoRef.current.addEventListener('loadeddata', () => {
|
videoRef.current
|
||||||
setLoaded(true);
|
.play()
|
||||||
});
|
.then(() => {
|
||||||
if (streamFile) {
|
console.debug('playing');
|
||||||
const hls = new Hls({
|
})
|
||||||
enableWorker: false,
|
.catch((e) => {
|
||||||
// workerPath: '/hls-worker.js',
|
console.error(e);
|
||||||
// 1MB buffer -- we have small videos anyways
|
spotPlayerStore.setIsPlaying(false);
|
||||||
maxBufferSize: 1000 * 1000,
|
const onClick = () => {
|
||||||
|
spotPlayerStore.setIsPlaying(true);
|
||||||
|
document.removeEventListener('click', onClick);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onClick);
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(base64toblob(streamFile));
|
}
|
||||||
if (url && videoRef.current) {
|
};
|
||||||
hls.loadSource(url);
|
checkReady().then((isReady) => {
|
||||||
hls.attachMedia(videoRef.current);
|
if (!isReady) {
|
||||||
if (spotPlayerStore.isPlaying) {
|
setIsProcessing(true);
|
||||||
void videoRef.current.play();
|
setPrevIsProcessing(true);
|
||||||
|
const int = setInterval(() => {
|
||||||
|
checkReady().then((r) => {
|
||||||
|
if (r) {
|
||||||
|
setIsProcessing(false);
|
||||||
|
clearInterval(int);
|
||||||
}
|
}
|
||||||
hlsRef.current = hls;
|
});
|
||||||
} else {
|
}, 5000)
|
||||||
if (videoRef.current) {
|
}
|
||||||
videoRef.current.src = videoURL;
|
import('hls.js').then(({ default: Hls }) => {
|
||||||
if (spotPlayerStore.isPlaying) {
|
if (Hls.isSupported() && videoRef.current) {
|
||||||
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', () => {
|
videoRef.current.addEventListener('loadeddata', () => {
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
});
|
});
|
||||||
videoRef.current.src = videoURL;
|
if (streamFile) {
|
||||||
if (spotPlayerStore.isPlaying) {
|
const hls = new Hls({
|
||||||
void videoRef.current.play();
|
// not needed for small videos (we have 3 min limit and 720 quality with half kbps)
|
||||||
|
enableWorker: false,
|
||||||
|
// = 1MB, should be enough
|
||||||
|
maxBufferSize: 1000 * 1000,
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(base64toblob(streamFile));
|
||||||
|
if (url && videoRef.current) {
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(videoRef.current);
|
||||||
|
startPlaying();
|
||||||
|
hlsRef.current = hls;
|
||||||
|
} else {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.src = videoURL;
|
||||||
|
startPlaying();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const check = () => {
|
||||||
|
fetch(videoURL).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;
|
||||||
|
startPlaying();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.addEventListener('loadeddata', () => {
|
||||||
|
setLoaded(true);
|
||||||
|
});
|
||||||
|
videoRef.current.src = videoURL;
|
||||||
|
startPlaying();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
hlsRef.current?.destroy();
|
hlsRef.current?.destroy();
|
||||||
|
|
@ -143,29 +169,46 @@ function SpotVideoContainer({
|
||||||
videoRef.current.playbackRate = spotPlayerStore.playbackRate;
|
videoRef.current.playbackRate = spotPlayerStore.playbackRate;
|
||||||
}
|
}
|
||||||
}, [spotPlayerStore.playbackRate]);
|
}, [spotPlayerStore.playbackRate]);
|
||||||
|
|
||||||
|
const warnText = isProcessing ? 'You’re viewing the entire recording. The trimmed Spot is on its way.' : 'Your trimmed Spot is ready! Please reload the page.'
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{isProcessing || prevIsProcessing
|
||||||
|
? <div
|
||||||
|
className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
zIndex: 999,
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '-24px',
|
||||||
|
transform: 'translate(-50%, 0)',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{warnText}
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
{!isLoaded && (
|
||||||
|
<div className="relative w-full h-full flex flex-col items-center justify-center bg-white/50">
|
||||||
|
<img
|
||||||
|
src={'/assets/img/videoProcessing.svg'}
|
||||||
|
alt={'Processing video..'}
|
||||||
|
width={75}
|
||||||
|
className="mb-5"
|
||||||
|
/>
|
||||||
|
<div className={'text-2xl font-bold'}>Loading Spot Recording</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
poster={thumbnail}
|
poster={thumbnail}
|
||||||
|
autoPlay
|
||||||
className={
|
className={
|
||||||
'object-contain absolute top-0 left-0 w-full h-full bg-gray-lightest cursor-pointer'
|
'object-contain absolute top-0 left-0 w-full h-full bg-gray-lightest cursor-pointer'
|
||||||
}
|
}
|
||||||
onClick={() => spotPlayerStore.setIsPlaying(!spotPlayerStore.isPlaying)}
|
onClick={() => spotPlayerStore.setIsPlaying(!spotPlayerStore.isPlaying)}
|
||||||
|
style={{ display: isLoaded ? 'block' : 'none' }}
|
||||||
/>
|
/>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ const mapSpotNetworkToEv = (ev: SpotNetworkRequest): any => {
|
||||||
export const PANELS = {
|
export const PANELS = {
|
||||||
CONSOLE: 'CONSOLE',
|
CONSOLE: 'CONSOLE',
|
||||||
NETWORK: 'NETWORK',
|
NETWORK: 'NETWORK',
|
||||||
|
OVERVIEW: 'OVERVIEW',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PanelType = keyof typeof PANELS;
|
export type PanelType = keyof typeof PANELS;
|
||||||
|
|
@ -78,13 +79,13 @@ class SpotPlayerStore {
|
||||||
time = 0;
|
time = 0;
|
||||||
duration = 0;
|
duration = 0;
|
||||||
durationString = '';
|
durationString = '';
|
||||||
isPlaying = false;
|
isPlaying = true;
|
||||||
state = PlayingState.Paused
|
state = PlayingState.Playing;
|
||||||
isMuted = false;
|
isMuted = false;
|
||||||
volume = 1;
|
volume = 1;
|
||||||
playbackRate = 1;
|
playbackRate = 1;
|
||||||
isFullScreen = false;
|
isFullScreen = false;
|
||||||
logs: typeof PLog[] = [];
|
logs: ReturnType<typeof PLog>[] = [];
|
||||||
locations: Location[] = [];
|
locations: Location[] = [];
|
||||||
clicks: Click[] = [];
|
clicks: Click[] = [];
|
||||||
network: ReturnType<typeof getResourceFromNetworkRequest>[] = [];
|
network: ReturnType<typeof getResourceFromNetworkRequest>[] = [];
|
||||||
|
|
@ -103,7 +104,8 @@ class SpotPlayerStore {
|
||||||
this.time = 0;
|
this.time = 0;
|
||||||
this.duration = 0;
|
this.duration = 0;
|
||||||
this.durationString = '';
|
this.durationString = '';
|
||||||
this.isPlaying = false;
|
this.isPlaying = true;
|
||||||
|
this.state = PlayingState.Playing;
|
||||||
this.isMuted = false;
|
this.isMuted = false;
|
||||||
this.volume = 1;
|
this.volume = 1;
|
||||||
this.playbackRate = 1;
|
this.playbackRate = 1;
|
||||||
|
|
@ -188,9 +190,9 @@ class SpotPlayerStore {
|
||||||
this.locations = locations.map((location) => ({
|
this.locations = locations.map((location) => ({
|
||||||
...location,
|
...location,
|
||||||
time: location.time - this.startTs,
|
time: location.time - this.startTs,
|
||||||
fcpTime: location.navTiming.fcpTime,
|
fcpTime: location.navTiming.fcpTime ? Math.round(location.navTiming.fcpTime) : null,
|
||||||
timeToInteractive: location.navTiming.timeToInteractive,
|
timeToInteractive: location.navTiming.timeToInteractive ? Math.round(location.navTiming.timeToInteractive) : null,
|
||||||
visuallyComplete: location.navTiming.visuallyComplete,
|
visuallyComplete: location.navTiming.visuallyComplete ? Math.round(location.navTiming.visuallyComplete) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.clicks = clicks.map((click) => ({
|
this.clicks = clicks.map((click) => ({
|
||||||
|
|
|
||||||
92
frontend/app/components/Spots/SpotsList/EmptyPage.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { ChromeOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
import { Alert, Badge, Button } from 'antd';
|
||||||
|
import { ArrowUpRight, CirclePlay } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function EmptyPage() {
|
||||||
|
const extKey = '__$spot_ext_exist$__';
|
||||||
|
const [extExist, setExtExist] = React.useState<boolean>(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
let int: any;
|
||||||
|
const v = localStorage.getItem(extKey);
|
||||||
|
if (v) {
|
||||||
|
setExtExist(true);
|
||||||
|
} else {
|
||||||
|
int = setInterval(() => {
|
||||||
|
window.postMessage({ type: 'orspot:ping' }, '*');
|
||||||
|
});
|
||||||
|
const onSpotMsg = (e) => {
|
||||||
|
if (e.data.type === 'orspot:pong') {
|
||||||
|
setExtExist(true);
|
||||||
|
localStorage.setItem(extKey, '1');
|
||||||
|
clearInterval(int);
|
||||||
|
int = null;
|
||||||
|
window.removeEventListener('message', onSpotMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onSpotMsg);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (int) {
|
||||||
|
clearInterval(int);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex flex-col gap-4 items-center w-full p-8 bg-white rounded-b-lg shadow-sm'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={'font-semibold text-2xl'}>Spot your first bug.</div>
|
||||||
|
<Button type="link">
|
||||||
|
<CirclePlay /> Watch How
|
||||||
|
</Button>
|
||||||
|
<div>Your recordings will appear here.</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'bg-white shadow-sm rounded-lg p-8 mt-4 w-full flex flex-col gap-4 items-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{extExist ? null : (
|
||||||
|
<Alert
|
||||||
|
message="It looks like you haven’t installed the Spot extension yet."
|
||||||
|
type="warning"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ChromeOutlined />}
|
||||||
|
className="text-lg"
|
||||||
|
>
|
||||||
|
Get Chrome Extension <ArrowUpRight />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
className="w-3/4 justify-between font-medium text-lg rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={'flex gap-4 w-full justify-center'}>
|
||||||
|
<div className={'border rounded bg-cyan-50'}>
|
||||||
|
<img src={'assets/img/spot1.svg'} alt={'pin spot'} width={400} />
|
||||||
|
<div className={'flex items-center gap-2 text-lg p-4 justify-center'}>
|
||||||
|
<Badge count={1} color="cyan" /> Pin Spot extension
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={'border rounded bg-indigo-50'}>
|
||||||
|
<img
|
||||||
|
src={'assets/img/spot2.svg'}
|
||||||
|
alt={'start recording'}
|
||||||
|
width={400}
|
||||||
|
/>
|
||||||
|
<div className={'flex items-center gap-2 text-lg p-4 justify-center'}>
|
||||||
|
<Badge count={2} color="cyan" /> Capture and share a bug
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyPage;
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
import { CopyOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, GlobalOutlined, MessageOutlined, MoreOutlined, SlackOutlined } from '@ant-design/icons';
|
import {
|
||||||
import { Button, Checkbox, Dropdown } from 'antd';
|
ClockCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
SlackOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Checkbox, Dropdown, Tooltip } from 'antd';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import React from 'react';
|
import { Link2 } from 'lucide-react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { Spot } from 'App/mstore/types/spot';
|
import { Spot } from 'App/mstore/types/spot';
|
||||||
import { spot as spotUrl, withSiteId } from 'App/routes';
|
import { spot as spotUrl, withSiteId } from 'App/routes';
|
||||||
|
|
||||||
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
|
import EditItemModal from './EditItemModal';
|
||||||
|
|
||||||
import EditItemModal from "./EditItemModal";
|
const backgroundUrl = '/assets/img/spotThumbBg.svg';
|
||||||
|
|
||||||
|
|
||||||
interface ISpotListItem {
|
interface ISpotListItem {
|
||||||
spot: Spot;
|
spot: Spot;
|
||||||
|
|
@ -21,12 +30,23 @@ interface ISpotListItem {
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onVideo: (id: string) => Promise<{ url: string }>;
|
onVideo: (id: string) => Promise<{ url: string }>;
|
||||||
onSelect: (selected: boolean) => void;
|
onSelect: (selected: boolean) => void;
|
||||||
|
isSelected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotListItem) {
|
function SpotListItem({
|
||||||
const [isEdit, setIsEdit] = React.useState(false)
|
spot,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onVideo,
|
||||||
|
onSelect,
|
||||||
|
isSelected,
|
||||||
|
}: ISpotListItem) {
|
||||||
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tooltipText, setTooltipText] = useState('Copy link to clipboard');
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { siteId } = useParams<{ siteId: string }>();
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
|
|
@ -38,17 +58,13 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
|
||||||
label: 'Download Video',
|
label: 'Download Video',
|
||||||
icon: <DownloadOutlined />,
|
icon: <DownloadOutlined />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'copy',
|
|
||||||
label: 'Copy Spot URL',
|
|
||||||
icon: <CopyOutlined />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
icon: <DeleteOutlined />,
|
icon: <DeleteOutlined />,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
menuItems.splice(1, 0, {
|
menuItems.splice(1, 0, {
|
||||||
key: 'slack',
|
key: 'slack',
|
||||||
|
|
@ -59,13 +75,18 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
|
||||||
const onMenuClick = async ({ key }: any) => {
|
const onMenuClick = async ({ key }: any) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
return setIsEdit(true)
|
return setIsEdit(true);
|
||||||
case 'download':
|
case 'download':
|
||||||
const { url } = await onVideo(spot.spotId)
|
const { url } = await onVideo(spot.spotId);
|
||||||
await downloadFile(url, `${spot.title}.webm`)
|
await downloadFile(url, `${spot.title}.webm`);
|
||||||
return;
|
return;
|
||||||
case 'copy':
|
case 'copy':
|
||||||
copy(`${window.location.origin}${withSiteId(spotUrl(spot.spotId.toString()), siteId)}`);
|
copy(
|
||||||
|
`${window.location.origin}${withSiteId(
|
||||||
|
spotUrl(spot.spotId.toString()),
|
||||||
|
siteId
|
||||||
|
)}`
|
||||||
|
);
|
||||||
return toast.success('Spot URL copied to clipboard');
|
return toast.success('Spot URL copied to clipboard');
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return onDelete();
|
return onDelete();
|
||||||
|
|
@ -75,6 +96,7 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSpotClick = (e: any) => {
|
const onSpotClick = (e: any) => {
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||||
const spotLink = withSiteId(spotUrl(spot.spotId.toString()), siteId);
|
const spotLink = withSiteId(spotUrl(spot.spotId.toString()), siteId);
|
||||||
|
|
@ -85,50 +107,114 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
const spotLink = withSiteId(spotUrl(spot.spotId.toString()), siteId);
|
||||||
|
const fullLink = `${window.location.origin}${spotLink}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(fullLink)
|
||||||
|
.then(() => {
|
||||||
|
setTooltipText('Link copied to clipboard!');
|
||||||
|
setTimeout(() => setTooltipText('Copy link to clipboard'), 2000); // Reset tooltip text after 2 seconds
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setTooltipText('Failed to copy URL');
|
||||||
|
setTimeout(() => setTooltipText('Copy link to clipboard'), 2000); // Reset tooltip text after 2 seconds
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onSave = (newName: string) => {
|
const onSave = (newName: string) => {
|
||||||
onRename(spot.spotId, newName);
|
onRename(spot.spotId, newName);
|
||||||
setIsEdit(false);
|
setIsEdit(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={`bg-white rounded-lg overflow-hidden shadow-sm border ${
|
||||||
'border rounded-xl overflow-hidden flex flex-col items-start hover:shadow'
|
isSelected ? 'border-teal/30' : 'border-transparent'
|
||||||
}
|
} transition flex flex-col items-start hover:border-teal`}
|
||||||
>
|
>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
<EditItemModal onSave={onSave} onClose={() => setIsEdit(false)} itemName={spot.title} />
|
<EditItemModal
|
||||||
) : null}
|
onSave={onSave}
|
||||||
<div style={{ cursor: 'pointer', width: '100%', height: 180, position: 'relative' }} onClick={onSpotClick}>
|
onClose={() => setIsEdit(false)}
|
||||||
<img
|
itemName={spot.title}
|
||||||
src={spot.thumbnail}
|
|
||||||
alt={spot.title}
|
|
||||||
className={'w-full h-full object-cover'}
|
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="relative group overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 180,
|
||||||
|
backgroundImage: `url(${backgroundUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<AnimatedSVG name={ICONS.LOADER} size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
className="block w-full h-full cursor-pointer transition hover:bg-teal/70 relative"
|
||||||
'absolute bottom-4 right-4 bg-black text-white p-1 rounded'
|
onClick={onSpotClick}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{spot.duration}
|
<img
|
||||||
|
src={spot.thumbnail}
|
||||||
|
alt={spot.title}
|
||||||
|
className={'w-full h-full object-cover opacity-80'}
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={() => setLoading(false)}
|
||||||
|
style={{ display: loading ? 'none' : 'block' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 scale-75 transition-all hover:scale-100 hover:transition-all group-hover:opacity-100 transition-opacity ">
|
||||||
|
<PlayCircleOutlined
|
||||||
|
style={{ fontSize: '48px', color: 'white' }}
|
||||||
|
className="bg-teal/50 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
|
||||||
|
<Tooltip title={tooltipText}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg transition-transform transform translate-y-14 group-hover:translate-y-0 '
|
||||||
|
}
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Link2 size={16} strokeWidth={1} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg flex items-center cursor-normal'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{spot.duration}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={'px-2 py-4 w-full'}>
|
<div className={'px-4 py-4 w-full border-t'}>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
<div>
|
<Checkbox
|
||||||
<Checkbox onChange={({ target: { checked }}) => onSelect(checked)} />
|
checked={isSelected}
|
||||||
</div>
|
onChange={({ target: { checked } }) => onSelect(checked)}
|
||||||
<div className={'cursor-pointer'} onClick={onSpotClick}>{spot.title}</div>
|
className={`flex cursor-pointer w-full hover:text-teal ${isSelected ? 'text-teal' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="w-full text-nowrap text-ellipsis overflow-hidden max-w-80 mb-0 block">
|
||||||
|
{spot.title}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={'flex items-center gap-1 leading-4 text-xs opacity-50'}>
|
||||||
className={'flex items-center gap-2 text-disabled-text leading-4'}
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<GlobalOutlined />
|
<UserOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div>{spot.user}</div>
|
<div>{spot.user}</div>
|
||||||
<div>
|
<div className="ms-4">
|
||||||
<MessageOutlined />
|
<ClockCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div>{spot.createdAt}</div>
|
<div>{spot.createdAt}</div>
|
||||||
<div className={'ml-auto'}>
|
<div className={'ml-auto'}>
|
||||||
|
|
|
||||||
114
frontend/app/components/Spots/SpotsList/SpotsListHeader.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Button, Input, Segmented } from 'antd';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { downgradeScope } from 'App/duck/user';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { debounce } from 'App/utils';
|
||||||
|
import { Icon } from 'UI';
|
||||||
|
|
||||||
|
const DebugDowngrade = connect(null, { downgradeScope })(
|
||||||
|
({ downgradeScope }: any) => (
|
||||||
|
<Button onClick={downgradeScope}>DEBUG: downgrade account scope</Button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const SpotsListHeader = observer(
|
||||||
|
({
|
||||||
|
onDelete,
|
||||||
|
selectedCount,
|
||||||
|
onClearSelection,
|
||||||
|
isEmpty,
|
||||||
|
toggleEmptyState,
|
||||||
|
isEmptyState,
|
||||||
|
}: {
|
||||||
|
onDelete: () => void;
|
||||||
|
selectedCount: number;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
toggleEmptyState?: () => void;
|
||||||
|
isEmptyState?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { spotStore } = useStore();
|
||||||
|
|
||||||
|
const debouncedFetch = React.useMemo(
|
||||||
|
() => debounce(spotStore.fetchSpots, 250),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const onSearch = (value: string) => {
|
||||||
|
spotStore.setQuery(value);
|
||||||
|
void spotStore.fetchSpots();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
spotStore.setQuery(e.target.value);
|
||||||
|
debouncedFetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterChange = (key: 'all' | 'own') => {
|
||||||
|
spotStore.setFilter(key);
|
||||||
|
void spotStore.fetchSpots();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSegmentChange = (value: string) => {
|
||||||
|
const key = value === 'All Spots' ? 'all' : 'own';
|
||||||
|
onFilterChange(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center justify-between w-full'}>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<Icon name={'orSpot'} size={24} />
|
||||||
|
<h1 className={'text-2xl capitalize mr-2'}>Spot List</h1>
|
||||||
|
<Button onClick={toggleEmptyState}>
|
||||||
|
DEBUG: empty state {isEmptyState ? 'ON' : 'OFF'}
|
||||||
|
</Button>
|
||||||
|
<DebugDowngrade />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEmpty ? null : (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className={'ml-auto'}>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="mr-2 px-3"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onDelete} type="primary" ghost>
|
||||||
|
Delete ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Segmented
|
||||||
|
options={['All Spots', 'My Spots']}
|
||||||
|
value={spotStore.filter === 'all' ? 'All Spots' : 'My Spots'}
|
||||||
|
onChange={handleSegmentChange}
|
||||||
|
className="mr-4 lg:hidden xl:flex"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-56">
|
||||||
|
<Input.Search
|
||||||
|
value={spotStore.query}
|
||||||
|
allowClear
|
||||||
|
name="spot-search"
|
||||||
|
placeholder="Filter by title"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onSearch={onSearch}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SpotsListHeader;
|
||||||
|
|
@ -1,83 +1,22 @@
|
||||||
import { DownOutlined } from '@ant-design/icons';
|
import { message } from 'antd';
|
||||||
import { Button, Dropdown, Input } from 'antd';
|
|
||||||
import { Pin, Puzzle, Share2 } from 'lucide-react';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
|
import withPermissions from 'App/components/hocs/withPermissions';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { numberWithCommas } from 'App/utils';
|
import { numberWithCommas } from 'App/utils';
|
||||||
import { Icon, Loader, Pagination } from "UI";
|
import { Loader, NoContent, Pagination } from 'UI';
|
||||||
import withPermissions from "../../hocs/withPermissions";
|
|
||||||
|
|
||||||
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
|
import EmptyPage from './EmptyPage';
|
||||||
import SpotListItem from './SpotListItem';
|
import SpotListItem from './SpotListItem';
|
||||||
|
import SpotsListHeader from './SpotsListHeader';
|
||||||
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() {
|
function SpotsList() {
|
||||||
const [selectedSpots, setSelectedSpots] = React.useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = React.useState<string[]>([]);
|
||||||
|
const [isEmptyState, setIsEmpty] = React.useState(false);
|
||||||
const { spotStore } = useStore();
|
const { spotStore } = useStore();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -89,13 +28,27 @@ function SpotsList() {
|
||||||
void spotStore.fetchSpots();
|
void spotStore.fetchSpots();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = (spotId: string) => {
|
const onDelete = async (spotId: string) => {
|
||||||
void spotStore.deleteSpot([spotId]);
|
await spotStore.deleteSpot([spotId]);
|
||||||
|
setSelectedSpots(selectedSpots.filter((s) => s !== spotId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const batchDelete = () => {
|
const batchDelete = async () => {
|
||||||
void spotStore.deleteSpot(selectedSpots);
|
const deletedCount = selectedSpots.length;
|
||||||
|
await spotStore.deleteSpot(selectedSpots);
|
||||||
setSelectedSpots([]);
|
setSelectedSpots([]);
|
||||||
|
|
||||||
|
const remainingItemsOnPage = spotStore.spots.length - deletedCount;
|
||||||
|
if (remainingItemsOnPage <= 0 && spotStore.page > 1) {
|
||||||
|
spotStore.setPage(spotStore.page - 1);
|
||||||
|
await spotStore.fetchSpots();
|
||||||
|
} else {
|
||||||
|
await spotStore.fetchSpots();
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
`${deletedCount} Spot${deletedCount > 1 ? 's' : ''} deleted successfully.`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRename = (id: string, newName: string) => {
|
const onRename = (id: string, newName: string) => {
|
||||||
|
|
@ -106,46 +59,79 @@ function SpotsList() {
|
||||||
return spotStore.getVideo(id);
|
return spotStore.getVideo(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectSpot = (spotId: string, isSelected: boolean) => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedSpots((prev) => [...prev, spotId]);
|
||||||
|
} else {
|
||||||
|
setSelectedSpots((prev) => prev.filter((id) => id !== spotId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSpotSelected = (spotId: string) => selectedSpots.includes(spotId);
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedSpots([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = spotStore.isLoading;
|
||||||
|
const isEmpty = isEmptyState || spotStore.total === 0 && spotStore.query === ''
|
||||||
return (
|
return (
|
||||||
<div className={'w-full'}>
|
<div className={'relative w-full mx-auto'} style={{ maxWidth: 1360 }}>
|
||||||
<div
|
<div
|
||||||
className={'mx-auto bg-white rounded border py-4'}
|
className={
|
||||||
style={{ maxWidth: 1360 }}
|
'flex mx-auto p-2 px-4 bg-white rounded-t-lg shadow-sm w-full z-50 border-b'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SpotsListHeader
|
<SpotsListHeader
|
||||||
disableButton={selectedSpots.length === 0}
|
|
||||||
onDelete={batchDelete}
|
onDelete={batchDelete}
|
||||||
|
selectedCount={selectedSpots.length}
|
||||||
|
onClearSelection={clearSelection}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isEmptyState={isEmptyState}
|
||||||
|
toggleEmptyState={() => setIsEmpty(!isEmptyState)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{spotStore.total === 0 ? (
|
<div className={'pb-4 w-full'}>
|
||||||
spotStore.isLoading ? <Loader /> : <EmptyPage />
|
{isEmpty ? (
|
||||||
|
isLoading ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<EmptyPage />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<NoContent
|
||||||
className={
|
className="w-full bg-white rounded-lg shadow-sm"
|
||||||
'py-2 px-0.5 border-t border-b border-gray-lighter grid grid-cols-3 gap-2'
|
show={spotStore.spots.length === 0}
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<AnimatedSVG name={ICONS.NO_RECORDINGS} size={60} />
|
||||||
|
<div className="font-medium text-center mt-4">
|
||||||
|
No Matching Results.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{spotStore.spots.map((spot, index) => (
|
<div
|
||||||
<SpotListItem
|
className={'py-2 border-gray-lighter grid grid-cols-3 gap-6'}
|
||||||
key={index}
|
>
|
||||||
spot={spot}
|
{spotStore.spots.map((spot) => (
|
||||||
onDelete={() => onDelete(spot.spotId)}
|
<SpotListItem
|
||||||
onRename={onRename}
|
key={spot.spotId}
|
||||||
onVideo={onVideo}
|
spot={spot}
|
||||||
onSelect={(checked: boolean) => {
|
onDelete={() => onDelete(spot.spotId)}
|
||||||
if (checked) {
|
onRename={onRename}
|
||||||
setSelectedSpots([...selectedSpots, spot.spotId]);
|
onVideo={onVideo}
|
||||||
} else {
|
onSelect={(checked: boolean) =>
|
||||||
setSelectedSpots(
|
handleSelectSpot(spot.spotId, checked)
|
||||||
selectedSpots.filter((s) => s !== spot.spotId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
isSelected={isSpotSelected(spot.spotId)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-5 w-full">
|
</NoContent>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 shadow-sm w-full bg-white rounded-lg mt-2">
|
||||||
<div>
|
<div>
|
||||||
Showing{' '}
|
Showing{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
|
|
@ -177,65 +163,5 @@ function SpotsList() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyPage() {
|
export default withPermissions(['SPOT'])(withPageTitle('Spot List - OpenReplay')(observer(SpotsList)));
|
||||||
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))
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import cn from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import cn from 'classnames';
|
|
||||||
import { closeBottomBlock } from 'Duck/components/player';
|
import { closeBottomBlock } from 'Duck/components/player';
|
||||||
import { CloseButton } from 'UI';
|
import { CloseButton } from 'UI';
|
||||||
|
|
||||||
import stl from './header.module.css';
|
import stl from './header.module.css';
|
||||||
|
|
||||||
const Header = ({
|
const Header = ({
|
||||||
|
|
@ -21,10 +24,23 @@ const Header = ({
|
||||||
showClose?: boolean;
|
showClose?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div className={ cn("relative border-r border-l py-1", stl.header) } >
|
<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
|
||||||
<div className="w-full flex items-center justify-between">{ children }</div>
|
className={cn(
|
||||||
{ showClose && <CloseButton onClick={ onClose ? onClose : closeBottomBlock } size="18" className="ml-2" /> }
|
'w-full h-full flex justify-between items-center',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full flex items-center justify-between">{children}</div>
|
||||||
|
{showClose && (
|
||||||
|
<Tooltip title="Close Panel">
|
||||||
|
<CloseButton
|
||||||
|
onClick={onClose ? onClose : closeBottomBlock}
|
||||||
|
size="18"
|
||||||
|
className="ml-2 hover:bg-black/10 rounded-lg p-1"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,34 @@ const LEVEL_TAB = {
|
||||||
|
|
||||||
export 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 }));
|
||||||
|
|
||||||
|
const urlRegex = /(https?:\/\/[^\s)]+)/g;
|
||||||
|
|
||||||
export function renderWithNL(s: string | null = '') {
|
export function renderWithNL(s: string | null = '') {
|
||||||
if (typeof s !== 'string') return '';
|
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 })}>
|
return s.split('\n').map((line, i) => {
|
||||||
{line}
|
const parts = line.split(urlRegex);
|
||||||
</div>
|
|
||||||
));
|
const formattedLine = parts.map((part, index) => {
|
||||||
|
if (urlRegex.test(part)) {
|
||||||
|
return (
|
||||||
|
<a key={`link-${index}`} className={'link text-main'} href={part} target="_blank" rel="noopener noreferrer">
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i + line.slice(0, 6)} className={cn({ 'ml-20': i !== 0 })}>
|
||||||
|
{formattedLine}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getIconProps = (level: LogLevel) => {
|
export const getIconProps = (level: LogLevel) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@ function ConsoleRow(props: Props) {
|
||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const urlRegex = /(https?:\/\/[^\s)]+)/g;
|
||||||
|
const renderLine = (l: string) => {
|
||||||
|
const parts = l.split(urlRegex);
|
||||||
|
const formattedLine = parts.map((part, index) => {
|
||||||
|
if (urlRegex.test(part)) {
|
||||||
|
return (
|
||||||
|
<a key={`link-${index}`} className={'link text-main'} href={part} target="_blank" rel="noopener noreferrer">
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedLine
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleLine = lines[0];
|
||||||
|
const restLines = lines.slice(1);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={style}
|
style={style}
|
||||||
|
|
@ -46,7 +65,7 @@ function ConsoleRow(props: Props) {
|
||||||
<Icon name={expanded ? 'caret-down-fill' : 'caret-right-fill'} className="mr-2" />
|
<Icon name={expanded ? 'caret-down-fill' : 'caret-right-fill'} className="mr-2" />
|
||||||
)}
|
)}
|
||||||
<span className='font-mono '>
|
<span className='font-mono '>
|
||||||
{renderWithNL(lines.pop())}
|
{renderWithNL(titleLine)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{log.errorId &&
|
{log.errorId &&
|
||||||
|
|
@ -56,9 +75,9 @@ function ConsoleRow(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
{canExpand &&
|
{canExpand &&
|
||||||
expanded &&
|
expanded &&
|
||||||
lines.map((l: string, i: number) => (
|
restLines.map((l: string, i: number) => (
|
||||||
<div key={l.slice(0, 4) + i} className="ml-4 mb-1" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>
|
<div key={l.slice(0, 4) + i} className="ml-4 mb-1 text-xs" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>
|
||||||
{l}
|
{renderLine(l)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ function JumpButton(props: Props) {
|
||||||
<div className="absolute right-2 top-0 bottom-0 my-auto flex items-center">
|
<div className="absolute right-2 top-0 bottom-0 my-auto flex items-center">
|
||||||
<Tooltip title={tooltip} disabled={!tooltip}>
|
<Tooltip title={tooltip} disabled={!tooltip}>
|
||||||
<div
|
<div
|
||||||
className="mr-2 border cursor-pointer hidden group-hover:flex rounded bg-white text-xs items-center px-2 py-1 color-teal hover:shadow h-6"
|
className="border cursor-pointer hidden group-hover:flex rounded bg-white text-xs items-center px-2 py-1 color-teal hover:shadow h-6"
|
||||||
onClick={(e: any) => {
|
onClick={(e: any) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
props.onClick();
|
props.onClick();
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import WebPlayer from 'Player/web/WebPlayer';
|
|
||||||
import MobilePlayer from 'Player/mobile/IOSPlayer';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
|
||||||
import { Duration } from 'luxon';
|
|
||||||
|
|
||||||
import { Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI';
|
|
||||||
import { ResourceType, Timed } from 'Player';
|
import { ResourceType, Timed } from 'Player';
|
||||||
import { formatBytes } from 'App/utils';
|
import MobilePlayer from 'Player/mobile/IOSPlayer';
|
||||||
import { formatMs } from 'App/date';
|
import WebPlayer from 'Player/web/WebPlayer';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { Duration } from 'luxon';
|
||||||
import FetchDetailsModal from 'Shared/FetchDetailsModal';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { MobilePlayerContext, PlayerContext } from 'App/components/Session/playerContext';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useStore } from 'App/mstore';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import TimeTable from '../TimeTable';
|
|
||||||
|
import { useModal } from 'App/components/Modal';
|
||||||
|
import {
|
||||||
|
MobilePlayerContext,
|
||||||
|
PlayerContext,
|
||||||
|
} from 'App/components/Session/playerContext';
|
||||||
|
import { formatMs } from 'App/date';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { formatBytes } from 'App/utils';
|
||||||
|
import { Icon, Input, NoContent, Tabs, Toggler, Tooltip } from 'UI';
|
||||||
|
|
||||||
|
import FetchDetailsModal from 'Shared/FetchDetailsModal';
|
||||||
|
|
||||||
import BottomBlock from '../BottomBlock';
|
import BottomBlock from '../BottomBlock';
|
||||||
import InfoLine from '../BottomBlock/InfoLine';
|
import InfoLine from '../BottomBlock/InfoLine';
|
||||||
|
import TimeTable from '../TimeTable';
|
||||||
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
|
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
|
||||||
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
|
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
|
||||||
import WSModal from './WSModal';
|
import WSModal from './WSModal';
|
||||||
|
|
@ -96,7 +101,9 @@ function renderSize(r: any) {
|
||||||
content = (
|
content = (
|
||||||
<ul>
|
<ul>
|
||||||
{showTransferred && (
|
{showTransferred && (
|
||||||
<li>{`${formatBytes(r.encodedBodySize + headerSize)} transferred over network`}</li>
|
<li>{`${formatBytes(
|
||||||
|
r.encodedBodySize + headerSize
|
||||||
|
)} transferred over network`}</li>
|
||||||
)}
|
)}
|
||||||
<li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li>
|
<li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -133,18 +140,38 @@ export function renderDuration(r: any) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus({ status, cached }: { status: string; cached: boolean }) {
|
function renderStatus({
|
||||||
|
status,
|
||||||
|
cached,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
cached: boolean;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const displayedStatus = error ? (
|
||||||
|
<Tooltip delay={0} title={error}>
|
||||||
|
<div
|
||||||
|
style={{ width: 90 }}
|
||||||
|
className={'overflow-hidden overflow-ellipsis'}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
status
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{cached ? (
|
{cached ? (
|
||||||
<Tooltip title={'Served from cache'}>
|
<Tooltip title={'Served from cache'}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-1">{status}</span>
|
<span className="mr-1">{displayedStatus}</span>
|
||||||
<Icon name="wifi" size={16} />
|
<Icon name="wifi" size={16} />
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
status
|
displayedStatus
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -165,7 +192,13 @@ function NetworkPanelCont({
|
||||||
}) {
|
}) {
|
||||||
const { player, store } = React.useContext(PlayerContext);
|
const { player, store } = React.useContext(PlayerContext);
|
||||||
|
|
||||||
const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab } = store.get();
|
const {
|
||||||
|
domContentLoadedTime,
|
||||||
|
loadTime,
|
||||||
|
domBuildingTime,
|
||||||
|
tabStates,
|
||||||
|
currentTab,
|
||||||
|
} = store.get();
|
||||||
const {
|
const {
|
||||||
fetchList = [],
|
fetchList = [],
|
||||||
resourceList = [],
|
resourceList = [],
|
||||||
|
|
@ -276,6 +309,7 @@ interface Props {
|
||||||
zoomEndTs?: number;
|
zoomEndTs?: number;
|
||||||
panelHeight: number;
|
panelHeight: number;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
activeOutsideIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NetworkPanelComp = observer(
|
export const NetworkPanelComp = observer(
|
||||||
|
|
@ -296,6 +330,7 @@ export const NetworkPanelComp = observer(
|
||||||
zoomStartTs,
|
zoomStartTs,
|
||||||
zoomEndTs,
|
zoomEndTs,
|
||||||
onClose,
|
onClose,
|
||||||
|
activeOutsideIndex,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showModal } = useModal();
|
const { showModal } = useModal();
|
||||||
const [sortBy, setSortBy] = useState('time');
|
const [sortBy, setSortBy] = useState('time');
|
||||||
|
|
@ -308,12 +343,13 @@ export const NetworkPanelComp = observer(
|
||||||
} = useStore();
|
} = useStore();
|
||||||
const filter = devTools[INDEX_KEY].filter;
|
const filter = devTools[INDEX_KEY].filter;
|
||||||
const activeTab = devTools[INDEX_KEY].activeTab;
|
const activeTab = devTools[INDEX_KEY].activeTab;
|
||||||
const activeIndex = devTools[INDEX_KEY].index;
|
const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index;
|
||||||
|
|
||||||
const socketList = useMemo(
|
const socketList = useMemo(
|
||||||
() =>
|
() =>
|
||||||
websocketList.filter(
|
websocketList.filter(
|
||||||
(ws, i, arr) => arr.findIndex((it) => it.channelName === ws.channelName) === i
|
(ws, i, arr) =>
|
||||||
|
arr.findIndex((it) => it.channelName === ws.channelName) === i
|
||||||
),
|
),
|
||||||
[websocketList]
|
[websocketList]
|
||||||
);
|
);
|
||||||
|
|
@ -362,7 +398,11 @@ export const NetworkPanelComp = observer(
|
||||||
transferredBodySize: 0,
|
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),
|
.sort((a, b) => a.time - b.time),
|
||||||
[resourceList.length, fetchList.length, socketList]
|
[resourceList.length, fetchList.length, socketList]
|
||||||
);
|
);
|
||||||
|
|
@ -371,18 +411,27 @@ export const NetworkPanelComp = observer(
|
||||||
if (!showOnlyErrors) {
|
if (!showOnlyErrors) {
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
return list.filter((it) => parseInt(it.status) >= 400 || !it.success);
|
return list.filter(
|
||||||
|
(it) => parseInt(it.status) >= 400 || !it.success || it.error
|
||||||
|
);
|
||||||
}, [showOnlyErrors, list]);
|
}, [showOnlyErrors, list]);
|
||||||
filteredList = useRegExListFilterMemo(
|
filteredList = useRegExListFilterMemo(
|
||||||
filteredList,
|
filteredList,
|
||||||
(it) => [it.status, it.name, it.type, it.method],
|
(it) => [it.status, it.name, it.type, it.method],
|
||||||
filter
|
filter
|
||||||
);
|
);
|
||||||
filteredList = useTabListFilterMemo(filteredList, (it) => TYPE_TO_TAB[it.type], ALL, activeTab);
|
filteredList = useTabListFilterMemo(
|
||||||
|
filteredList,
|
||||||
|
(it) => TYPE_TO_TAB[it.type],
|
||||||
|
ALL,
|
||||||
|
activeTab
|
||||||
|
);
|
||||||
|
|
||||||
const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) =>
|
const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) =>
|
||||||
devTools.update(INDEX_KEY, { activeTab });
|
devTools.update(INDEX_KEY, { activeTab });
|
||||||
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
|
const onFilterChange = ({
|
||||||
|
target: { value },
|
||||||
|
}: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
devTools.update(INDEX_KEY, { filter: value });
|
devTools.update(INDEX_KEY, { filter: value });
|
||||||
|
|
||||||
// AutoScroll
|
// AutoScroll
|
||||||
|
|
@ -401,7 +450,11 @@ export const NetworkPanelComp = observer(
|
||||||
};
|
};
|
||||||
|
|
||||||
const resourcesSize = useMemo(
|
const resourcesSize = useMemo(
|
||||||
() => resourceList.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0),
|
() =>
|
||||||
|
resourceList.reduce(
|
||||||
|
(sum, { decodedBodySize }) => sum + (decodedBodySize || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
[resourceList.length]
|
[resourceList.length]
|
||||||
);
|
);
|
||||||
const transferredSize = useMemo(
|
const transferredSize = useMemo(
|
||||||
|
|
@ -435,7 +488,9 @@ export const NetworkPanelComp = observer(
|
||||||
|
|
||||||
const showDetailsModal = (item: any) => {
|
const showDetailsModal = (item: any) => {
|
||||||
if (item.type === 'websocket') {
|
if (item.type === 'websocket') {
|
||||||
const socketMsgList = websocketList.filter((ws) => ws.channelName === item.channelName);
|
const socketMsgList = websocketList.filter(
|
||||||
|
(ws) => ws.channelName === item.channelName
|
||||||
|
);
|
||||||
|
|
||||||
return showModal(<WSModal socketMsgList={socketMsgList} />, {
|
return showModal(<WSModal socketMsgList={socketMsgList} />, {
|
||||||
right: true,
|
right: true,
|
||||||
|
|
@ -464,149 +519,158 @@ export const NetworkPanelComp = observer(
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomBlock
|
<BottomBlock
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
className="border"
|
className="border"
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
<BottomBlock.Header onClose={onClose}>
|
<BottomBlock.Header onClose={onClose}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="font-semibold color-gray-medium mr-4">Network</span>
|
<span className="font-semibold color-gray-medium mr-4">
|
||||||
{isMobile ? null : (
|
Network
|
||||||
<Tabs
|
</span>
|
||||||
className="uppercase"
|
{isMobile ? null : (
|
||||||
tabs={NETWORK_TABS}
|
<Tabs
|
||||||
active={activeTab}
|
className="uppercase"
|
||||||
onClick={onTabClick}
|
tabs={NETWORK_TABS}
|
||||||
border={false}
|
active={activeTab}
|
||||||
/>
|
onClick={onTabClick}
|
||||||
)}
|
border={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="input-small"
|
||||||
|
placeholder="Filter by name, type, method or value"
|
||||||
|
icon="search"
|
||||||
|
name="filter"
|
||||||
|
onChange={onFilterChange}
|
||||||
|
height={28}
|
||||||
|
width={280}
|
||||||
|
value={filter}
|
||||||
|
/>
|
||||||
|
</BottomBlock.Header>
|
||||||
|
<BottomBlock.Content>
|
||||||
|
<div className="flex items-center justify-between px-4 border-b bg-teal/5 h-8">
|
||||||
|
<div>
|
||||||
|
<Toggler
|
||||||
|
checked={showOnlyErrors}
|
||||||
|
name="show-errors-only"
|
||||||
|
onChange={() => setShowOnlyErrors(!showOnlyErrors)}
|
||||||
|
label="4xx-5xx Only"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<InfoLine>
|
||||||
className="input-small"
|
<InfoLine.Point
|
||||||
placeholder="Filter by name, type, method or value"
|
label={filteredList.length + ''}
|
||||||
icon="search"
|
value=" requests"
|
||||||
name="filter"
|
/>
|
||||||
onChange={onFilterChange}
|
<InfoLine.Point
|
||||||
height={28}
|
label={formatBytes(transferredSize)}
|
||||||
width={280}
|
value="transferred"
|
||||||
value={filter}
|
display={transferredSize > 0}
|
||||||
/>
|
/>
|
||||||
</BottomBlock.Header>
|
<InfoLine.Point
|
||||||
<BottomBlock.Content>
|
label={formatBytes(resourcesSize)}
|
||||||
<div className="flex items-center justify-between px-4">
|
value="resources"
|
||||||
<div>
|
display={resourcesSize > 0}
|
||||||
<Toggler
|
/>
|
||||||
checked={showOnlyErrors}
|
<InfoLine.Point
|
||||||
name="show-errors-only"
|
label={formatMs(domBuildingTime)}
|
||||||
onChange={() => setShowOnlyErrors(!showOnlyErrors)}
|
value="DOM Building Time"
|
||||||
label="4xx-5xx Only"
|
display={domBuildingTime != null}
|
||||||
/>
|
/>
|
||||||
|
<InfoLine.Point
|
||||||
|
label={
|
||||||
|
domContentLoadedTime && formatMs(domContentLoadedTime.value)
|
||||||
|
}
|
||||||
|
value="DOMContentLoaded"
|
||||||
|
display={domContentLoadedTime != null}
|
||||||
|
dotColor={DOM_LOADED_TIME_COLOR}
|
||||||
|
/>
|
||||||
|
<InfoLine.Point
|
||||||
|
label={loadTime && formatMs(loadTime.value)}
|
||||||
|
value="Load"
|
||||||
|
display={loadTime != null}
|
||||||
|
dotColor={LOAD_TIME_COLOR}
|
||||||
|
/>
|
||||||
|
</InfoLine>
|
||||||
|
</div>
|
||||||
|
<NoContent
|
||||||
|
title={
|
||||||
|
<div className="capitalize flex items-center">
|
||||||
|
<Icon name="info-circle" className="mr-2" size="18" />
|
||||||
|
No Data
|
||||||
</div>
|
</div>
|
||||||
<InfoLine>
|
}
|
||||||
<InfoLine.Point label={filteredList.length + ''} value=" requests" />
|
size="small"
|
||||||
<InfoLine.Point
|
show={filteredList.length === 0}
|
||||||
label={formatBytes(transferredSize)}
|
>
|
||||||
value="transferred"
|
{/*@ts-ignore*/}
|
||||||
display={transferredSize > 0}
|
<TimeTable
|
||||||
/>
|
rows={filteredList}
|
||||||
<InfoLine.Point
|
tableHeight={panelHeight - 102}
|
||||||
label={formatBytes(resourcesSize)}
|
referenceLines={referenceLines}
|
||||||
value="resources"
|
renderPopup
|
||||||
display={resourcesSize > 0}
|
onRowClick={showDetailsModal}
|
||||||
/>
|
sortBy={sortBy}
|
||||||
<InfoLine.Point
|
sortAscending={sortAscending}
|
||||||
label={formatMs(domBuildingTime)}
|
onJump={(row: any) => {
|
||||||
value="DOM Building Time"
|
devTools.update(INDEX_KEY, {
|
||||||
display={domBuildingTime != null}
|
index: filteredList.indexOf(row),
|
||||||
/>
|
});
|
||||||
<InfoLine.Point
|
player.jump(row.time);
|
||||||
label={domContentLoadedTime && formatMs(domContentLoadedTime.value)}
|
}}
|
||||||
value="DOMContentLoaded"
|
activeIndex={activeIndex}
|
||||||
display={domContentLoadedTime != null}
|
|
||||||
dotColor={DOM_LOADED_TIME_COLOR}
|
|
||||||
/>
|
|
||||||
<InfoLine.Point
|
|
||||||
label={loadTime && formatMs(loadTime.value)}
|
|
||||||
value="Load"
|
|
||||||
display={loadTime != null}
|
|
||||||
dotColor={LOAD_TIME_COLOR}
|
|
||||||
/>
|
|
||||||
</InfoLine>
|
|
||||||
</div>
|
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
{/*@ts-ignore*/}
|
{[
|
||||||
<TimeTable
|
// {
|
||||||
rows={filteredList}
|
// label: 'Start',
|
||||||
tableHeight={panelHeight - 102}
|
// width: 120,
|
||||||
referenceLines={referenceLines}
|
// render: renderStart,
|
||||||
renderPopup
|
// },
|
||||||
onRowClick={showDetailsModal}
|
{
|
||||||
sortBy={sortBy}
|
label: 'Status',
|
||||||
sortAscending={sortAscending}
|
dataKey: 'status',
|
||||||
onJump={(row: any) => {
|
width: 90,
|
||||||
devTools.update(INDEX_KEY, { index: filteredList.indexOf(row) });
|
render: renderStatus,
|
||||||
player.jump(row.time);
|
},
|
||||||
}}
|
{
|
||||||
activeIndex={activeIndex}
|
label: 'Type',
|
||||||
>
|
dataKey: 'type',
|
||||||
{[
|
width: 90,
|
||||||
// {
|
render: renderType,
|
||||||
// label: 'Start',
|
},
|
||||||
// width: 120,
|
{
|
||||||
// render: renderStart,
|
label: 'Method',
|
||||||
// },
|
width: 80,
|
||||||
{
|
dataKey: 'method',
|
||||||
label: 'Status',
|
},
|
||||||
dataKey: 'status',
|
{
|
||||||
width: 90,
|
label: 'Name',
|
||||||
render: renderStatus,
|
width: 240,
|
||||||
},
|
dataKey: 'name',
|
||||||
{
|
render: renderName,
|
||||||
label: 'Type',
|
},
|
||||||
dataKey: 'type',
|
{
|
||||||
width: 90,
|
label: 'Size',
|
||||||
render: renderType,
|
width: 80,
|
||||||
},
|
dataKey: 'decodedBodySize',
|
||||||
{
|
render: renderSize,
|
||||||
label: 'Method',
|
hidden: activeTab === XHR,
|
||||||
width: 80,
|
},
|
||||||
dataKey: 'method',
|
{
|
||||||
},
|
label: 'Duration',
|
||||||
{
|
width: 80,
|
||||||
label: 'Name',
|
dataKey: 'duration',
|
||||||
width: 240,
|
render: renderDuration,
|
||||||
dataKey: 'name',
|
},
|
||||||
render: renderName,
|
]}
|
||||||
},
|
</TimeTable>
|
||||||
{
|
</NoContent>
|
||||||
label: 'Size',
|
</BottomBlock.Content>
|
||||||
width: 80,
|
</BottomBlock>
|
||||||
dataKey: 'decodedBodySize',
|
|
||||||
render: renderSize,
|
|
||||||
hidden: activeTab === XHR,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Duration',
|
|
||||||
width: 80,
|
|
||||||
dataKey: 'duration',
|
|
||||||
render: renderDuration,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
</TimeTable>
|
|
||||||
</NoContent>
|
|
||||||
</BottomBlock.Content>
|
|
||||||
</BottomBlock>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ export default class TimeTable extends React.PureComponent<Props, State> {
|
||||||
{columns
|
{columns
|
||||||
.filter((i: any) => !i.hidden)
|
.filter((i: any) => !i.hidden)
|
||||||
.map(({ dataKey, render, width, label }) => (
|
.map(({ dataKey, render, width, label }) => (
|
||||||
<div key={parseInt(label.replace(' ', '')+dataKey, 36)} className={stl.cell} style={{ width: `${width}px` }}>
|
<div key={parseInt(label.replace(' ', '')+dataKey, 36)} className={cn(stl.cell, 'overflow-ellipsis overflow-hidden')} style={{ width: `${width}px` }}>
|
||||||
{render
|
{render
|
||||||
? render(row)
|
? render(row)
|
||||||
: row[dataKey || ''] || <i className="color-gray-light">{'empty'}</i>}
|
: row[dataKey || ''] || <i className="color-gray-light">{'empty'}</i>}
|
||||||
|
|
@ -338,7 +338,7 @@ export default class TimeTable extends React.PureComponent<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NoContent size="small" show={rows.length === 0}>
|
<NoContent size="small" show={rows.length === 0}>
|
||||||
<div className="relative">
|
<div className="relative" style={{ height: this.tableHeight }}>
|
||||||
<div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}>
|
<div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}>
|
||||||
{timeColumns.map((_, index) => (
|
{timeColumns.map((_, index) => (
|
||||||
<div key={`tc-${index}`} className={stl.timeCell} />
|
<div key={`tc-${index}`} className={stl.timeCell} />
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ function LiveSessionList(props: Props) {
|
||||||
var timeoutId: any;
|
var timeoutId: any;
|
||||||
const { filters } = filter;
|
const { filters } = filter;
|
||||||
const hasUserFilter = filters.map((i: any) => i.key).includes(KEYS.USERID);
|
const hasUserFilter = filters.map((i: any) => i.key).includes(KEYS.USERID);
|
||||||
const sortOptions = [{ label: 'Freshness', value: 'timestamp' }].concat(
|
const sortOptions = [{ label: 'Start Time', value: 'timestamp' }].concat(
|
||||||
metaList
|
metaList
|
||||||
.map((i: any) => ({
|
.map((i: any) => ({
|
||||||
label: capitalize(i),
|
label: capitalize(i),
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default React.memo(function SortOrderButton(props: Props) {
|
||||||
{ label: 'Ascending', value: 'Ascending', icon: <ArrowUpOutlined /> },
|
{ label: 'Ascending', value: 'Ascending', icon: <ArrowUpOutlined /> },
|
||||||
{ label: 'Descending', value: 'Descending', icon: <ArrowDownOutlined /> },
|
{ label: 'Descending', value: 'Descending', icon: <ArrowDownOutlined /> },
|
||||||
]}
|
]}
|
||||||
defaultValue="Descending"
|
defaultValue="Ascending"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (value === 'Ascending') {
|
if (value === 'Ascending') {
|
||||||
onChange('asc');
|
onChange('asc');
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ function Color_browser_whale(props: Props) {
|
||||||
<path d="M15.9554 16.8819C15.4528 16.8819 15.0449 16.3989 15.0449 15.8033C15.0449 15.2076 15.4528 14.7246 15.9554 14.7246C16.4581 14.7246 16.8659 15.2076 16.8659 15.8033C16.8659 16.3989 16.4581 16.8819 15.9554 16.8819Z" fill="#004781"/>
|
<path d="M15.9554 16.8819C15.4528 16.8819 15.0449 16.3989 15.0449 15.8033C15.0449 15.2076 15.4528 14.7246 15.9554 14.7246C16.4581 14.7246 16.8659 15.2076 16.8659 15.8033C16.8659 16.3989 16.4581 16.8819 15.9554 16.8819Z" fill="#004781"/>
|
||||||
</g>
|
</g>
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="filter0_i_450_13811" x="5.44946" y="3.64941" width="24.7933" height="26.5266" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
<filter id="filter0_i_450_13811" x="5.44946" y="3.64941" width="24.7933" height="26.5266" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
|
||||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
|
||||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
<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"/>
|
<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 dx="-4" dy="-4"/>
|
<feOffset dx="-4" dy="-4"/>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ interface Props {
|
||||||
function Dashboard_icn(props: Props) {
|
function Dashboard_icn(props: Props) {
|
||||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 276 241" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><g filter="url(#a)"><rect x="6" y="4" width="264" height="229" rx="6" fill="#fff"/></g><g opacity=".7"><rect x="141" y="14" width="119" height="101" rx="4.244" fill="#000" fillOpacity=".08"/><rect x="141.5" y="14.5" width="118" height="100" rx="3.744" stroke="#000" stroke-opacity=".12"/></g><g opacity=".3"><rect x="16" y="14" width="119" height="101" rx="4.244" fill="#000" fillOpacity=".08"/><rect x="16.5" y="14.5" width="118" height="100" rx="3.744" stroke="#000" stroke-opacity=".12"/></g><g opacity=".46"><rect x="16" y="122" width="244" height="99" rx="4.244" fill="#000" fillOpacity=".08"/><rect x="16.5" y="122.5" width="243" height="98" rx="3.744" stroke="#000" stroke-opacity=".12"/></g><rect opacity=".2" x="149" y="85" width="15" height="20" rx="2" fill="#3EAAAF"/><rect opacity=".4" x="178" y="54" width="15" height="51" rx="2" fill="#3EAAAF"/><rect opacity=".6" x="207" y="62" width="15" height="43" rx="2" fill="#3EAAAF"/><rect opacity=".4" x="236" y="45" width="15" height="60" rx="2" fill="#3EAAAF"/><path opacity=".6" d="M109 62a32.997 32.997 0 0 1-56.334 23.335l9.16-9.162A20.044 20.044 0 0 0 96.045 62H109Z" fill="#3EAAAF"/><path opacity=".2" d="M51.09 83.645a33.002 33.002 0 0 1-6.892-30.457l12.582 3.486a19.945 19.945 0 0 0 4.165 18.408l-9.855 8.563Z" fill="#3EAAAF"/><path opacity=".4" d="M44.652 51.688a33 33 0 0 1 64.32 8.95l-12.948.535a20.04 20.04 0 0 0-39.061-5.435l-12.31-4.05Z" fill="#3EAAAF"/><path d="M150.176 79.433a1 1 0 0 0 1.648 1.134l-1.648-1.134ZM247 29l-11.457 1.437 6.972 9.204L247 29Zm-35.954 23.046-.552.834.552-.834Zm5.777-.185-.604-.797.604.797Zm-36.606-14.317-.823-.567.823.567Zm6.877-1.336-.551.834.551-.834Zm-35.27 44.359 29.217-42.457-1.647-1.133-29.218 42.456 1.648 1.134Zm34.719-43.525 23.951 15.838 1.103-1.668-23.951-15.839-1.103 1.669Zm30.884 15.616 23.003-17.426-1.208-1.594-23.003 17.426 1.208 1.594Zm-6.933.222a6 6 0 0 0 6.933-.222l-1.208-1.594a4 4 0 0 1-4.622.148l-1.103 1.668Zm-29.453-14.77a4 4 0 0 1 5.502-1.068l1.103-1.669a6 6 0 0 0-8.252 1.604l1.647 1.133Z" fill="#3EAAAF"/><rect opacity=".2" x="60" y="102" width="7" height="3" rx="1.5" fill="#3EAAAF"/><rect opacity=".3" x="72" y="102" width="7" height="3" rx="1.5" fill="#3EAAAF"/><rect opacity=".6" x="84" y="102" width="7" height="3" rx="1.5" fill="#3EAAAF"/><path clipRule="evenodd" d="M17 187.928c0-1.01 1.326-1.383 1.853-.522l1.777 2.906c3.63 5.938 11.092 17.813 18.352 19 7.26 1.188 14.721-8.312 21.981-20.187C68.425 177.25 75.685 163 82.945 163c7.462 0 14.722 14.25 21.982 21.375 7.461 7.125 14.721 7.125 21.981 2.375 7.462-4.75 14.722-14.25 22.184-14.25 7.26 0 14.52 9.5 21.981 16.625 7.26 7.125 14.52 11.875 21.982 8.313 7.26-3.563 14.52-15.438 21.982-14.25 7.26 1.187 14.721 15.437 21.981 16.624 7.26 1.188 14.722-10.687 18.352-16.624l1.777-2.907c.527-.861 1.853-.488 1.853.522V217a3 3 0 0 1-3 3H20a3 3 0 0 1-3-3v-29.072Z" fill="url(#b)"/><path d="M258 180.5c-7 11-17.882 29.121-29 14-12.5-17-21.333-11.5-24.5-8-16.5 21.5-30.5 10.5-43-6-10-13.2-19.833-7.833-23.5-3.5-22.5 28-35.5 3.5-48.5-11.5C79 154 67 179 59 194c-8 13.5-21 29.5-41-6.5" stroke="#3EAAAF" strokeWidth="2" strokeLinecap="round"/><circle cx="42" cy="206" r="7" fill="#fff" stroke="#3EAAAF" strokeWidth="2"/><circle cx="117" cy="187" r="7" fill="#fff" stroke="#3EAAAF" strokeWidth="2"/><circle cx="212" cy="183" r="7" fill="#fff" stroke="#3EAAAF" strokeWidth="2"/><defs><linearGradient id="b" x1="138" y1="163" x2="138.49" y2="224" gradientUnits="userSpaceOnUse"><stop stopColor="#86C6C9"/><stop offset="1" stopColor="#F6F6F6"/></linearGradient><filter id="a" x="0" y="0" width="276" height="241" 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="2"/><feGaussianBlur stdDeviation="3"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_59_3139"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_59_3139" result="shape"/></filter></defs></svg>
|
<svg viewBox="0 0 276 241" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><g filter="url(#a)"><rect x="6" y="4" width="264" height="229" rx="6" fill="#fff"/></g><g opacity=".7"><rect x="141" y="14" width="119" height="101" rx="4.244" fill="#000" fillOpacity=".08"/><rect x="141.5" y="14.5" width="118" height="100" rx="3.744" stroke="#000" stroke-opacity=".12"/></g><g opacity=".3"><rect x="16" y="14" width="119" height="101" rx="4.244" fill="#000" fillOpacity=".08"/><rect x="16.5" y="14.5" width="118" height="100" rx="3.744" stroke="#000" stroke-opacity=".12"/></g><g opacity=".46"><rect x="16" y="122" width="244" height="99" rx="4.244" fill="#000" fillOpacity=".08"/><rect x="16.5" y="122.5" width="243" height="98" rx="3.744" stroke="#000" stroke-opacity=".12"/></g><rect opacity=".2" x="149" y="85" width="15" height="20" rx="2" fill="#3EAAAF"/><rect opacity=".4" x="178" y="54" width="15" height="51" rx="2" fill="#3EAAAF"/><rect opacity=".6" x="207" y="62" width="15" height="43" rx="2" fill="#3EAAAF"/><rect opacity=".4" x="236" y="45" width="15" height="60" rx="2" fill="#3EAAAF"/><path opacity=".6" d="M109 62a32.997 32.997 0 0 1-56.334 23.335l9.16-9.162A20.044 20.044 0 0 0 96.045 62H109Z" fill="#3EAAAF"/><path opacity=".2" d="M51.09 83.645a33.002 33.002 0 0 1-6.892-30.457l12.582 3.486a19.945 19.945 0 0 0 4.165 18.408l-9.855 8.563Z" fill="#3EAAAF"/><path opacity=".4" d="M44.652 51.688a33 33 0 0 1 64.32 8.95l-12.948.535a20.04 20.04 0 0 0-39.061-5.435l-12.31-4.05Z" fill="#3EAAAF"/><path d="M150.176 79.433a1 1 0 0 0 1.648 1.134l-1.648-1.134ZM247 29l-11.457 1.437 6.972 9.204L247 29Zm-35.954 23.046-.552.834.552-.834Zm5.777-.185-.604-.797.604.797Zm-36.606-14.317-.823-.567.823.567Zm6.877-1.336-.551.834.551-.834Zm-35.27 44.359 29.217-42.457-1.647-1.133-29.218 42.456 1.648 1.134Zm34.719-43.525 23.951 15.838 1.103-1.668-23.951-15.839-1.103 1.669Zm30.884 15.616 23.003-17.426-1.208-1.594-23.003 17.426 1.208 1.594Zm-6.933.222a6 6 0 0 0 6.933-.222l-1.208-1.594a4 4 0 0 1-4.622.148l-1.103 1.668Zm-29.453-14.77a4 4 0 0 1 5.502-1.068l1.103-1.669a6 6 0 0 0-8.252 1.604l1.647 1.133Z" fill="#3EAAAF"/><rect opacity=".2" x="60" y="102" width="7" height="3" rx="1.5" fill="#3EAAAF"/><rect opacity=".3" x="72" y="102" width="7" height="3" rx="1.5" fill="#3EAAAF"/><rect opacity=".6" x="84" y="102" width="7" height="3" rx="1.5" fill="#3EAAAF"/><path clipRule="evenodd" d="M17 187.928c0-1.01 1.326-1.383 1.853-.522l1.777 2.906c3.63 5.938 11.092 17.813 18.352 19 7.26 1.188 14.721-8.312 21.981-20.187C68.425 177.25 75.685 163 82.945 163c7.462 0 14.722 14.25 21.982 21.375 7.461 7.125 14.721 7.125 21.981 2.375 7.462-4.75 14.722-14.25 22.184-14.25 7.26 0 14.52 9.5 21.981 16.625 7.26 7.125 14.52 11.875 21.982 8.313 7.26-3.563 14.52-15.438 21.982-14.25 7.26 1.187 14.721 15.437 21.981 16.624 7.26 1.188 14.722-10.687 18.352-16.624l1.777-2.907c.527-.861 1.853-.488 1.853.522V217a3 3 0 0 1-3 3H20a3 3 0 0 1-3-3v-29.072Z" fill="url(#b)"/><path d="M258 180.5c-7 11-17.882 29.121-29 14-12.5-17-21.333-11.5-24.5-8-16.5 21.5-30.5 10.5-43-6-10-13.2-19.833-7.833-23.5-3.5-22.5 28-35.5 3.5-48.5-11.5C79 154 67 179 59 194c-8 13.5-21 29.5-41-6.5" stroke="#3EAAAF" strokeWidth="2" strokeLinecap="round"/><circle cx="42" cy="206" r="7" fill="#fff" stroke="#3EAAAF" strokeWidth="2"/><circle cx="117" cy="187" r="7" fill="#fff" stroke="#3EAAAF" strokeWidth="2"/><circle cx="212" cy="183" r="7" fill="#fff" stroke="#3EAAAF" strokeWidth="2"/><defs><linearGradient id="b" x1="138" y1="163" x2="138.49" y2="224" gradientUnits="userSpaceOnUse"><stop stopColor="#86C6C9"/><stop offset="1" stopColor="#F6F6F6"/></linearGradient><filter id="a" x="0" y="0" width="276" height="241" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"><feFlood floodOpacity="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="2"/><feGaussianBlur stdDeviation="3"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_59_3139"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_59_3139" result="shape"/></filter></defs></svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ interface Props {
|
||||||
function Orspot(props: Props) {
|
function Orspot(props: Props) {
|
||||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||||
return (
|
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>
|
<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" colorInterpolationFilters="sRGB"><feFlood floodOpacity="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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,19 @@ interface Props {
|
||||||
children?: any;
|
children?: any;
|
||||||
image?: any;
|
image?: any;
|
||||||
style?: any;
|
style?: any;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoContent(props: Props) {
|
export default function NoContent(props: Props) {
|
||||||
const { title = '', subtext = '', icon, iconSize, size, show, children, image, style } = props;
|
const { title = '', subtext = '', icon, iconSize, size, show, children, image, style, className } = props;
|
||||||
|
|
||||||
return !show ? (
|
return !show ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<div className={`${styles.wrapper} ${size && styles[size]}`} style={style}>
|
<div
|
||||||
|
className={`${styles.wrapper} ${size && styles[size]} h-full ${className || ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
{icon && <Icon name={icon} size={iconSize} />}
|
{icon && <Icon name={icon} size={iconSize} />}
|
||||||
{title && <div className='flex'>{title}</div>}
|
{title && <div className='flex'>{title}</div>}
|
||||||
{subtext && <div className={styles.subtext}>{subtext}</div>}
|
{subtext && <div className={styles.subtext}>{subtext}</div>}
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ export const GETTING_STARTED = "__$user-gettingStarted$__"
|
||||||
export const MOUSE_TRAIL = "__$session-mouseTrail$__"
|
export const MOUSE_TRAIL = "__$session-mouseTrail$__"
|
||||||
export const IFRAME = "__$session-iframe$__"
|
export const IFRAME = "__$session-iframe$__"
|
||||||
export const JWT_PARAM = "__$session-jwt-param$__"
|
export const JWT_PARAM = "__$session-jwt-param$__"
|
||||||
export const MENU_COLLAPSED = "__$global-menuCollapsed$__"
|
export const MENU_COLLAPSED = "__$global-menuCollapsed$__"
|
||||||
|
export const SPOT_ONBOARDING = "__$spot-onboarding$__"
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import { List, Map } from 'immutable';
|
|
||||||
import Client from 'Types/client';
|
|
||||||
import { deleteCookie } from 'App/utils';
|
|
||||||
import Account from 'Types/account';
|
import Account from 'Types/account';
|
||||||
|
import Client from 'Types/client';
|
||||||
|
import { List, Map } from 'immutable';
|
||||||
|
|
||||||
|
import { deleteCookie } from 'App/utils';
|
||||||
|
|
||||||
import withRequestState, { RequestTypes } from './requestStateCreator';
|
import withRequestState, { RequestTypes } from './requestStateCreator';
|
||||||
|
|
||||||
export const LOGIN = new RequestTypes('user/LOGIN');
|
export const LOGIN = new RequestTypes('user/LOGIN');
|
||||||
export const SIGNUP = new RequestTypes('user/SIGNUP');
|
export const SIGNUP = new RequestTypes('user/SIGNUP');
|
||||||
export const RESET_PASSWORD = new RequestTypes('user/RESET_PASSWORD');
|
export const RESET_PASSWORD = new RequestTypes('user/RESET_PASSWORD');
|
||||||
export const REQUEST_RESET_PASSWORD = new RequestTypes('user/REQUEST_RESET_PASSWORD');
|
export const REQUEST_RESET_PASSWORD = new RequestTypes(
|
||||||
|
'user/REQUEST_RESET_PASSWORD'
|
||||||
|
);
|
||||||
export const FETCH_ACCOUNT = new RequestTypes('user/FETCH_ACCOUNT');
|
export const FETCH_ACCOUNT = new RequestTypes('user/FETCH_ACCOUNT');
|
||||||
const FETCH_TENANTS = new RequestTypes('user/FETCH_TENANTS');
|
const FETCH_TENANTS = new RequestTypes('user/FETCH_TENANTS');
|
||||||
const UPDATE_ACCOUNT = new RequestTypes('user/UPDATE_ACCOUNT');
|
const UPDATE_ACCOUNT = new RequestTypes('user/UPDATE_ACCOUNT');
|
||||||
const RESEND_EMAIL_VERIFICATION = new RequestTypes('user/RESEND_EMAIL_VERIFICATION');
|
const RESEND_EMAIL_VERIFICATION = new RequestTypes(
|
||||||
|
'user/RESEND_EMAIL_VERIFICATION'
|
||||||
|
);
|
||||||
const FETCH_CLIENT = new RequestTypes('user/FETCH_CLIENT');
|
const FETCH_CLIENT = new RequestTypes('user/FETCH_CLIENT');
|
||||||
export const UPDATE_PASSWORD = new RequestTypes('user/UPDATE_PASSWORD');
|
export const UPDATE_PASSWORD = new RequestTypes('user/UPDATE_PASSWORD');
|
||||||
const PUT_CLIENT = new RequestTypes('user/PUT_CLIENT');
|
const PUT_CLIENT = new RequestTypes('user/PUT_CLIENT');
|
||||||
|
|
@ -20,6 +26,8 @@ const RESET_ERRORS = 'user/RESET_ERRORS';
|
||||||
const PUSH_NEW_SITE = 'user/PUSH_NEW_SITE';
|
const PUSH_NEW_SITE = 'user/PUSH_NEW_SITE';
|
||||||
const SET_ONBOARDING = 'user/SET_ONBOARDING';
|
const SET_ONBOARDING = 'user/SET_ONBOARDING';
|
||||||
const UPDATE_ACCOUNT_MODULE = 'user/UPDATE_ACCOUNT_MODULE';
|
const UPDATE_ACCOUNT_MODULE = 'user/UPDATE_ACCOUNT_MODULE';
|
||||||
|
const UPGRADE_ACCOUNT_SCOPE = new RequestTypes('user/UPGRADE_ACCOUNT_SCOPE');
|
||||||
|
const DOWNGRADE_ACCOUNT_SCOPE = new RequestTypes('user/DOWNGRADE_ACCOUNT_SCOPE');
|
||||||
|
|
||||||
export const initialState = Map({
|
export const initialState = Map({
|
||||||
account: Account(),
|
account: Account(),
|
||||||
|
|
@ -31,11 +39,14 @@ export const initialState = Map({
|
||||||
onboarding: false,
|
onboarding: false,
|
||||||
sites: List(),
|
sites: List(),
|
||||||
jwt: null,
|
jwt: null,
|
||||||
|
spotJwt: null,
|
||||||
errors: List(),
|
errors: List(),
|
||||||
loginRequest: {
|
loginRequest: {
|
||||||
loading: false,
|
loading: false,
|
||||||
errors: []
|
errors: [],
|
||||||
}
|
},
|
||||||
|
scope: null,
|
||||||
|
scopeSetup: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setClient = (state, data) => {
|
const setClient = (state, data) => {
|
||||||
|
|
@ -49,32 +60,54 @@ export const DELETE = new RequestTypes('jwt/DELETE');
|
||||||
export function setJwt(data) {
|
export function setJwt(data) {
|
||||||
return {
|
return {
|
||||||
type: UPDATE_JWT,
|
type: UPDATE_JWT,
|
||||||
data
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getScope = (state) => state.getIn(['user', 'scope']);
|
||||||
|
|
||||||
const reducer = (state = initialState, action = {}) => {
|
const reducer = (state = initialState, action = {}) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case RESET_ERRORS:
|
case RESET_ERRORS:
|
||||||
return state.set('requestResetPassowrd', List());
|
return state.set('requestResetPassowrd', List());
|
||||||
case UPDATE_JWT:
|
case UPDATE_JWT:
|
||||||
return state.set('jwt', action.data);
|
return state
|
||||||
|
.set('jwt', action.data.jwt)
|
||||||
|
.set('spotJwt', action.data.spotJwt);
|
||||||
case LOGIN.REQUEST:
|
case LOGIN.REQUEST:
|
||||||
return state.set('loginRequest', { loading: true, errors: [] });
|
return state.set('loginRequest', { loading: true, errors: [] });
|
||||||
case RESET_PASSWORD.SUCCESS:
|
case RESET_PASSWORD.SUCCESS:
|
||||||
case LOGIN.SUCCESS:
|
case LOGIN.SUCCESS:
|
||||||
return state.set('account', Account({ ...action.data.user })).set('loginRequest', { loading: false, errors: [] });
|
return state
|
||||||
|
.set('account', Account({ ...action.data.data.user }))
|
||||||
|
.set('spotJwt', action.data.spotJwt)
|
||||||
|
.set('scope', action.data.data.scope)
|
||||||
|
.set('loginRequest', { loading: false, errors: [] });
|
||||||
case UPDATE_PASSWORD.REQUEST:
|
case UPDATE_PASSWORD.REQUEST:
|
||||||
case UPDATE_PASSWORD.SUCCESS:
|
case UPDATE_PASSWORD.SUCCESS:
|
||||||
return state.set('passwordErrors', List());
|
return state.set('passwordErrors', List());
|
||||||
case SIGNUP.SUCCESS:
|
case SIGNUP.SUCCESS:
|
||||||
return state.set('account', Account(action.data.user)).set('onboarding', true);
|
return state
|
||||||
|
.set('account', Account(action.data.user))
|
||||||
|
.set('scope', action.data.scope)
|
||||||
|
.set('scopeSetup', true);
|
||||||
|
case UPGRADE_ACCOUNT_SCOPE.SUCCESS:
|
||||||
|
return state
|
||||||
|
.set('scope', 'full')
|
||||||
|
.set('scopeSetup', false)
|
||||||
|
.set('onboarding', true)
|
||||||
|
case DOWNGRADE_ACCOUNT_SCOPE.SUCCESS:
|
||||||
|
return state
|
||||||
|
.set('scope', 'spot')
|
||||||
|
.set('scopeSetup', false)
|
||||||
case REQUEST_RESET_PASSWORD.SUCCESS:
|
case REQUEST_RESET_PASSWORD.SUCCESS:
|
||||||
break;
|
break;
|
||||||
case UPDATE_ACCOUNT.SUCCESS:
|
case UPDATE_ACCOUNT.SUCCESS:
|
||||||
case FETCH_ACCOUNT.SUCCESS:
|
case FETCH_ACCOUNT.SUCCESS:
|
||||||
return state.set('account', Account(action.data)).set('passwordErrors', List());
|
return state
|
||||||
|
.set('account', Account(action.data))
|
||||||
|
.set('scope', action.data.scope)
|
||||||
|
.set('passwordErrors', List());
|
||||||
case FETCH_TENANTS.SUCCESS:
|
case FETCH_TENANTS.SUCCESS:
|
||||||
return state.set('authDetails', action.data);
|
return state.set('authDetails', action.data);
|
||||||
case UPDATE_PASSWORD.FAILURE:
|
case UPDATE_PASSWORD.FAILURE:
|
||||||
|
|
@ -82,7 +115,10 @@ const reducer = (state = initialState, action = {}) => {
|
||||||
case LOGIN.FAILURE:
|
case LOGIN.FAILURE:
|
||||||
console.log('login failed', action);
|
console.log('login failed', action);
|
||||||
deleteCookie('jwt', '/', 'openreplay.com');
|
deleteCookie('jwt', '/', 'openreplay.com');
|
||||||
return state.set('loginRequest', { loading: false, errors: action.errors });
|
return state.set('loginRequest', {
|
||||||
|
loading: false,
|
||||||
|
errors: action.errors,
|
||||||
|
});
|
||||||
case FETCH_ACCOUNT.FAILURE:
|
case FETCH_ACCOUNT.FAILURE:
|
||||||
case DELETE.SUCCESS:
|
case DELETE.SUCCESS:
|
||||||
case DELETE.FAILURE:
|
case DELETE.FAILURE:
|
||||||
|
|
@ -93,14 +129,15 @@ const reducer = (state = initialState, action = {}) => {
|
||||||
case FETCH_CLIENT.SUCCESS:
|
case FETCH_CLIENT.SUCCESS:
|
||||||
return setClient(state, action.data);
|
return setClient(state, action.data);
|
||||||
case PUSH_NEW_SITE:
|
case PUSH_NEW_SITE:
|
||||||
return state.updateIn(['site', 'list'], list =>
|
return state.updateIn(['site', 'list'], (list) =>
|
||||||
list.push(action.newSite));
|
list.push(action.newSite)
|
||||||
|
);
|
||||||
case SET_ONBOARDING:
|
case SET_ONBOARDING:
|
||||||
return state.set('onboarding', action.state);
|
return state.set('onboarding', action.state);
|
||||||
case UPDATE_ACCOUNT_MODULE:
|
case UPDATE_ACCOUNT_MODULE:
|
||||||
return state.updateIn(['account', 'settings', 'modules'], modules => {
|
return state.updateIn(['account', 'settings', 'modules'], (modules) => {
|
||||||
if (modules.includes(action.moduleKey)) {
|
if (modules.includes(action.moduleKey)) {
|
||||||
return modules.filter(module => module !== action.moduleKey);
|
return modules.filter((module) => module !== action.moduleKey);
|
||||||
} else {
|
} else {
|
||||||
return modules.concat(action.moduleKey);
|
return modules.concat(action.moduleKey);
|
||||||
}
|
}
|
||||||
|
|
@ -109,112 +146,136 @@ const reducer = (state = initialState, action = {}) => {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default withRequestState(
|
||||||
|
{
|
||||||
|
signupRequest: SIGNUP,
|
||||||
|
updatePasswordRequest: UPDATE_PASSWORD,
|
||||||
|
requestResetPassowrd: REQUEST_RESET_PASSWORD,
|
||||||
|
resetPassword: RESET_PASSWORD,
|
||||||
|
fetchUserInfoRequest: [FETCH_ACCOUNT, FETCH_CLIENT, FETCH_TENANTS],
|
||||||
|
putClientRequest: PUT_CLIENT,
|
||||||
|
updateAccountRequest: UPDATE_ACCOUNT,
|
||||||
|
},
|
||||||
|
reducer
|
||||||
|
);
|
||||||
|
|
||||||
export default withRequestState({
|
export const upgradeScope = () => ({
|
||||||
signupRequest: SIGNUP,
|
types: UPGRADE_ACCOUNT_SCOPE.toArray(),
|
||||||
// loginRequest: LOGIN,
|
call: (client) => client.post('/account/scope', { scope: 'full' }),
|
||||||
updatePasswordRequest: UPDATE_PASSWORD,
|
|
||||||
requestResetPassowrd: REQUEST_RESET_PASSWORD,
|
|
||||||
resetPassword: RESET_PASSWORD,
|
|
||||||
fetchUserInfoRequest: [FETCH_ACCOUNT, FETCH_CLIENT, FETCH_TENANTS],
|
|
||||||
putClientRequest: PUT_CLIENT,
|
|
||||||
updateAccountRequest: UPDATE_ACCOUNT
|
|
||||||
}, reducer);
|
|
||||||
|
|
||||||
export const login = params => ({
|
|
||||||
types: LOGIN.toArray(),
|
|
||||||
call: client => client.post('/login', params)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const loginSuccess = data => ({
|
|
||||||
type: LOGIN.SUCCESS,
|
|
||||||
data
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const signup = params => dispatch => dispatch({
|
export const downgradeScope = () => ({
|
||||||
types: SIGNUP.toArray(),
|
types: DOWNGRADE_ACCOUNT_SCOPE.toArray(),
|
||||||
call: client => client.post('/signup', params)
|
call: (client) => client.post('/account/scope', { scope: 'spot' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const login = (params) => ({
|
||||||
|
types: LOGIN.toArray(),
|
||||||
|
call: (client) => client.post('/login', params),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetPassword = params => dispatch => dispatch({
|
export const loadingLogin = () => ({
|
||||||
types: RESET_PASSWORD.toArray(),
|
type: LOGIN.REQUEST,
|
||||||
call: client => client.post('/password/reset', params)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const requestResetPassword = params => dispatch => dispatch({
|
export const loginSuccess = (data) => ({
|
||||||
types: REQUEST_RESET_PASSWORD.toArray(),
|
type: LOGIN.SUCCESS,
|
||||||
call: client => client.post('/password/reset-link', params)
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updatePassword = params => dispatch => dispatch({
|
export const loginFailure = (errors) => ({
|
||||||
types: UPDATE_PASSWORD.toArray(),
|
type: LOGIN.FAILURE,
|
||||||
call: client => client.post('/account/password', params)
|
errors,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const signup = (params) => (dispatch) =>
|
||||||
|
dispatch({
|
||||||
|
types: SIGNUP.toArray(),
|
||||||
|
call: (client) => client.post('/signup', params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetPassword = (params) => (dispatch) =>
|
||||||
|
dispatch({
|
||||||
|
types: RESET_PASSWORD.toArray(),
|
||||||
|
call: (client) => client.post('/password/reset', params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const requestResetPassword = (params) => (dispatch) =>
|
||||||
|
dispatch({
|
||||||
|
types: REQUEST_RESET_PASSWORD.toArray(),
|
||||||
|
call: (client) => client.post('/password/reset-link', params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePassword = (params) => (dispatch) =>
|
||||||
|
dispatch({
|
||||||
|
types: UPDATE_PASSWORD.toArray(),
|
||||||
|
call: (client) => client.post('/account/password', params),
|
||||||
|
});
|
||||||
|
|
||||||
export function fetchTenants() {
|
export function fetchTenants() {
|
||||||
return {
|
return {
|
||||||
types: FETCH_TENANTS.toArray(),
|
types: FETCH_TENANTS.toArray(),
|
||||||
call: client => client.get('/signup')
|
call: (client) => client.get('/signup'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchUserInfo = () => ({
|
export const fetchUserInfo = () => ({
|
||||||
types: FETCH_ACCOUNT.toArray(),
|
types: FETCH_ACCOUNT.toArray(),
|
||||||
call: client => client.get('/account')
|
call: (client) => client.get('/account'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
return {
|
return {
|
||||||
types: DELETE.toArray(),
|
types: DELETE.toArray(),
|
||||||
call: client => client.get('/logout')
|
call: (client) => client.get('/logout'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateClient(params) {
|
export function updateClient(params) {
|
||||||
return {
|
return {
|
||||||
types: PUT_CLIENT.toArray(),
|
types: PUT_CLIENT.toArray(),
|
||||||
call: client => client.post('/account', params),
|
call: (client) => client.post('/account', params),
|
||||||
params
|
params,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAccount(params) {
|
export function updateAccount(params) {
|
||||||
return {
|
return {
|
||||||
types: UPDATE_ACCOUNT.toArray(),
|
types: UPDATE_ACCOUNT.toArray(),
|
||||||
call: client => client.post('/account', params)
|
call: (client) => client.post('/account', params),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resendEmailVerification(email) {
|
export function resendEmailVerification(email) {
|
||||||
return {
|
return {
|
||||||
types: RESEND_EMAIL_VERIFICATION.toArray(),
|
types: RESEND_EMAIL_VERIFICATION.toArray(),
|
||||||
call: client => client.post('/re-validate', { email })
|
call: (client) => client.post('/re-validate', { email }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pushNewSite(newSite) {
|
export function pushNewSite(newSite) {
|
||||||
return {
|
return {
|
||||||
type: PUSH_NEW_SITE,
|
type: PUSH_NEW_SITE,
|
||||||
newSite
|
newSite,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setOnboarding(state = false) {
|
export function setOnboarding(state = false) {
|
||||||
return {
|
return {
|
||||||
type: SET_ONBOARDING,
|
type: SET_ONBOARDING,
|
||||||
state
|
state,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetErrors() {
|
export function resetErrors() {
|
||||||
return {
|
return {
|
||||||
type: RESET_ERRORS
|
type: RESET_ERRORS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateModule(moduleKey) {
|
export function updateModule(moduleKey) {
|
||||||
return {
|
return {
|
||||||
type: UPDATE_ACCOUNT_MODULE,
|
type: UPDATE_ACCOUNT_MODULE,
|
||||||
moduleKey
|
moduleKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
frontend/app/layout/InitORCard.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function InitORCard({
|
||||||
|
onOpenModal,
|
||||||
|
}: {
|
||||||
|
onOpenModal: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'shadow-sm flex flex-col gap-4 bg-white items-center p-4 mx-auto rounded'
|
||||||
|
}
|
||||||
|
style={{ width: 236 }}
|
||||||
|
>
|
||||||
|
<img src={'/assets/img/init-or.png'} width={200} height={120} />
|
||||||
|
<div className={'font-semibold'}>
|
||||||
|
Discover the full potential of OpenReplay!
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Empower your product team with essential tools like Session Replay,
|
||||||
|
Product Analytics, Co-Browsing, and more.
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
icon={<ArrowRightOutlined />}
|
||||||
|
iconPosition={'end'}
|
||||||
|
onClick={onOpenModal}
|
||||||
|
>
|
||||||
|
Setup OpenReplay Tracker
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InitORCard;
|
||||||
|
|
@ -1,16 +1,36 @@
|
||||||
import React from 'react';
|
import { Divider, Menu, Tag, Typography } from 'antd';
|
||||||
import { Menu, Typography } from 'antd';
|
|
||||||
import SVG from 'UI/SVG';
|
|
||||||
import * as routes from 'App/routes';
|
|
||||||
import { bookmarks, client, CLIENT_DEFAULT_TAB, CLIENT_TABS, fflags, notes, sessions, withSiteId } from 'App/routes';
|
|
||||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
|
||||||
import { categories as main_menu, MENU, preferences, PREFERENCES_MENU } from './data';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { MODULES } from 'Components/Client/Modules';
|
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { Icon, Divider } from 'UI';
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import SupportModal from 'App/layout/SupportModal';
|
import SupportModal from 'App/layout/SupportModal';
|
||||||
|
import * as routes from 'App/routes';
|
||||||
|
import {
|
||||||
|
CLIENT_DEFAULT_TAB,
|
||||||
|
CLIENT_TABS,
|
||||||
|
bookmarks,
|
||||||
|
client,
|
||||||
|
fflags,
|
||||||
|
notes,
|
||||||
|
sessions,
|
||||||
|
withSiteId,
|
||||||
|
} from 'App/routes';
|
||||||
|
import { MODULES } from 'Components/Client/Modules';
|
||||||
import { setActiveTab } from 'Duck/search';
|
import { setActiveTab } from 'Duck/search';
|
||||||
|
import { Icon } from 'UI';
|
||||||
|
import SVG from 'UI/SVG';
|
||||||
|
|
||||||
|
import { getScope } from 'App/duck/user';
|
||||||
|
import InitORCard from './InitORCard';
|
||||||
|
import SpotToOpenReplayPrompt from './SpotToOpenReplayPrompt';
|
||||||
|
import {
|
||||||
|
MENU,
|
||||||
|
PREFERENCES_MENU,
|
||||||
|
categories as main_menu,
|
||||||
|
preferences,
|
||||||
|
spotOnlyCats,
|
||||||
|
} from './data';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|
@ -18,10 +38,9 @@ const TabToUrlMap = {
|
||||||
all: sessions() as '/sessions',
|
all: sessions() as '/sessions',
|
||||||
bookmark: bookmarks() as '/bookmarks',
|
bookmark: bookmarks() as '/bookmarks',
|
||||||
notes: notes() as '/notes',
|
notes: notes() as '/notes',
|
||||||
flags: fflags() as '/feature-flags'
|
flags: fflags() as '/feature-flags',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
interface Props extends RouteComponentProps {
|
interface Props extends RouteComponentProps {
|
||||||
siteId?: string;
|
siteId?: string;
|
||||||
modules: string[];
|
modules: string[];
|
||||||
|
|
@ -29,78 +48,114 @@ interface Props extends RouteComponentProps {
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
isEnterprise: boolean;
|
isEnterprise: boolean;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
|
spotOnly?: boolean;
|
||||||
|
account: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function SideMenu(props: Props) {
|
function SideMenu(props: Props) {
|
||||||
// @ts-ignore
|
const {
|
||||||
const { activeTab, siteId, modules, location, account, isEnterprise, isCollapsed } = props;
|
activeTab,
|
||||||
|
siteId,
|
||||||
|
modules,
|
||||||
|
location,
|
||||||
|
account,
|
||||||
|
isEnterprise,
|
||||||
|
isCollapsed,
|
||||||
|
spotOnly,
|
||||||
|
} = props;
|
||||||
const isPreferencesActive = location.pathname.includes('/client/');
|
const isPreferencesActive = location.pathname.includes('/client/');
|
||||||
const [supportOpen, setSupportOpen] = React.useState(false);
|
const [supportOpen, setSupportOpen] = React.useState(false);
|
||||||
const isAdmin = account.admin || account.superAdmin;
|
const isAdmin = account.admin || account.superAdmin;
|
||||||
|
|
||||||
|
const [isModalVisible, setIsModalVisible] = React.useState(false);
|
||||||
|
|
||||||
|
const handleModalOpen = () => {
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
let menu: any[] = React.useMemo(() => {
|
let menu: any[] = React.useMemo(() => {
|
||||||
const sourceMenu = isPreferencesActive ? preferences : main_menu;
|
const sourceMenu = isPreferencesActive ? preferences : main_menu;
|
||||||
|
|
||||||
return sourceMenu.map(category => {
|
return sourceMenu
|
||||||
const updatedItems = category.items.map(item => {
|
.filter((cat) => {
|
||||||
if (isEnterprise) {
|
if (spotOnly) {
|
||||||
if (item.key === MENU.BOOKMARKS) {
|
return spotOnlyCats.includes(cat.key);
|
||||||
return { ...item, hidden: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.key === MENU.VAULT) {
|
|
||||||
return { ...item, hidden: false };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (item.key === MENU.VAULT) {
|
|
||||||
return { ...item, hidden: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.key === MENU.BOOKMARKS) {
|
|
||||||
return { ...item, hidden: false };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (item.hidden) return item;
|
return true;
|
||||||
|
})
|
||||||
|
.map((category) => {
|
||||||
|
const updatedItems = category.items
|
||||||
|
.filter((item) => {
|
||||||
|
if (spotOnly) {
|
||||||
|
return spotOnlyCats.includes(item.key);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
if (isEnterprise) {
|
||||||
|
if (item.key === MENU.BOOKMARKS) {
|
||||||
|
return { ...item, hidden: true };
|
||||||
|
}
|
||||||
|
|
||||||
const isHidden = [
|
if (item.key === MENU.VAULT) {
|
||||||
(item.key === MENU.RECOMMENDATIONS && modules.includes(MODULES.RECOMMENDATIONS)),
|
return { ...item, hidden: false };
|
||||||
(item.key === MENU.FEATURE_FLAGS && modules.includes(MODULES.FEATURE_FLAGS)),
|
}
|
||||||
(item.key === MENU.NOTES && modules.includes(MODULES.NOTES)),
|
} else {
|
||||||
(item.key === MENU.LIVE_SESSIONS && modules.includes(MODULES.ASSIST)),
|
if (item.key === MENU.VAULT) {
|
||||||
(item.key === MENU.SESSIONS && modules.includes(MODULES.OFFLINE_RECORDINGS)),
|
return { ...item, hidden: true };
|
||||||
(item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS)),
|
}
|
||||||
(item.isAdmin && !isAdmin),
|
|
||||||
(item.isEnterprise && !isEnterprise),
|
|
||||||
].some(cond => cond);
|
|
||||||
|
|
||||||
return { ...item, hidden: isHidden };
|
if (item.key === MENU.BOOKMARKS) {
|
||||||
|
return { ...item, hidden: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.hidden) return item;
|
||||||
|
|
||||||
|
const isHidden = [
|
||||||
|
item.key === MENU.RECOMMENDATIONS &&
|
||||||
|
modules.includes(MODULES.RECOMMENDATIONS),
|
||||||
|
item.key === MENU.FEATURE_FLAGS &&
|
||||||
|
modules.includes(MODULES.FEATURE_FLAGS),
|
||||||
|
item.key === MENU.NOTES && modules.includes(MODULES.NOTES),
|
||||||
|
item.key === MENU.LIVE_SESSIONS &&
|
||||||
|
modules.includes(MODULES.ASSIST),
|
||||||
|
item.key === MENU.SESSIONS &&
|
||||||
|
modules.includes(MODULES.OFFLINE_RECORDINGS),
|
||||||
|
item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS),
|
||||||
|
item.isAdmin && !isAdmin,
|
||||||
|
item.isEnterprise && !isEnterprise,
|
||||||
|
].some((cond) => cond);
|
||||||
|
|
||||||
|
return { ...item, hidden: isHidden };
|
||||||
|
});
|
||||||
|
|
||||||
|
const allItemsHidden = updatedItems.every((item) => item.hidden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
items: updatedItems,
|
||||||
|
hidden: allItemsHidden,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
}, [isAdmin, isEnterprise, isPreferencesActive, modules, spotOnly]);
|
||||||
// Check if all items are hidden in this category
|
|
||||||
const allItemsHidden = updatedItems.every(item => item.hidden);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...category,
|
|
||||||
items: updatedItems,
|
|
||||||
hidden: allItemsHidden // Set the hidden flag for the category
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [isAdmin, isEnterprise, isPreferencesActive, modules]);
|
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const currentLocation = location.pathname;
|
const currentLocation = location.pathname;
|
||||||
const tab = Object.keys(TabToUrlMap).find((tab: keyof typeof TabToUrlMap) => currentLocation.includes(TabToUrlMap[tab]));
|
const tab = Object.keys(TabToUrlMap).find((tab: keyof typeof TabToUrlMap) =>
|
||||||
|
currentLocation.includes(TabToUrlMap[tab])
|
||||||
|
);
|
||||||
if (tab && tab !== activeTab) {
|
if (tab && tab !== activeTab) {
|
||||||
props.setActiveTab({ type: tab });
|
props.setActiveTab({ type: tab });
|
||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
|
||||||
const menuRoutes: any = {
|
const menuRoutes: any = {
|
||||||
[MENU.EXIT]: () => props.history.push(withSiteId(routes.sessions(), siteId)),
|
[MENU.EXIT]: () =>
|
||||||
|
props.history.push(withSiteId(routes.sessions(), siteId)),
|
||||||
[MENU.SESSIONS]: () => withSiteId(routes.sessions(), siteId),
|
[MENU.SESSIONS]: () => withSiteId(routes.sessions(), siteId),
|
||||||
[MENU.BOOKMARKS]: () => withSiteId(routes.bookmarks(), siteId),
|
[MENU.BOOKMARKS]: () => withSiteId(routes.bookmarks(), siteId),
|
||||||
[MENU.VAULT]: () => withSiteId(routes.bookmarks(), siteId),
|
[MENU.VAULT]: () => withSiteId(routes.bookmarks(), siteId),
|
||||||
|
|
@ -114,7 +169,8 @@ function SideMenu(props: Props) {
|
||||||
[MENU.USABILITY_TESTS]: () => withSiteId(routes.usabilityTesting(), siteId),
|
[MENU.USABILITY_TESTS]: () => withSiteId(routes.usabilityTesting(), siteId),
|
||||||
[MENU.SPOTS]: () => withSiteId(routes.spotsList(), siteId),
|
[MENU.SPOTS]: () => withSiteId(routes.spotsList(), siteId),
|
||||||
[PREFERENCES_MENU.ACCOUNT]: () => client(CLIENT_TABS.PROFILE),
|
[PREFERENCES_MENU.ACCOUNT]: () => client(CLIENT_TABS.PROFILE),
|
||||||
[PREFERENCES_MENU.SESSION_LISTING]: () => client(CLIENT_TABS.SESSIONS_LISTING),
|
[PREFERENCES_MENU.SESSION_LISTING]: () =>
|
||||||
|
client(CLIENT_TABS.SESSIONS_LISTING),
|
||||||
[PREFERENCES_MENU.INTEGRATIONS]: () => client(CLIENT_TABS.INTEGRATIONS),
|
[PREFERENCES_MENU.INTEGRATIONS]: () => client(CLIENT_TABS.INTEGRATIONS),
|
||||||
[PREFERENCES_MENU.METADATA]: () => client(CLIENT_TABS.CUSTOM_FIELDS),
|
[PREFERENCES_MENU.METADATA]: () => client(CLIENT_TABS.CUSTOM_FIELDS),
|
||||||
[PREFERENCES_MENU.WEBHOOKS]: () => client(CLIENT_TABS.WEBHOOKS),
|
[PREFERENCES_MENU.WEBHOOKS]: () => client(CLIENT_TABS.WEBHOOKS),
|
||||||
|
|
@ -124,7 +180,7 @@ function SideMenu(props: Props) {
|
||||||
[PREFERENCES_MENU.TEAM]: () => client(CLIENT_TABS.MANAGE_USERS),
|
[PREFERENCES_MENU.TEAM]: () => client(CLIENT_TABS.MANAGE_USERS),
|
||||||
[PREFERENCES_MENU.NOTIFICATIONS]: () => client(CLIENT_TABS.NOTIFICATIONS),
|
[PREFERENCES_MENU.NOTIFICATIONS]: () => client(CLIENT_TABS.NOTIFICATIONS),
|
||||||
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
|
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
|
||||||
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES)
|
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (item: any) => {
|
const handleClick = (item: any) => {
|
||||||
|
|
@ -150,18 +206,21 @@ function SideMenu(props: Props) {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const pushTo = (path: string) => {
|
const pushTo = (path: string) => {
|
||||||
props.history.push(path);
|
props.history.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
mode='inline' onClick={handleClick}
|
mode="inline"
|
||||||
|
onClick={handleClick}
|
||||||
style={{ marginTop: '8px', border: 'none' }}
|
style={{ marginTop: '8px', border: 'none' }}
|
||||||
selectedKeys={menu.flatMap(category => category.items.filter((item: any) => isMenuItemActive(item.key)).map(item => item.key))}
|
selectedKeys={menu.flatMap((category) =>
|
||||||
|
category.items
|
||||||
|
.filter((item: any) => isMenuItemActive(item.key))
|
||||||
|
.map((item) => item.key)
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{menu.map((category, index) => (
|
{menu.map((category, index) => (
|
||||||
<React.Fragment key={category.key}>
|
<React.Fragment key={category.key}>
|
||||||
|
|
@ -169,7 +228,9 @@ function SideMenu(props: Props) {
|
||||||
<>
|
<>
|
||||||
{index > 0 && <Divider style={{ margin: '6px 0' }} />}
|
{index > 0 && <Divider style={{ margin: '6px 0' }} />}
|
||||||
|
|
||||||
{category.items.filter((item: any) => !item.hidden).map((item: any) => {
|
{category.items
|
||||||
|
.filter((item: any) => !item.hidden)
|
||||||
|
.map((item: any) => {
|
||||||
const isActive = isMenuItemActive(item.key);
|
const isActive = isMenuItemActive(item.key);
|
||||||
|
|
||||||
if (item.key === MENU.EXIT) {
|
if (item.key === MENU.EXIT) {
|
||||||
|
|
@ -177,7 +238,13 @@ function SideMenu(props: Props) {
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={item.key}
|
key={item.key}
|
||||||
style={{ paddingLeft: '20px' }}
|
style={{ paddingLeft: '20px' }}
|
||||||
icon={<Icon name={item.icon} size={16} color={isActive ? 'teal' : ''} />}
|
icon={
|
||||||
|
<Icon
|
||||||
|
name={item.icon}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? 'teal' : ''}
|
||||||
|
/>
|
||||||
|
}
|
||||||
className={cn('!rounded-lg hover-fill-teal')}
|
className={cn('!rounded-lg hover-fill-teal')}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
@ -185,49 +252,126 @@ function SideMenu(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.key === MENU.SPOTS) {
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={item.key}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
name={item.icon}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? 'teal' : ''}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{ paddingLeft: '20px' }}
|
||||||
|
className={cn('!rounded-lg hover-fill-teal !pe-0')}
|
||||||
|
itemIcon={
|
||||||
|
item.leading ? (
|
||||||
|
<Icon
|
||||||
|
name={item.leading}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? 'teal' : ''}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<Tag
|
||||||
|
color="cyan"
|
||||||
|
bordered={false}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
Beta{' '}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return item.children ? (
|
return item.children ? (
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
key={item.key}
|
key={item.key}
|
||||||
title={<Text className={cn('ml-5 !rounded')}>{item.label}</Text>}
|
title={
|
||||||
icon={<SVG name={item.icon} size={16} />}>
|
<Text className={cn('ml-5 !rounded')}>
|
||||||
{/*style={{ paddingLeft: '30px' }}*/}
|
{item.label}
|
||||||
{item.children.map((child: any) => <Menu.Item
|
</Text>
|
||||||
className={cn('ml-8', { 'ant-menu-item-selected !bg-active-dark-blue': isMenuItemActive(child.key) })}
|
}
|
||||||
key={child.key}>{child.label}</Menu.Item>)}
|
icon={<SVG name={item.icon} size={16} />}
|
||||||
|
>
|
||||||
|
{item.children.map((child: any) => (
|
||||||
|
<Menu.Item
|
||||||
|
className={cn('ml-8', {
|
||||||
|
'ant-menu-item-selected !bg-active-dark-blue':
|
||||||
|
isMenuItemActive(child.key),
|
||||||
|
})}
|
||||||
|
key={child.key}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={item.key}
|
key={item.key}
|
||||||
icon={<Icon name={item.icon} size={16} color={isActive ? 'teal' : ''}
|
icon={
|
||||||
className={'hover-fill-teal'} />}
|
<Icon
|
||||||
|
name={item.icon}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? 'teal' : ''}
|
||||||
|
className={'hover-fill-teal'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
style={{ paddingLeft: '20px' }}
|
style={{ paddingLeft: '20px' }}
|
||||||
className={cn('!rounded-lg hover-fill-teal')}
|
className={cn('!rounded-lg hover-fill-teal')}
|
||||||
itemIcon={item.leading ?
|
itemIcon={
|
||||||
<Icon name={item.leading} size={16} color={isActive ? 'teal' : ''} /> : null}>
|
item.leading ? (
|
||||||
|
<Icon
|
||||||
|
name={item.leading}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? 'teal' : ''}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
<SupportModal
|
{spotOnly && !isPreferencesActive ? (
|
||||||
onClose={() => {
|
<>
|
||||||
setSupportOpen(false);
|
<InitORCard onOpenModal={handleModalOpen} />
|
||||||
}} open={supportOpen} />
|
<SpotToOpenReplayPrompt
|
||||||
|
isVisible={isModalVisible}
|
||||||
|
onCancel={handleModalClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<SupportModal onClose={() => setSupportOpen(false)} open={supportOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(
|
export default withRouter(
|
||||||
connect((state: any) => ({
|
connect(
|
||||||
|
(state: any) => ({
|
||||||
modules: state.getIn(['user', 'account', 'settings', 'modules']) || [],
|
modules: state.getIn(['user', 'account', 'settings', 'modules']) || [],
|
||||||
activeTab: state.getIn(['search', 'activeTab', 'type']),
|
activeTab: state.getIn(['search', 'activeTab', 'type']),
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
||||||
account: state.getIn(['user', 'account'])
|
account: state.getIn(['user', 'account']),
|
||||||
|
spotOnly: getScope(state) === 'spot',
|
||||||
}),
|
}),
|
||||||
{ setActiveTab }
|
{ setActiveTab }
|
||||||
)(SideMenu)
|
)(SideMenu)
|
||||||
|
|
|
||||||
74
frontend/app/layout/SpotToOpenReplayPrompt.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal, Button, List, Divider } from 'antd';
|
||||||
|
import { CircleDot, Play, TrendingUp, Radio, Sparkles, Plug, ArrowRight } from 'lucide-react';
|
||||||
|
import { upgradeScope } from 'App/duck/user';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { onboarding } from 'App/routes';
|
||||||
|
|
||||||
|
interface SpotToOpenReplayPromptProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
upgradeScope: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotToOpenReplayPrompt: React.FC<SpotToOpenReplayPromptProps> = ({ upgradeScope, isVisible, onCancel }: {
|
||||||
|
upgradeScope: () => Promise<void>;
|
||||||
|
isVisible: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) => {
|
||||||
|
const history = useHistory();
|
||||||
|
const features = [
|
||||||
|
{ icon: <CircleDot />, text: 'Spot', noBorder: true },
|
||||||
|
{ isDivider: true },
|
||||||
|
{ icon: <Play />, text: 'Session Replay & DevTools' },
|
||||||
|
{ icon: <TrendingUp />, text: 'Product Analytics' },
|
||||||
|
{ icon: <Radio />, text: 'Co-Browsing (Live Session Replay & Customer Support)' },
|
||||||
|
{ icon: <Sparkles />, text: 'AI Powered Features' },
|
||||||
|
{ icon: <Plug />, text: 'Integrations & more' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onUpgrade = () => {
|
||||||
|
upgradeScope().then(() => {
|
||||||
|
history.push(onboarding());
|
||||||
|
onCancel();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Setup OpenReplay"
|
||||||
|
visible={isVisible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>,
|
||||||
|
<Button key="setup" type="primary" onClick={onUpgrade} className='gap-2'>
|
||||||
|
Setup OpenReplay Tracker <ArrowRight size={16} />
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
By setting up OpenReplay, you'll unlock access to the following core features available under the OpenReplay free tier.
|
||||||
|
</p>
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={features}
|
||||||
|
renderItem={item =>
|
||||||
|
item.isDivider ? (
|
||||||
|
<Divider plain className="text-sm text-slate-500 ">+ Plus</Divider>
|
||||||
|
) : (
|
||||||
|
<List.Item style={item.noBorder ? { borderBottom: 'none' } : {}}>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={item.icon}
|
||||||
|
title={item.text}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(null, { upgradeScope })(SpotToOpenReplayPrompt);
|
||||||
|
|
@ -1,35 +1,46 @@
|
||||||
|
import { Popover, Space } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import GettingStartedProgress from 'Shared/GettingStarted/GettingStartedProgress';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { getInitials } from 'App/utils';
|
||||||
import Notifications from 'Components/Alerts/Notifications/Notifications';
|
import Notifications from 'Components/Alerts/Notifications/Notifications';
|
||||||
import HealthStatus from 'Components/Header/HealthStatus';
|
import HealthStatus from 'Components/Header/HealthStatus';
|
||||||
import { getInitials } from 'App/utils';
|
|
||||||
import UserMenu from 'Components/Header/UserMenu/UserMenu';
|
import UserMenu from 'Components/Header/UserMenu/UserMenu';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Popover, Space } from 'antd';
|
import GettingStartedProgress from 'Shared/GettingStarted/GettingStartedProgress';
|
||||||
import ProjectDropdown from 'Shared/ProjectDropdown';
|
import ProjectDropdown from 'Shared/ProjectDropdown';
|
||||||
|
import { getScope } from "../duck/user";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
account: any;
|
account: any;
|
||||||
siteId: any;
|
siteId: any;
|
||||||
sites: any;
|
sites: any;
|
||||||
boardingCompletion: any;
|
boardingCompletion: any;
|
||||||
|
spotOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopRight(props: Props) {
|
function TopRight(props: Props) {
|
||||||
const { account } = props;
|
const { account } = props;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
<Space style={{ lineHeight: '0'}}>
|
<Space style={{ lineHeight: '0' }}>
|
||||||
<ProjectDropdown />
|
{props.spotOnly ? null : (
|
||||||
<GettingStartedProgress />
|
<>
|
||||||
|
<ProjectDropdown />
|
||||||
|
<GettingStartedProgress />
|
||||||
|
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
|
||||||
{account.name ? <HealthStatus /> : null}
|
{account.name ? <HealthStatus /> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Popover content={<UserMenu />} placement={'topRight'}>
|
<Popover content={<UserMenu />} placement={'topRight'}>
|
||||||
<div className='flex items-center cursor-pointer'>
|
<div className="flex items-center cursor-pointer">
|
||||||
<div className='bg-tealx rounded-full flex items-center justify-center color-white' style={{ width: '32px', height: '32px'}}>
|
<div
|
||||||
|
className="bg-tealx rounded-full flex items-center justify-center color-white"
|
||||||
|
style={{ width: '32px', height: '32px' }}
|
||||||
|
>
|
||||||
{getInitials(account.name)}
|
{getInitials(account.name)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -41,10 +52,11 @@ function TopRight(props: Props) {
|
||||||
function mapStateToProps(state: any) {
|
function mapStateToProps(state: any) {
|
||||||
return {
|
return {
|
||||||
account: state.getIn(['user', 'account']),
|
account: state.getIn(['user', 'account']),
|
||||||
|
spotOnly: getScope(state) === 'spot',
|
||||||
siteId: state.getIn(['site', 'siteId']),
|
siteId: state.getIn(['site', 'siteId']),
|
||||||
sites: state.getIn(['site', 'list']),
|
sites: state.getIn(['site', 'list']),
|
||||||
boardingCompletion: state.getIn(['dashboard', 'boardingCompletion'])
|
boardingCompletion: state.getIn(['dashboard', 'boardingCompletion']),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps)(TopRight);
|
export default connect(mapStateToProps)(TopRight);
|
||||||
|
|
|
||||||
|
|
@ -150,3 +150,14 @@ export const preferences: Category[] = [
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const spotOnlyCats = [
|
||||||
|
'spot',
|
||||||
|
'other',
|
||||||
|
PREFERENCES_MENU.TEAM,
|
||||||
|
PREFERENCES_MENU.ACCOUNT,
|
||||||
|
MENU.EXIT,
|
||||||
|
MENU.PREFERENCES,
|
||||||
|
MENU.SUPPORT,
|
||||||
|
MENU.SPOTS,
|
||||||
|
]
|
||||||
|
|
@ -72,7 +72,7 @@ class LoginStore {
|
||||||
this.setSpotJWT(resp.spotJwt)
|
this.setSpotJWT(resp.spotJwt)
|
||||||
return resp
|
return resp
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
this.setSpotJwtPending(false)
|
this.setSpotJwtPending(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,16 @@ export default class SpotStore {
|
||||||
accessKey: string | undefined = undefined;
|
accessKey: string | undefined = undefined;
|
||||||
pubKey: { value: string; expiration: number } | null = null;
|
pubKey: { value: string; expiration: number } | null = null;
|
||||||
readonly order = 'desc';
|
readonly order = 'desc';
|
||||||
|
accessError = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAccessError(error: boolean) {
|
||||||
|
this.accessError = error;
|
||||||
|
}
|
||||||
|
|
||||||
clearCurrent = () => {
|
clearCurrent = () => {
|
||||||
this.currentSpot = null;
|
this.currentSpot = null;
|
||||||
this.pubKey = null;
|
this.pubKey = null;
|
||||||
|
|
@ -67,7 +72,7 @@ export default class SpotStore {
|
||||||
this.total = total;
|
this.total = total;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchSpots() {
|
fetchSpots = async () => {
|
||||||
const filters = {
|
const filters = {
|
||||||
page: this.page,
|
page: this.page,
|
||||||
filterBy: this.filter,
|
filterBy: this.filter,
|
||||||
|
|
@ -81,22 +86,33 @@ export default class SpotStore {
|
||||||
);
|
);
|
||||||
this.setSpots(response.spots.map((spot: any) => new Spot(spot)));
|
this.setSpots(response.spots.map((spot: any) => new Spot(spot)));
|
||||||
this.setTotal(response.total);
|
this.setTotal(response.total);
|
||||||
}
|
};
|
||||||
|
|
||||||
async fetchSpotById(id: string) {
|
async fetchSpotById(id: string) {
|
||||||
const response = await this.withLoader(() =>
|
try {
|
||||||
spotService.fetchSpot(id, this.accessKey)
|
const response = await this.withLoader(() =>
|
||||||
);
|
spotService.fetchSpot(id, this.accessKey)
|
||||||
|
);
|
||||||
|
|
||||||
const spotInst = new Spot({ ...response.spot, id });
|
const spotInst = new Spot({ ...response.spot, id });
|
||||||
this.setCurrentSpot(spotInst);
|
this.setCurrentSpot(spotInst);
|
||||||
|
|
||||||
return spotInst;
|
return spotInst;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response.status === 401 || e.response.status === 403) {
|
||||||
|
this.setAccessError(true);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addComment(spotId: string, comment: string, userName: string) {
|
async addComment(spotId: string, comment: string, userName: string) {
|
||||||
await this.withLoader(async () => {
|
await this.withLoader(async () => {
|
||||||
await spotService.addComment(spotId, { comment, userName });
|
await spotService.addComment(
|
||||||
|
spotId,
|
||||||
|
{ comment, userName },
|
||||||
|
this.accessKey
|
||||||
|
);
|
||||||
const spot = this.currentSpot;
|
const spot = this.currentSpot;
|
||||||
if (spot) {
|
if (spot) {
|
||||||
spot.comments!.push({
|
spot.comments!.push({
|
||||||
|
|
@ -143,24 +159,40 @@ export default class SpotStore {
|
||||||
* @param expiration - in seconds
|
* @param expiration - in seconds
|
||||||
* @param id - spot id string
|
* @param id - spot id string
|
||||||
* */
|
* */
|
||||||
async generateKey(id: string, expiration: number) {
|
generateKey = async (id: string, expiration: number) => {
|
||||||
try {
|
try {
|
||||||
const { key } = await this.withLoader(() =>
|
const { key } = await this.withLoader(() => {
|
||||||
spotService.generateKey(id, expiration)
|
return spotService.generateKey(id, expiration);
|
||||||
);
|
});
|
||||||
this.setPubKey(key);
|
this.setPubKey(key);
|
||||||
return key;
|
return key;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('couldnt generate pubkey')
|
console.error('couldnt generate pubkey');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
async getPubKey(id: string) {
|
getPubKey = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const { key } = await this.withLoader(() => spotService.getKey(id));
|
const { key } = await this.withLoader(() => {
|
||||||
|
return spotService.getKey(id);
|
||||||
|
});
|
||||||
this.setPubKey(key);
|
this.setPubKey(key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('no pubkey', e)
|
console.error('no pubkey', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsProcessed = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const { status } = await this.withLoader(() => {
|
||||||
|
return spotService.checkProcessingStatus(id);
|
||||||
|
})
|
||||||
|
|
||||||
|
return status === 'processed';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('couldnt check status', e);
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ interface IResource {
|
||||||
time: number,
|
time: number,
|
||||||
type: ResourceType,
|
type: ResourceType,
|
||||||
url: string,
|
url: string,
|
||||||
status: string,
|
status: string | number,
|
||||||
method: string,
|
method: string,
|
||||||
duration: number,
|
duration: number,
|
||||||
success: boolean,
|
success: boolean,
|
||||||
|
|
@ -81,6 +81,7 @@ interface IResource {
|
||||||
encodedBodySize?: number,
|
encodedBodySize?: number,
|
||||||
decodedBodySize?: number,
|
decodedBodySize?: number,
|
||||||
responseBodySize?: number,
|
responseBodySize?: number,
|
||||||
|
error?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IResourceTiming extends IResource {
|
export interface IResourceTiming extends IResource {
|
||||||
|
|
@ -110,7 +111,7 @@ export interface IResourceRequest extends IResource {
|
||||||
export const Resource = (resource: IResource) => ({
|
export const Resource = (resource: IResource) => ({
|
||||||
...resource,
|
...resource,
|
||||||
name: getResourceName(resource.url),
|
name: getResourceName(resource.url),
|
||||||
isRed: !resource.success, //|| resource.score >= RED_BOUND,
|
isRed: !resource.success || resource.error, //|| resource.score >= RED_BOUND,
|
||||||
isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
|
isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export const usabilityTestingView = (id = ':testId', hash?: string | number): st
|
||||||
|
|
||||||
export const spotsList = (): string => '/spots';
|
export const spotsList = (): string => '/spots';
|
||||||
export const spot = (id = ':spotId', hash?: string | number): string => hashed(`/view-spot/${id}`, hash);
|
export const spot = (id = ':spotId', hash?: string | number): string => hashed(`/view-spot/${id}`, hash);
|
||||||
|
export const scopeSetup = (): string => '/scope-setup';
|
||||||
|
|
||||||
const REQUIRED_SITE_ID_ROUTES = [
|
const REQUIRED_SITE_ID_ROUTES = [
|
||||||
liveSession(''),
|
liveSession(''),
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ export default class LoginService extends BaseService {
|
||||||
'g-recaptcha-response': captchaResponse,
|
'g-recaptcha-response': captchaResponse,
|
||||||
})
|
})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.ok) {
|
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
throw e;
|
return e.response.json()
|
||||||
|
.then((r: { errors: string[] }) => {
|
||||||
|
throw r.errors;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +54,6 @@ export default class SpotService extends BaseService {
|
||||||
async fetchSpot(id: string, accessKey?: string): Promise<GetSpotResponse> {
|
async fetchSpot(id: string, accessKey?: string): Promise<GetSpotResponse> {
|
||||||
return this.client.get(`/spot/v1/spots/${id}${accessKey ? `?key=${accessKey}` : ''}`)
|
return this.client.get(`/spot/v1/spots/${id}${accessKey ? `?key=${accessKey}` : ''}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.catch(console.error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSpot(id: string, filter: UpdateSpotRequest) {
|
async updateSpot(id: string, filter: UpdateSpotRequest) {
|
||||||
|
|
@ -71,10 +70,9 @@ export default class SpotService extends BaseService {
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addComment(id: string, data: AddCommentRequest) {
|
async addComment(id: string, data: AddCommentRequest, accessKey?: string) {
|
||||||
return this.client.post(`/spot/v1/spots/${id}/comment`, data)
|
return this.client.post(`/spot/v1/spots/${id}/comment${accessKey ? `?key=${accessKey}` : ''}`, data)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.catch(console.error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVideo(id:string) {
|
async getVideo(id:string) {
|
||||||
|
|
@ -98,4 +96,10 @@ export default class SpotService extends BaseService {
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkProcessingStatus(id: string) {
|
||||||
|
return this.client.get(`/spot/v1/spots/${id}/status`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.catch(console.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,11 @@ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && window.e
|
||||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
|
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
|
||||||
|
|
||||||
const storageState = storage.state();
|
const storageState = storage.state();
|
||||||
const initialState = Map({ user: initUserState.update('jwt', () => storageState.user?.jwt || null) });
|
const initialState = Map({ user:
|
||||||
|
initUserState
|
||||||
|
.update('jwt', () => storageState.user?.jwt || null)
|
||||||
|
.update('spotJwt', () => storageState.user?.spotJwt || null)
|
||||||
|
});
|
||||||
|
|
||||||
const store = createStore(indexReducer, initialState, composeEnhancers(applyMiddleware(thunk, apiMiddleware)));
|
const store = createStore(indexReducer, initialState, composeEnhancers(applyMiddleware(thunk, apiMiddleware)));
|
||||||
store.subscribe(() => {
|
store.subscribe(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,19 @@ import chroma from 'chroma-js';
|
||||||
import * as htmlToImage from 'html-to-image';
|
import * as htmlToImage from 'html-to-image';
|
||||||
import { SESSION_FILTER } from 'App/constants/storageKeys';
|
import { SESSION_FILTER } from 'App/constants/storageKeys';
|
||||||
|
|
||||||
|
export const HOUR_SECS = 60 * 60;
|
||||||
|
export const DAY_SECS = 24 * HOUR_SECS;
|
||||||
|
export const WEEK_SECS = 7 * DAY_SECS;
|
||||||
|
|
||||||
|
export const formatExpirationTime = (seconds: number) => {
|
||||||
|
if (seconds >= WEEK_SECS) {
|
||||||
|
return `${Math.floor(seconds / DAY_SECS)} days`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours > 0 ? `${hours}h` : ''}${minutes > 0 ? `${minutes}m` : ''}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
export function debounce(callback, wait, context = this) {
|
export function debounce(callback, wait, context = this) {
|
||||||
let timeout = null;
|
let timeout = null;
|
||||||
let callbackArgs = null;
|
let callbackArgs = null;
|
||||||
|
|
@ -488,4 +501,3 @@ export function truncateStringToFit(string: string, screenWidth: number, charWid
|
||||||
|
|
||||||
return string.slice(0, frontLen) + ellipsis + string.slice(-backLen);
|
return string.slice(0, frontLen) + ellipsis + string.slice(-backLen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,5 @@ ${ Object.entries(flatColors).map(([name, value]) => `.hover-${ name.replace(/ /
|
||||||
${ Object.entries(flatColors).map(([name, value]) => `.border-${ name.replace(/ /g, '-') } { border-color: ${ value } }`).join('\n') }
|
${ Object.entries(flatColors).map(([name, value]) => `.border-${ name.replace(/ /g, '-') } { border-color: ${ value } }`).join('\n') }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Log the generated CSS to the console
|
|
||||||
console.log(generatedCSS);
|
|
||||||
|
|
||||||
// Write the generated CSS to a file
|
// Write the generated CSS to a file
|
||||||
fs.writeFileSync('app/styles/colors-autogen.css', generatedCSS);
|
fs.writeFileSync('app/styles/colors-autogen.css', generatedCSS);
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,18 @@ const { collectFilenames } = require('./fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const svgRE = /\.svg$/;
|
const svgRE = /\.svg$/;
|
||||||
const ICONS_DIRNAME = path.join(__dirname, '../app/svg/icons')
|
const ICONS_DIRNAME = path.join(__dirname, '../app/svg/icons');
|
||||||
const UI_DIRNAME = path.join(__dirname, '../app/components/ui')
|
const UI_DIRNAME = path.join(__dirname, '../app/components/ui');
|
||||||
const icons = collectFilenames(ICONS_DIRNAME, n => svgRE.test(n));
|
const icons = collectFilenames(ICONS_DIRNAME, (n) => svgRE.test(n));
|
||||||
|
|
||||||
const getDirectories = source =>
|
const getDirectories = (source) =>
|
||||||
fs.readdirSync(source, { withFileTypes: true })
|
fs
|
||||||
.filter(dirent => dirent.isDirectory())
|
.readdirSync(source, { withFileTypes: true })
|
||||||
.map(dirent => dirent.name)
|
.filter((dirent) => dirent.isDirectory())
|
||||||
|
.map((dirent) => dirent.name);
|
||||||
const titleCase = (string) => {
|
const titleCase = (string) => {
|
||||||
return string[0].toUpperCase() + string.slice(1).toLowerCase();
|
return string[0].toUpperCase() + string.slice(1).toLowerCase();
|
||||||
}
|
};
|
||||||
|
|
||||||
const plugins = (removeFill = true) => {
|
const plugins = (removeFill = true) => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -28,24 +29,38 @@ const plugins = (removeFill = true) => {
|
||||||
removeViewBox: false,
|
removeViewBox: false,
|
||||||
inlineStyles: {
|
inlineStyles: {
|
||||||
onlyMatchedOnce: false,
|
onlyMatchedOnce: false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'removeAttrs',
|
name: 'removeAttrs',
|
||||||
params: {
|
params: {
|
||||||
attrs: ['xml', 'class', 'style', 'data-name', 'dataName', 'svg:width', 'svg:height', 'fill-rule', 'clip-path']
|
attrs: [
|
||||||
}
|
'xml',
|
||||||
|
'class',
|
||||||
|
'style',
|
||||||
|
'data-name',
|
||||||
|
'dataName',
|
||||||
|
'svg:width',
|
||||||
|
'svg:height',
|
||||||
|
'fill-rule',
|
||||||
|
'clip-path',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'addAttributesToSVGElement',
|
name: 'addAttributesToSVGElement',
|
||||||
params: {
|
params: {
|
||||||
attributes: ['width={ `${ width }px` }', 'height={ `${ height }px` }', !removeFill ? 'fill={ `${ fill }` }' : '']
|
attributes: [
|
||||||
}
|
'width={ `${ width }px` }',
|
||||||
|
'height={ `${ height }px` }',
|
||||||
|
!removeFill ? 'fill={ `${ fill }` }' : '',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ name: 'removeXMLNS' }
|
{ name: 'removeXMLNS' },
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -54,19 +69,30 @@ const dirs = getDirectories(ICONS_DIRNAME);
|
||||||
|
|
||||||
fs.mkdirSync(`${UI_DIRNAME}/Icons`, { recursive: true });
|
fs.mkdirSync(`${UI_DIRNAME}/Icons`, { recursive: true });
|
||||||
dirs.forEach((dir) => {
|
dirs.forEach((dir) => {
|
||||||
fs.mkdirSync(`${UI_DIRNAME}/Icons/${dir.replaceAll('-', '_')}`, { recursive: true });
|
fs.mkdirSync(`${UI_DIRNAME}/Icons/${dir.replaceAll('-', '_')}`, {
|
||||||
})
|
recursive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
icons.forEach((icon) => {
|
||||||
const fileName = icon.slice(0, -4).replaceAll('-', '_').replaceAll('/', '_');
|
const fileName = icon.slice(0, -4).replaceAll('-', '_').replaceAll('/', '_');
|
||||||
const name = fileName
|
const name = fileName;
|
||||||
const path = `${UI_DIRNAME}/Icons/${name}.tsx`
|
const path = `${UI_DIRNAME}/Icons/${name}.tsx`;
|
||||||
iconPaths.push({ path: `./Icons/${name}`, name, oldName: icon.slice(0, -4), fileName });
|
iconPaths.push({
|
||||||
|
path: `./Icons/${name}`,
|
||||||
|
name,
|
||||||
|
oldName: icon.slice(0, -4),
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
const svg = fs.readFileSync(`${ICONS_DIRNAME}/${icon}`, 'utf-8');
|
const svg = fs.readFileSync(`${ICONS_DIRNAME}/${icon}`, 'utf-8');
|
||||||
const canOptimize = !icon.includes('integrations');
|
const canOptimize = !icon.includes('integrations');
|
||||||
const keepOriginal = icon.includes('color')
|
const keepOriginal = icon.includes('color');
|
||||||
const { data } = keepOriginal ? { data: svg } : optimize(svg, plugins(canOptimize));
|
const { data } = keepOriginal
|
||||||
fs.writeFileSync(path, `
|
? { data: svg }
|
||||||
|
: optimize(svg, plugins(canOptimize));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path,
|
||||||
|
`
|
||||||
/* Auto-generated, do not edit */
|
/* Auto-generated, do not edit */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
|
@ -80,12 +106,16 @@ interface Props {
|
||||||
function ${titleCase(fileName)}(props: Props) {
|
function ${titleCase(fileName)}(props: Props) {
|
||||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||||
return (
|
return (
|
||||||
${data.replaceAll(/xlink\:href/g, 'xlinkHref')
|
${data
|
||||||
|
.replaceAll(/xlink\:href/g, 'xlinkHref')
|
||||||
.replaceAll(/xmlns\:xlink/g, 'xmlnsXlink')
|
.replaceAll(/xmlns\:xlink/g, 'xmlnsXlink')
|
||||||
.replaceAll(/clip\-path/g, 'clipPath')
|
.replaceAll(/clip\-path/g, 'clipPath')
|
||||||
.replaceAll(/clip\-rule/g, 'clipRule')
|
.replaceAll(/clip\-rule/g, 'clipRule')
|
||||||
// hack to keep fill rule for some icons like stop recording square
|
// hack to keep fill rule for some icons like stop recording square
|
||||||
.replaceAll(/clipRule="evenoddCustomFill"/g, 'clipRule="evenodd" fillRule="evenodd"')
|
.replaceAll(
|
||||||
|
/clipRule="evenoddCustomFill"/g,
|
||||||
|
'clipRule="evenodd" fillRule="evenodd"'
|
||||||
|
)
|
||||||
.replaceAll(/fill-rule/g, 'fillRule')
|
.replaceAll(/fill-rule/g, 'fillRule')
|
||||||
.replaceAll(/fill-opacity/g, 'fillOpacity')
|
.replaceAll(/fill-opacity/g, 'fillOpacity')
|
||||||
.replaceAll(/stop-color/g, 'stopColor')
|
.replaceAll(/stop-color/g, 'stopColor')
|
||||||
|
|
@ -94,28 +124,47 @@ function ${titleCase(fileName)}(props: Props) {
|
||||||
.replaceAll(/stroke-linejoin/g, 'strokeLinejoin')
|
.replaceAll(/stroke-linejoin/g, 'strokeLinejoin')
|
||||||
.replaceAll(/stroke-miterlimit/g, 'strokeMiterlimit')
|
.replaceAll(/stroke-miterlimit/g, 'strokeMiterlimit')
|
||||||
.replaceAll(/xml:space="preserve"/g, '')
|
.replaceAll(/xml:space="preserve"/g, '')
|
||||||
}
|
.replaceAll(/flood-opacity/g, 'floodOpacity')
|
||||||
|
.replaceAll(
|
||||||
|
/color-interpolation-filters/g,
|
||||||
|
'colorInterpolationFilters'
|
||||||
|
)}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ${titleCase(fileName)};
|
export default ${titleCase(fileName)};
|
||||||
`)
|
`
|
||||||
})
|
);
|
||||||
|
});
|
||||||
|
|
||||||
fs.writeFileSync(`${UI_DIRNAME}/Icons/index.ts`, `
|
fs.writeFileSync(
|
||||||
|
`${UI_DIRNAME}/Icons/index.ts`,
|
||||||
|
`
|
||||||
/* Auto-generated, do not edit */
|
/* Auto-generated, do not edit */
|
||||||
${iconPaths.map((icon) => `export { default as ${titleCase(icon.fileName)} } from './${icon.fileName}';`).join('\n')}
|
${iconPaths
|
||||||
`);
|
.map(
|
||||||
|
(icon) =>
|
||||||
|
`export { default as ${titleCase(icon.fileName)} } from './${
|
||||||
|
icon.fileName
|
||||||
|
}';`
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
// MAIN FILE
|
// MAIN FILE
|
||||||
fs.writeFileSync(`${UI_DIRNAME}/SVG.tsx`, `
|
fs.writeFileSync(
|
||||||
|
`${UI_DIRNAME}/SVG.tsx`,
|
||||||
|
`
|
||||||
/* Auto-generated, do not edit */
|
/* Auto-generated, do not edit */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
${iconPaths.map(icon => ` ${titleCase(icon.fileName)}`).join(',\n')}
|
${iconPaths.map((icon) => ` ${titleCase(icon.fileName)}`).join(',\n')}
|
||||||
} from './Icons'
|
} from './Icons'
|
||||||
|
|
||||||
export type IconNames = ${icons.map((icon, i) => `'${icon.slice(0, -4)}'`).join(' | ')};
|
export type IconNames = ${icons
|
||||||
|
.map((icon, i) => `'${icon.slice(0, -4)}'`)
|
||||||
|
.join(' | ')};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: IconNames;
|
name: IconNames;
|
||||||
|
|
@ -129,16 +178,21 @@ interface Props {
|
||||||
const SVG = (props: Props) => {
|
const SVG = (props: Props) => {
|
||||||
const { name, size = 14, width = size, height = size, fill = '' } = props;
|
const { name, size = 14, width = size, height = size, fill = '' } = props;
|
||||||
switch (name) {
|
switch (name) {
|
||||||
${iconPaths.map(icon => {
|
${iconPaths
|
||||||
return `
|
.map((icon) => {
|
||||||
|
return `
|
||||||
${icon.oldName !== icon.name ? `// case '${icon.oldName}':` : ''}
|
${icon.oldName !== icon.name ? `// case '${icon.oldName}':` : ''}
|
||||||
case '${icon.oldName}': return <${titleCase(icon.fileName)} width={ width } height={ height } fill={ fill } />;
|
case '${icon.oldName}': return <${titleCase(
|
||||||
`}
|
icon.fileName
|
||||||
).join('')}
|
)} width={ width } height={ height } fill={ fill } />;
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join('')}
|
||||||
default:
|
default:
|
||||||
console.trace('Unknown icon name ' + name);
|
console.trace('Unknown icon name ' + name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SVG.displayName = 'SVG';
|
SVG.displayName = 'SVG';
|
||||||
export default SVG;
|
export default SVG;
|
||||||
`);
|
`
|
||||||
|
);
|
||||||
|
|
|
||||||
28
spot/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.output
|
||||||
|
stats.html
|
||||||
|
stats-*.json
|
||||||
|
.wxt
|
||||||
|
web-ext.config.ts
|
||||||
|
|
||||||
|
!public
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1
spot/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v20.16.0
|
||||||
1
spot/.prettierrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
2
spot/README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# spot
|
||||||
|
Report bugs in no time. Simply record bugs you spot directly from your browser and instantly generate comprehensive bug reports with all the information engineers need to fix them. No more back-and-forth.
|
||||||
10
spot/assets/Setting.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Setting" clip-path="url(#clip0_314_2018)">
|
||||||
|
<path id="Vector" d="M17.2928 11.2871L15.9769 10.1621C16.0392 9.78041 16.0713 9.39068 16.0713 9.00094C16.0713 8.61121 16.0392 8.22148 15.9769 7.83978L17.2928 6.71478C17.392 6.62982 17.463 6.51665 17.4964 6.39034C17.5298 6.26402 17.5239 6.13054 17.4796 6.00764L17.4615 5.95541C17.0993 4.94296 16.5568 4.00441 15.8604 3.18509L15.8242 3.14291C15.7398 3.04357 15.6272 2.97216 15.5013 2.93809C15.3754 2.90402 15.2422 2.90889 15.1191 2.95206L13.4858 3.53264C12.8832 3.03844 12.2102 2.64871 11.4829 2.3755L11.1675 0.667908C11.1437 0.539421 11.0814 0.421216 10.9888 0.328997C10.8963 0.236778 10.7778 0.17491 10.6492 0.151613L10.595 0.141568C9.54834 -0.0472709 8.44745 -0.0472709 7.4008 0.141568L7.34656 0.151613C7.21798 0.17491 7.09953 0.236778 7.00696 0.328997C6.91438 0.421216 6.85205 0.539421 6.82825 0.667908L6.51084 2.38353C5.78941 2.6568 5.11759 3.04633 4.52201 3.53666L2.87669 2.95206C2.75368 2.90855 2.62033 2.90351 2.49438 2.93759C2.36843 2.97168 2.25584 3.04329 2.17156 3.14291L2.1354 3.18509C1.4398 4.00499 0.897447 4.94338 0.534281 5.95541L0.516201 6.00764C0.425799 6.25876 0.50013 6.54001 0.703031 6.71478L2.03495 7.85184C1.97267 8.22952 1.94254 8.61523 1.94254 8.99893C1.94254 9.38465 1.97267 9.77036 2.03495 10.146L0.703031 11.2831C0.603782 11.3681 0.532747 11.4812 0.499374 11.6075C0.466001 11.7338 0.47187 11.8673 0.516201 11.9902L0.534281 12.0425C0.897897 13.055 1.43629 13.9891 2.1354 14.8128L2.17156 14.855C2.25604 14.9543 2.36864 15.0257 2.49452 15.0598C2.62039 15.0938 2.75364 15.089 2.87669 15.0458L4.52201 14.4612C5.12067 14.9534 5.78964 15.3431 6.51084 15.6143L6.82825 17.33C6.85205 17.4584 6.91438 17.5767 7.00696 17.6689C7.09953 17.7611 7.21798 17.823 7.34656 17.8463L7.4008 17.8563C8.45707 18.0462 9.53873 18.0462 10.595 17.8563L10.6492 17.8463C10.7778 17.823 10.8963 17.7611 10.9888 17.6689C11.0814 17.5767 11.1437 17.4584 11.1675 17.33L11.4829 15.6224C12.2099 15.3499 12.8867 14.9589 13.4858 14.4652L15.1191 15.0458C15.2421 15.0893 15.3755 15.0944 15.5014 15.0603C15.6274 15.0262 15.74 14.9546 15.8242 14.855L15.8604 14.8128C16.5595 13.9871 17.0979 13.055 17.4615 12.0425L17.4796 11.9902C17.57 11.7431 17.4957 11.4619 17.2928 11.2871ZM14.5506 8.07684C14.6008 8.38018 14.6269 8.69157 14.6269 9.00295C14.6269 9.31434 14.6008 9.62572 14.5506 9.92907L14.418 10.7346L15.9187 12.0184C15.6912 12.5425 15.404 13.0386 15.0629 13.4969L13.1986 12.836L12.5678 13.3543C12.0876 13.748 11.5533 14.0574 10.9747 14.2744L10.2093 14.5617L9.84968 16.5103C9.2823 16.5746 8.70947 16.5746 8.14209 16.5103L7.7825 14.5576L7.02312 14.2663C6.45058 14.0494 5.91821 13.74 5.44209 13.3483L4.81129 12.828L2.93495 13.4949C2.59343 13.0349 2.30817 12.5387 2.07915 12.0163L3.59589 10.7206L3.46531 9.91702C3.41709 9.61768 3.39098 9.30831 3.39098 9.00295C3.39098 8.69559 3.41509 8.38822 3.46531 8.08889L3.59589 7.28532L2.07915 5.98956C2.30616 5.46523 2.59343 4.97103 2.93495 4.51099L4.81129 5.17795L5.44209 4.65764C5.91821 4.2659 6.45058 3.95652 7.02312 3.73956L7.7845 3.45228L8.1441 1.4996C8.70861 1.43532 9.28517 1.43532 9.85169 1.4996L10.2113 3.44826L10.9767 3.73554C11.5533 3.95251 12.0896 4.26188 12.5698 4.65563L13.2006 5.17393L15.0649 4.513C15.4064 4.97304 15.6916 5.46925 15.9207 5.99157L14.42 7.27527L14.5506 8.07684ZM8.99991 5.26635C7.04723 5.26635 5.46419 6.84938 5.46419 8.80206C5.46419 10.7547 7.04723 12.3378 8.99991 12.3378C10.9526 12.3378 12.5356 10.7547 12.5356 8.80206C12.5356 6.84938 10.9526 5.26635 8.99991 5.26635ZM10.591 10.3931C10.3823 10.6024 10.1343 10.7684 9.86123 10.8815C9.58818 10.9945 9.29545 11.0525 8.99991 11.0521C8.39924 11.0521 7.83473 10.817 7.40883 10.3931C7.19955 10.1844 7.03359 9.93644 6.92051 9.66339C6.80743 9.39033 6.74945 9.09761 6.74991 8.80206C6.74991 8.20139 6.98495 7.63688 7.40883 7.21099C7.83473 6.78509 8.39924 6.55206 8.99991 6.55206C9.60058 6.55206 10.1651 6.78509 10.591 7.21099C10.8003 7.41967 10.9662 7.66768 11.0793 7.94073C11.1924 8.21379 11.2504 8.50651 11.2499 8.80206C11.2499 9.40273 11.0149 9.96724 10.591 10.3931Z" fill="black" fill-opacity="0.85"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_314_2018">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
1
spot/assets/arrow-left.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
12
spot/assets/circle-help.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="circle-help" clip-path="url(#clip0_314_2013)">
|
||||||
|
<path id="Vector" d="M9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9C1.5 13.1421 4.85786 16.5 9 16.5Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M6.81738 6.75C6.99371 6.24875 7.34175 5.82608 7.79985 5.55685C8.25795 5.28762 8.79655 5.1892 9.32027 5.27903C9.84398 5.36886 10.319 5.64114 10.6612 6.04765C11.0034 6.45415 11.1907 6.96864 11.1899 7.5C11.1899 9 8.93988 9.75 8.93988 9.75" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M9 12.75H9.0075" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_314_2013">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 970 B |
10
spot/assets/desktop.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="wrapper" clip-path="url(#clip0_314_2037)">
|
||||||
|
<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M2.53879 2.82223C2.61693 2.74409 2.72291 2.7002 2.83341 2.7002H8.08341C8.49763 2.7002 8.83341 2.36441 8.83341 1.9502C8.83341 1.53598 8.49763 1.2002 8.08341 1.2002H2.83341C2.32508 1.2002 1.83757 1.40213 1.47813 1.76157C1.11868 2.12102 0.916748 2.60853 0.916748 3.11686V8.9502C0.916748 9.45853 1.11868 9.94604 1.47813 10.3055C1.83757 10.6649 2.32508 10.8669 2.83341 10.8669H6.75008V11.7002H5.16675C4.75253 11.7002 4.41675 12.036 4.41675 12.4502C4.41675 12.8644 4.75253 13.2002 5.16675 13.2002H7.50008H9.83341C10.2476 13.2002 10.5834 12.8644 10.5834 12.4502C10.5834 12.036 10.2476 11.7002 9.83341 11.7002H8.25008V10.8669H12.1667C12.6751 10.8669 13.1626 10.6649 13.522 10.3055C13.8815 9.94604 14.0834 9.45853 14.0834 8.9502V7.2002C14.0834 6.78598 13.7476 6.4502 13.3334 6.4502C12.9192 6.4502 12.5834 6.78598 12.5834 7.2002V8.9502C12.5834 9.0607 12.5395 9.16668 12.4614 9.24482C12.3832 9.32296 12.2773 9.36686 12.1667 9.36686H7.50008H2.83341C2.72291 9.36686 2.61693 9.32296 2.53879 9.24482C2.46065 9.16668 2.41675 9.0607 2.41675 8.9502V3.11686C2.41675 3.00636 2.46065 2.90037 2.53879 2.82223ZM10.5834 3.7002C10.5834 3.14791 11.0311 2.7002 11.5834 2.7002C12.1357 2.7002 12.5834 3.14791 12.5834 3.7002C12.5834 4.25248 12.1357 4.7002 11.5834 4.7002C11.0311 4.7002 10.5834 4.25248 10.5834 3.7002ZM11.5834 1.2002C10.2027 1.2002 9.08341 2.31948 9.08341 3.7002C9.08341 5.08091 10.2027 6.2002 11.5834 6.2002C12.9641 6.2002 14.0834 5.08091 14.0834 3.7002C14.0834 2.31948 12.9641 1.2002 11.5834 1.2002Z" fill="black" fill-opacity="0.85"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_314_2037">
|
||||||
|
<rect width="14" height="14" fill="white" transform="translate(0.5 0.200073)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
3
spot/assets/main.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
15
spot/assets/mic-off-red.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="mic-off" clip-path="url(#clip0_314_2049)">
|
||||||
|
<path id="Vector" d="M1.33325 1.93329L14.6666 15.2666" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M12.5933 9.42C12.6414 9.1493 12.6659 8.87494 12.6666 8.6V7.26666" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M3.33326 7.26666V8.6C3.31969 9.53277 3.586 10.4482 4.09781 11.2282C4.60962 12.0081 5.34344 12.6167 6.20456 12.9755C7.06568 13.3343 8.01456 13.4268 8.92876 13.241C9.84295 13.0553 10.6805 12.5998 11.3333 11.9333" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_4" d="M10 6.82666V3.93332C9.99734 3.48424 9.84357 3.04913 9.56349 2.69807C9.28341 2.34701 8.89333 2.10044 8.45606 1.99805C8.01879 1.89566 7.55979 1.94342 7.15297 2.13364C6.74615 2.32385 6.41518 2.64546 6.21338 3.04666" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_5" d="M6 6.59998V8.59998C6.00035 8.99528 6.11783 9.38162 6.33762 9.7102C6.55741 10.0388 6.86964 10.2948 7.23487 10.4461C7.60011 10.5973 8.00197 10.6369 8.3897 10.5599C8.77743 10.4829 9.13364 10.2927 9.41333 10.0133" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_6" d="M8 13.2667V15.2667" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_314_2049">
|
||||||
|
<rect width="16" height="16" fill="#CC0000" transform="translate(0 0.599976)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
15
spot/assets/mic-off.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="mic-off" clip-path="url(#clip0_314_2049)">
|
||||||
|
<path id="Vector" d="M1.33325 1.93329L14.6666 15.2666" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M12.5933 9.42C12.6414 9.1493 12.6659 8.87494 12.6666 8.6V7.26666" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M3.33326 7.26666V8.6C3.31969 9.53277 3.586 10.4482 4.09781 11.2282C4.60962 12.0081 5.34344 12.6167 6.20456 12.9755C7.06568 13.3343 8.01456 13.4268 8.92876 13.241C9.84295 13.0553 10.6805 12.5998 11.3333 11.9333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_4" d="M10 6.82666V3.93332C9.99734 3.48424 9.84357 3.04913 9.56349 2.69807C9.28341 2.34701 8.89333 2.10044 8.45606 1.99805C8.01879 1.89566 7.55979 1.94342 7.15297 2.13364C6.74615 2.32385 6.41518 2.64546 6.21338 3.04666" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_5" d="M6 6.59998V8.59998C6.00035 8.99528 6.11783 9.38162 6.33762 9.7102C6.55741 10.0388 6.86964 10.2948 7.23487 10.4461C7.60011 10.5973 8.00197 10.6369 8.3897 10.5599C8.77743 10.4829 9.13364 10.2927 9.41333 10.0133" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_6" d="M8 13.2667V15.2667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_314_2049">
|
||||||
|
<rect width="16" height="16" fill="currentColor" transform="translate(0 0.599976)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
14
spot/assets/mic-on-animated-dark.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="5" y1="10" x2="5" y2="10" stroke="#000" stroke-width="2" stroke-linecap="round">
|
||||||
|
<animate attributeName="y1" values="10;5;10" dur="1s" begin="0s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="y2" values="10;15;10" dur="1s" begin="0s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
<line x1="10" y1="8" x2="10" y2="8" stroke="#000" stroke-width="2" stroke-linecap="round">
|
||||||
|
<animate attributeName="y1" values="8;3;8" dur="1s" begin="0.2s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="y2" values="8;17;8" dur="1s" begin="0.2s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
<line x1="15" y1="10" x2="15" y2="10" stroke="#000" stroke-width="2" stroke-linecap="round">
|
||||||
|
<animate attributeName="y1" values="10;5;10" dur="1s" begin="0.4s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="y2" values="10;15;10" dur="1s" begin="0.4s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 985 B |
14
spot/assets/mic-on-animated.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="5" y1="10" x2="5" y2="10" stroke="#FFF" stroke-width="2" stroke-linecap="round">
|
||||||
|
<animate attributeName="y1" values="10;5;10" dur="1s" begin="0s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="y2" values="10;15;10" dur="1s" begin="0s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
<line x1="10" y1="8" x2="10" y2="8" stroke="#FFF" stroke-width="2" stroke-linecap="round">
|
||||||
|
<animate attributeName="y1" values="8;3;8" dur="1s" begin="0.2s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="y2" values="8;17;8" dur="1s" begin="0.2s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
<line x1="15" y1="10" x2="15" y2="10" stroke="#FFF" stroke-width="2" stroke-linecap="round">
|
||||||
|
<animate attributeName="y1" values="10;5;10" dur="1s" begin="0.4s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="y2" values="10;15;10" dur="1s" begin="0.4s" repeatCount="indefinite"/>
|
||||||
|
</line>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 985 B |
1
spot/assets/mic-on-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mic"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
1
spot/assets/mic-on-red.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mic"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
1
spot/assets/mic-on.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mic"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
22
spot/assets/orSpot.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" 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.6576 11.9819L4.37651 3.01376V20.9501L19.6576 11.9819ZM21.7429 10.1281C22.3999 10.5087 22.8053 11.216 22.8053 11.9819C22.8053 12.7479 22.3999 13.4552 21.7429 13.8358L4.98997 23.6696C3.62088 24.474 1.74365 23.555 1.74365 21.8158V2.14811C1.74365 0.408813 3.62088 -0.510111 4.98997 0.294281L21.7429 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_314_2001)">
|
||||||
|
<ellipse cx="13.9648" cy="5.62884" rx="3.1579" ry="3.15789" fill="#CC0000"/>
|
||||||
|
<ellipse cx="13.9648" cy="5.62884" rx="3.1579" ry="3.15789" stroke="white" stroke-width="1.50147"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_d_314_2001" x="10.0562" y="1.72021" width="7.81714" 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_314_2001"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_314_2001" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
6
spot/assets/recording-animated.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="4" fill="#fff">
|
||||||
|
<animate attributeName="r" values="4;6;4" dur="1s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="opacity" values="1;0.5;1" dur="1s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 320 B |
1
spot/assets/solid.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><defs><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient></defs><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
spot/assets/tab.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-app-window"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/></svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
17
spot/entrypoints/audio/audio.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
async function requestMicrophoneAccess() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
void chrome.runtime.sendMessage({ type: "audio:audio-perm" });
|
||||||
|
void chrome.storage.local.set({ audioPerm: true });
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
window.close();
|
||||||
|
} catch (error) {
|
||||||
|
alert(
|
||||||
|
"Permission denied or device not found. The extension may not work as expected.",
|
||||||
|
);
|
||||||
|
console.error("Error requesting audio permission:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
void requestMicrophoneAccess();
|
||||||
|
});
|
||||||
38
spot/entrypoints/audio/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Spot: Permissions Required</title>
|
||||||
|
<meta name="manifest.type" content="browser_action" />
|
||||||
|
<link href="../../assets/main.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="w-full h-screen flex flex-col justify-center items-center bg-indigo-50 p-10 gap-5">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<h1 class="font-bold text-4xl flex flex-col gap-2 items-start">
|
||||||
|
<svg width="147" height="235" viewBox="0 0 147 235" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_252_6)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.6154 242.106C60.9374 241.63 60.7736 240.695 61.2496 240.017C84.2097 207.31 93.1585 179.022 93.1644 157.243C91.1053 157.811 88.9982 158.244 86.8675 158.548C74.8237 160.263 61.8217 157.88 51.9901 152.227C46.5699 149.11 42.2962 145.089 39.8486 140.189C37.3862 135.259 36.8352 129.564 38.6646 123.263C39.8356 119.229 42.4726 114.801 45.9544 111.486C49.4273 108.179 53.9409 105.803 58.8443 106.348C77.5997 108.432 92.3123 123.361 95.5128 147.179C95.7726 149.112 95.9569 151.104 96.0619 153.153C103.47 150.366 109.787 145.546 113.701 138.457C131.472 106.263 118.66 68.2851 96.5045 40.9767C95.4826 39.7172 92.2609 36.5851 88.1349 32.9304C84.0422 29.3051 79.171 25.2635 74.9192 22.1993C72.7895 20.6644 70.8498 19.4 69.2609 18.5562C68.4654 18.1337 67.7926 17.8355 67.2515 17.6584C66.9773 17.5688 66.7688 17.521 66.6169 17.4968C66.6273 17.6196 66.6508 17.7842 66.6988 17.9989C66.8946 18.8738 67.3985 20.1616 68.3199 21.9506C72.9248 27.0533 75.344 31.2917 78.7303 38.0633C78.8862 38.3751 79.0113 38.6397 79.0992 38.8504C79.1422 38.9533 79.1881 39.0713 79.2242 39.1907C79.2422 39.2501 79.2653 39.3342 79.2823 39.4321C79.2956 39.5085 79.3257 39.7015 79.2921 39.9395C79.262 40.1535 79.1168 40.7798 78.4438 41.1019C77.8382 41.3918 77.3078 41.1799 77.1752 41.1223C76.8824 40.9951 76.6831 40.7952 76.6376 40.7496L76.6326 40.7445C76.4834 40.5964 76.3289 40.4033 76.1967 40.2307C75.6414 39.5054 74.6708 38.0609 73.5766 36.3775C71.3696 32.9821 68.5167 28.3926 67.1466 26.036C66.6585 25.1965 66.222 24.4109 65.8364 23.6775C65.0574 22.8244 64.2127 21.9436 63.2856 21.0165C63.1213 20.8522 62.8449 20.6112 62.4684 20.283C62.1936 20.0434 61.8656 19.7574 61.4889 19.4208C60.6615 18.6815 59.6837 17.7678 58.902 16.8529C58.5113 16.3956 58.1387 15.9024 57.8555 15.4005C57.5849 14.9209 57.3191 14.3005 57.3098 13.6191C57.299 12.8358 57.633 12.0988 58.3195 11.5916C58.918 11.1494 59.6777 10.96 60.4413 10.8837C65.0344 10.4244 69.6023 9.81198 74.1995 9.19567C74.8834 9.10398 75.568 9.01221 76.2534 8.92084C81.5342 8.21686 86.8549 7.53825 92.2281 7.12495C93.0541 7.06142 93.7752 7.6795 93.8387 8.5055C93.9022 9.33148 93.2841 10.0526 92.4581 10.1161C87.1759 10.5224 81.9277 11.1909 76.6498 11.8945C75.9676 11.9855 75.2848 12.077 74.6013 12.1687C70.0085 12.7845 65.3866 13.4041 60.7398 13.8688C60.6293 13.8798 60.5358 13.8931 60.4575 13.907C60.461 13.9133 60.4646 13.9198 60.4683 13.9264C60.6211 14.1971 60.8605 14.5268 61.183 14.9042C61.8276 15.6587 62.6805 16.4624 63.4878 17.1838C63.5282 17.2199 63.5688 17.2561 63.6096 17.2923C63.6139 16.8818 63.6765 16.4777 63.826 16.0984C64.2011 15.1469 64.9998 14.6385 65.8758 14.5103C66.6393 14.3986 67.4508 14.5672 68.1842 14.8071C68.9482 15.057 69.7877 15.4392 70.668 15.9066C72.4304 16.8426 74.4956 18.1961 76.6733 19.7655C81.0363 22.9099 85.9899 27.0226 90.1241 30.6847C94.2252 34.3173 97.6326 37.6055 98.8342 39.0866C121.329 66.813 134.961 106.149 116.327 139.906C111.83 148.053 104.517 153.38 96.1598 156.307C96.415 179.029 87.2069 208.262 63.705 241.74C63.229 242.418 62.2935 242.582 61.6154 242.106ZM93.1036 154.142C93.0132 151.877 92.8232 149.689 92.5396 147.578C89.4862 124.856 75.646 111.233 58.513 109.329C54.8176 108.919 51.1396 110.691 48.023 113.659C44.9153 116.617 42.5649 120.588 41.5456 124.099C39.9099 129.733 40.4332 134.646 42.5324 138.848C44.6464 143.08 48.4215 146.714 53.4855 149.626C62.6993 154.924 75.0195 157.205 86.4445 155.577C88.7175 155.254 90.9469 154.777 93.1036 154.142Z" fill="#1C1C1C"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_252_6">
|
||||||
|
<rect width="147" height="235" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span class="flex flex-col items-center">
|
||||||
|
<span class=" rounded-full w-12 h-12 shadow-sm mb-2 bg-white flex items-center p-2"><svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mic"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg></span>
|
||||||
|
Allow microphone access
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl">Microphone access is required to include voice memos to Spot recordings.</p>
|
||||||
|
<!-- <button id="request-permission" class="btn btn-lg btn-primary rounded-lg text-white text-xl">Grant Permission</button> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./audio.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1372
spot/entrypoints/background.ts
Normal file
99
spot/entrypoints/content/ControlsBox.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import Countdown from "@/entrypoints/content/Countdown";
|
||||||
|
import "~/assets/main.css";
|
||||||
|
import "./style.css";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import RecordingControls from "./RecordingControls";
|
||||||
|
import SavingControls from "./SavingControls";
|
||||||
|
import { STATES } from "./utils";
|
||||||
|
|
||||||
|
interface IControlsBox {
|
||||||
|
stop: () => Promise<any>;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
getVideoData: () => Promise<any>;
|
||||||
|
getMicStatus: () => Promise<any>;
|
||||||
|
callRecording: () => Promise<boolean>;
|
||||||
|
getClockStart: () => number;
|
||||||
|
onClose: (
|
||||||
|
save: boolean,
|
||||||
|
spotObj?: {
|
||||||
|
blob?: Blob;
|
||||||
|
name?: string;
|
||||||
|
comment?: string;
|
||||||
|
useHook?: boolean;
|
||||||
|
thumbnail?: string;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
muteMic: () => void;
|
||||||
|
unmuteMic: () => void;
|
||||||
|
getInitState: () => string;
|
||||||
|
onRestart: () => void;
|
||||||
|
getErrorEvents: () => Promise<any>;
|
||||||
|
getAudioPerm: () => boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlsBox({
|
||||||
|
stop,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
getVideoData,
|
||||||
|
getMicStatus,
|
||||||
|
onClose,
|
||||||
|
getClockStart,
|
||||||
|
muteMic,
|
||||||
|
unmuteMic,
|
||||||
|
getInitState,
|
||||||
|
callRecording,
|
||||||
|
onRestart,
|
||||||
|
getErrorEvents,
|
||||||
|
getAudioPerm,
|
||||||
|
}: IControlsBox) {
|
||||||
|
const initialState =
|
||||||
|
getInitState() === "recording" ? STATES.recording : STATES.count;
|
||||||
|
const [boxState, setBoxState] =
|
||||||
|
createSignal<keyof typeof STATES>(initialState);
|
||||||
|
const changeState = (newState: keyof typeof STATES) => {
|
||||||
|
setBoxState(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimerEnd = async (proceed?: boolean) => {
|
||||||
|
if (!proceed) {
|
||||||
|
onClose(false);
|
||||||
|
return changeState(STATES.idle)
|
||||||
|
}
|
||||||
|
await callRecording();
|
||||||
|
let int = setInterval(() => {
|
||||||
|
const state = getInitState();
|
||||||
|
if (state !== "count") {
|
||||||
|
clearInterval(int);
|
||||||
|
changeState(STATES.recording);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={"controls"}>
|
||||||
|
{boxState() === STATES.saving ? (
|
||||||
|
<SavingControls getErrorEvents={getErrorEvents} getVideoData={getVideoData} onClose={onClose} />
|
||||||
|
) : null}
|
||||||
|
{boxState() === STATES.count ? <Countdown getAudioPerm={getAudioPerm} onEnd={onTimerEnd} /> : null}
|
||||||
|
{boxState() === STATES.recording ? (
|
||||||
|
<RecordingControls
|
||||||
|
getAudioPerm={getAudioPerm}
|
||||||
|
getMicStatus={getMicStatus}
|
||||||
|
changeState={changeState}
|
||||||
|
pause={pause}
|
||||||
|
resume={resume}
|
||||||
|
stop={stop}
|
||||||
|
getClockStart={getClockStart}
|
||||||
|
mute={muteMic}
|
||||||
|
unmute={unmuteMic}
|
||||||
|
getInitState={getInitState}
|
||||||
|
onRestart={onRestart}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlsBox;
|
||||||
121
spot/entrypoints/content/Countdown.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { createSignal, onCleanup, onMount } from "solid-js";
|
||||||
|
|
||||||
|
function Countdown(props: {
|
||||||
|
onEnd: (proceed?: boolean) => void;
|
||||||
|
getAudioPerm: () => number;
|
||||||
|
}) {
|
||||||
|
const [count, setCount] = createSignal(3);
|
||||||
|
|
||||||
|
let interval: any;
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
clearInterval(interval);
|
||||||
|
props.onEnd(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setCount((prev) => {
|
||||||
|
if (prev === 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
props.onEnd(true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
window.addEventListener("keydown", escHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener("keydown", escHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioPerm = props.getAudioPerm();
|
||||||
|
|
||||||
|
const audioPrompt = {
|
||||||
|
0: "Microphone permission isn't granted yet.",
|
||||||
|
1: "Microphone access is enabled. Unmute anytime to add voice over.",
|
||||||
|
2: "Microphone is enabled."
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="flex flex-col gap-2 items-center px-4 py-2 text-white mb-2">
|
||||||
|
<div class="text-3xl text-white font-bold rounded-full w-16 h-16 flex items-center justify-center relative z-20">
|
||||||
|
<span class="z-30">{count()}</span>
|
||||||
|
|
||||||
|
<div class="absolute top-0 left-0 z-10">
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="45"
|
||||||
|
fill="rgba(0, 0, 0, 0.3)"
|
||||||
|
stroke="rgba(255,255,255,.4)"
|
||||||
|
stroke-width="10"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="45"
|
||||||
|
fill="rgba(255,255,255,.3)"
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
stroke-width="10"
|
||||||
|
stroke-dasharray="283"
|
||||||
|
stroke-dashoffset="0"
|
||||||
|
>
|
||||||
|
{count() > 0 && (
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
values="0;283"
|
||||||
|
dur="1s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-between items-center gap-2 mt-2">
|
||||||
|
<span class="text-2xl font-medium">Get Ready to Record...</span>
|
||||||
|
<span class="text-base text-white/70 flex gap-2 items-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-mic"
|
||||||
|
>
|
||||||
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" x2="12" y1="19" y2="22" />
|
||||||
|
</svg>{" "}
|
||||||
|
{audioPrompt[audioPerm]}
|
||||||
|
</span>
|
||||||
|
<span class="text-base text-white/70 flex gap-2 items-center">
|
||||||
|
<span class="px-1 rounded-lg bg-white/30 text-inherit">ESC</span>{" "}
|
||||||
|
to cancel.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Countdown;
|
||||||
291
spot/entrypoints/content/RecordingControls.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { createSignal, onCleanup, createEffect } from "solid-js";
|
||||||
|
import { STATES, formatMsToTime } from "@/entrypoints/content/utils";
|
||||||
|
import micOn from "@/assets/mic-on.svg";
|
||||||
|
import { createDraggable } from "@neodrag/solid";
|
||||||
|
|
||||||
|
interface IRControls {
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
stop: () => Promise<any>;
|
||||||
|
changeState: (newState: keyof typeof STATES) => void;
|
||||||
|
getMicStatus: () => Promise<any>;
|
||||||
|
getClockStart: () => number;
|
||||||
|
mute: () => void;
|
||||||
|
unmute: () => void;
|
||||||
|
getInitState: () => string;
|
||||||
|
onRestart: () => void;
|
||||||
|
getAudioPerm: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordingControls({
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
changeState,
|
||||||
|
getMicStatus,
|
||||||
|
getClockStart,
|
||||||
|
mute,
|
||||||
|
unmute,
|
||||||
|
getInitState,
|
||||||
|
onRestart,
|
||||||
|
getAudioPerm,
|
||||||
|
}: IRControls) {
|
||||||
|
const { draggable } = createDraggable();
|
||||||
|
|
||||||
|
const initState = getInitState();
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
|
const [mic, setMic] = createSignal(false);
|
||||||
|
const [recording, setRecording] = createSignal(initState === "recording");
|
||||||
|
const [time, setTime] = createSignal(0);
|
||||||
|
const [timeStr, setTimeStr] = createSignal(formatMsToTime(0));
|
||||||
|
|
||||||
|
const onMsg = (e: any) => {
|
||||||
|
if (e.data.type === "content:trigger-stop") {
|
||||||
|
void onEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
createEffect(() => {
|
||||||
|
window.addEventListener("message", onMsg);
|
||||||
|
const startDelta = getClockStart();
|
||||||
|
setTime(startDelta);
|
||||||
|
setTimeStr(formatMsToTime(startDelta));
|
||||||
|
getMicStatus().then((status) => {
|
||||||
|
setMic(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const createTimer = () => {
|
||||||
|
return setInterval(() => {
|
||||||
|
const timeDelta = time() + 1000;
|
||||||
|
if (timeDelta > 3 * 60 * 1000) {
|
||||||
|
void onEnd();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setTime((time) => {
|
||||||
|
const newTime = time + 1000;
|
||||||
|
const newTimeStr = formatMsToTime(newTime);
|
||||||
|
setTimeStr(newTimeStr);
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
let timer: ReturnType<typeof setInterval> | null =
|
||||||
|
initState === "recording" ? createTimer() : null;
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
window.removeEventListener("message", onMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPause = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
timer = null;
|
||||||
|
pause();
|
||||||
|
setRecording(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRestartEv = () => {
|
||||||
|
onPause();
|
||||||
|
onRestart();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResume = () => {
|
||||||
|
timer = createTimer();
|
||||||
|
resume();
|
||||||
|
setRecording(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stop();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
changeState(STATES.saving);
|
||||||
|
}, 25);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMic = async () => {
|
||||||
|
if (mic()) {
|
||||||
|
mute();
|
||||||
|
} else {
|
||||||
|
unmute();
|
||||||
|
}
|
||||||
|
const status = await getMicStatus();
|
||||||
|
setMic(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
let handleRef: HTMLDivElement;
|
||||||
|
setTimeout(() => {
|
||||||
|
handleRef.classList.remove("popupanimated");
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
const audioPerm = getAudioPerm()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={"rec-controls popupanimated cursor-grab"}
|
||||||
|
use:draggable={{ bounds: "body" }}
|
||||||
|
ref={(el) => (handleRef = el)}
|
||||||
|
>
|
||||||
|
{!isLoading() ? (
|
||||||
|
<div
|
||||||
|
class={
|
||||||
|
"flex flex-row w-fit gap-2 items-center bg-black/70 px-4 py-2 rounded-full text-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{recording() ? (
|
||||||
|
<button
|
||||||
|
class={
|
||||||
|
"btn btn-sm btn-ghost btn-circle tooltip tooltip-top flex items-center bg-black/20 hover:bg-black/70"
|
||||||
|
}
|
||||||
|
data-tip="Pause Recording"
|
||||||
|
onClick={onPause}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-pause"
|
||||||
|
>
|
||||||
|
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||||
|
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
class={
|
||||||
|
"btn btn-sm btn-ghost btn-circle tooltip tooltip-top flex items-center bg-black/70 hover:bg-black"
|
||||||
|
}
|
||||||
|
data-tip="Resume Recording"
|
||||||
|
onClick={onResume}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-play"
|
||||||
|
>
|
||||||
|
<polygon points="6 3 20 12 6 21 6 3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="divider hidden"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={"timerarea text-base cursor-default p-1 rounded-xl bg-black"}
|
||||||
|
>
|
||||||
|
{timeStr()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={`btn btn-sm btn-circle btn-ghost tooltip tooltip-top flex items-center ${
|
||||||
|
mic() ? "bg-black/20" : "bg-black"
|
||||||
|
}`}
|
||||||
|
data-tip={audioPerm > 0 ? mic() ? "Switch Off Mic" : "Switch On Mic" : "Microphone disabled"}
|
||||||
|
onClick={audioPerm > 0 ? toggleMic : undefined}
|
||||||
|
>
|
||||||
|
{mic() ? (
|
||||||
|
<img src={micOn} />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-mic-off"
|
||||||
|
>
|
||||||
|
<line x1="2" x2="22" y1="2" y2="22" />
|
||||||
|
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
|
||||||
|
<path d="M5 10v2a7 7 0 0 0 12 5" />
|
||||||
|
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
|
||||||
|
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
|
||||||
|
<line x1="12" x2="12" y1="19" y2="22" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider hidden"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={
|
||||||
|
"btn btn-sm btn-ghost btn-circle tooltip tooltip-top flex items-center bg-red-600 hover:bg-red-700 "
|
||||||
|
}
|
||||||
|
data-tip="End Recording"
|
||||||
|
onClick={onEnd}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-square"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={
|
||||||
|
"btn btn-sm btn-ghost btn-circle tooltip tooltip-top flex items-center bg-black/20 hover:bg-black/70"
|
||||||
|
}
|
||||||
|
data-tip="Restart Recording"
|
||||||
|
onClick={onRestartEv}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-refresh-cw"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<polyline points="1 20 1 14 7 14" />
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10" />
|
||||||
|
<path d="M20.49 15a9 9 0 0 1-14.13 3.36L1 14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class={"container"}>Loading video... </div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecordingControls;
|
||||||
504
spot/entrypoints/content/SavingControls.tsx
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
// noinspection SpellCheckingInspection
|
||||||
|
|
||||||
|
import { createSignal, onCleanup, createEffect } from "solid-js";
|
||||||
|
import { formatMsToTime } from "@/entrypoints/content/utils";
|
||||||
|
import "./style.css";
|
||||||
|
import "./dragControls.css";
|
||||||
|
|
||||||
|
interface ISavingControls {
|
||||||
|
onClose: (
|
||||||
|
save: boolean,
|
||||||
|
obj?: {
|
||||||
|
blob?: Blob;
|
||||||
|
name?: string;
|
||||||
|
comment?: string;
|
||||||
|
useHook?: boolean;
|
||||||
|
thumbnail?: string;
|
||||||
|
crop?: [number, number];
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
getVideoData: () => Promise<any>;
|
||||||
|
getErrorEvents: () => Promise<{title:string,time:number}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64ToBlob = (base64: string) => {
|
||||||
|
const splitStr = base64.split(",");
|
||||||
|
const len = splitStr.length;
|
||||||
|
const byteString = atob(splitStr[len - 1]);
|
||||||
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
|
const ia = new Uint8Array(ab);
|
||||||
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
|
ia[i] = byteString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new Blob([ab], { type: "video/webm" });
|
||||||
|
};
|
||||||
|
|
||||||
|
function SavingControls({ onClose, getVideoData, getErrorEvents }: ISavingControls) {
|
||||||
|
const [name, setName] = createSignal(`Issues in — ${document.title}`);
|
||||||
|
const [description, setDescription] = createSignal("");
|
||||||
|
const [currentTime, setCurrentTime] = createSignal(0);
|
||||||
|
const [duration, setDuration] = createSignal(0);
|
||||||
|
const [playing, setPlaying] = createSignal(false);
|
||||||
|
const [trimBounds, setTrimBounds] = createSignal([0, 0]);
|
||||||
|
const [videoData, setVideoData] = createSignal<string | undefined>(undefined);
|
||||||
|
const [videoBlob, setVideoBlob] = createSignal<Blob | undefined>(undefined);
|
||||||
|
const [processing, setProcessing] = createSignal(false);
|
||||||
|
const [startPos, setStartPos] = createSignal(0);
|
||||||
|
const [endPos, setEndPos] = createSignal(100);
|
||||||
|
const [dragging, setDragging] = createSignal<string | null>(null);
|
||||||
|
const [isTyping, setIsTyping] = createSignal(false);
|
||||||
|
const [errorEvents, setErrorEvents] = createSignal([])
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setTrimBounds([0, 0]);
|
||||||
|
getErrorEvents().then(r => {
|
||||||
|
setErrorEvents(r)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const spacePressed = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
isTyping()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === " ") {
|
||||||
|
if (playing()) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
window.addEventListener("keydown", spacePressed);
|
||||||
|
onCleanup(() => window.removeEventListener("keydown", spacePressed));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const convertToPercentage = (clientX: number, element: HTMLElement) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
return (x / rect.width) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag =
|
||||||
|
(marker: "start" | "end" | "body") => (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setDragging(marker);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrag = (event: MouseEvent) => {
|
||||||
|
if (dragging() && event.clientX !== 0) {
|
||||||
|
const newPos = convertToPercentage(
|
||||||
|
event.clientX,
|
||||||
|
event.currentTarget as HTMLElement,
|
||||||
|
);
|
||||||
|
if (dragging() === "start") {
|
||||||
|
if (endPos() - newPos <= 1 || newPos < 0 || newPos > 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStartPos(newPos);
|
||||||
|
} else if (dragging() === "end") {
|
||||||
|
if (newPos - startPos() <= 1 || newPos < 0 || newPos > 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndPos(newPos);
|
||||||
|
}
|
||||||
|
onTrimChange(
|
||||||
|
(startPos() / 100) * duration(),
|
||||||
|
(endPos() / 100) * duration(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
setDragging(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
setDragging(null);
|
||||||
|
});
|
||||||
|
if (videoData() === undefined) {
|
||||||
|
getVideoData().then(async (data: Record<string, any>) => {
|
||||||
|
const fullData = data.base64data.join("");
|
||||||
|
const blob = base64ToBlob(fullData);
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
setVideoBlob(blob);
|
||||||
|
setVideoData(blobUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoRef: HTMLVideoElement;
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
setProcessing(true);
|
||||||
|
const thumbnail = await generateThumbnail();
|
||||||
|
setProcessing(false);
|
||||||
|
const bounds = trimBounds();
|
||||||
|
const trim =
|
||||||
|
bounds[0] + bounds[1] === 0
|
||||||
|
? null
|
||||||
|
: (bounds.map((i: number) => Math.round(i * 1000)) as [number, number]);
|
||||||
|
const dataObj = {
|
||||||
|
blob: videoBlob(),
|
||||||
|
name: name(),
|
||||||
|
comment: description(),
|
||||||
|
useHook: false,
|
||||||
|
thumbnail,
|
||||||
|
crop: trim,
|
||||||
|
};
|
||||||
|
onClose(true, dataObj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
onClose(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
videoRef.pause();
|
||||||
|
setPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
void videoRef.play();
|
||||||
|
setPlaying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCurrentTime = () => {
|
||||||
|
setCurrentTime(videoRef.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateThumbnail = async (): Promise<string> => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return "";
|
||||||
|
let thumbnailRes = "";
|
||||||
|
const aspectRatio = videoRef.videoWidth / videoRef.videoHeight;
|
||||||
|
const width = 1080;
|
||||||
|
const height = width / aspectRatio;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
videoRef.currentTime = duration() ? duration() : 3;
|
||||||
|
context.drawImage(videoRef, 0, 0, canvas.width, canvas.height);
|
||||||
|
thumbnailRes = canvas.toDataURL("image/jpeg", 0.7);
|
||||||
|
|
||||||
|
return new Promise((res) => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (thumbnailRes) {
|
||||||
|
clearInterval(interval);
|
||||||
|
res(thumbnailRes);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDuration = async () => {
|
||||||
|
videoRef.currentTime = 1e101;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
videoRef.ontimeupdate = () => {
|
||||||
|
videoRef.ontimeupdate = null;
|
||||||
|
resolve(videoRef.duration);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.currentTime = 0;
|
||||||
|
}, 25);
|
||||||
|
return videoRef.duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMetaLoad = async () => {
|
||||||
|
let videoDuration = videoRef.duration;
|
||||||
|
if (videoDuration === Infinity || Number.isNaN(videoDuration)) {
|
||||||
|
videoDuration = await getDuration();
|
||||||
|
}
|
||||||
|
setDuration(videoDuration);
|
||||||
|
void generateThumbnail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVideoEnd = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVideoRef = (el: HTMLVideoElement) => {
|
||||||
|
videoRef = el;
|
||||||
|
videoRef.addEventListener("loadedmetadata", onMetaLoad);
|
||||||
|
videoRef.addEventListener("ended", onVideoEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (num: number) => {
|
||||||
|
return Math.round((num + Number.EPSILON) * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrimChange = (a: number, b: number) => {
|
||||||
|
const start = round(a);
|
||||||
|
const end = round(b);
|
||||||
|
setTrimBounds([start, end]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrimDuration = () => {
|
||||||
|
const [trimStart, trimEnd] = trimBounds();
|
||||||
|
return trimEnd - trimStart;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageUrl = document.location.href;
|
||||||
|
|
||||||
|
let dialogRef: HTMLDialogElement;
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (dialogRef) {
|
||||||
|
dialogRef.showModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeUrl = pageUrl.length > 60 ? pageUrl.slice(0, 60) + "..." : pageUrl;
|
||||||
|
|
||||||
|
const int = setInterval(() => {
|
||||||
|
updateCurrentTime();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(int);
|
||||||
|
videoRef.removeEventListener("loadedmetadata", onMetaLoad);
|
||||||
|
videoRef.removeEventListener("ended", onVideoEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={(el) => (dialogRef = el)}
|
||||||
|
id="editRecording"
|
||||||
|
class="modal save-controls"
|
||||||
|
>
|
||||||
|
<div class="modal-box bg-slate-50 p-0 max-w-[85%]">
|
||||||
|
<div class={"savingcontainer flex xl:flex-row flex-col"}>
|
||||||
|
{processing() ? (
|
||||||
|
<div class={"processingloader"}>
|
||||||
|
<div class="flex flex-col gap-2 justify-center items-center">
|
||||||
|
<span class="loading loading-spinner text-primary text-center justify-center items-center"></span>
|
||||||
|
Saving...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div class={"replayarea flex-1 p-4 join join-vertical"}>
|
||||||
|
<div
|
||||||
|
class={
|
||||||
|
"card join-item border-t border-r border-l border-slate-100 "
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={
|
||||||
|
"urlcontainer text-sm p-2 text-neutral/70 flex gap-1 items-center overflow-hidden"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-link-2"
|
||||||
|
>
|
||||||
|
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||||
|
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
||||||
|
<line x1="8" x2="16" y1="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{safeUrl}
|
||||||
|
</div>
|
||||||
|
<video
|
||||||
|
ref={setVideoRef}
|
||||||
|
class={"videocontainer"}
|
||||||
|
src={videoData()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class={"card p-1"}>
|
||||||
|
{errorEvents().length ? (
|
||||||
|
<div class={'relative w-full h-4'}>
|
||||||
|
{errorEvents().map(e => (
|
||||||
|
<div
|
||||||
|
class={'w-3 h-3 rounded-full bg-red-600 absolute tooltip'}
|
||||||
|
style={{ top: '2px', left: `${(e.time/duration()) * 100}%` }}
|
||||||
|
data-tip={e.title}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div class={"flex items-center gap-2"}>
|
||||||
|
<div
|
||||||
|
class={`${playing() ? "" : "bg-indigo-100"} cursor-pointer btn btn-ghost btn-circle btn-sm hover:bg-indigo-50 border border-slate-100`}
|
||||||
|
>
|
||||||
|
{playing() ? (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
pause();
|
||||||
|
}}
|
||||||
|
class={"pause-icon w-5"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
resume();
|
||||||
|
}}
|
||||||
|
class={"play-icon ml-0.5 w-5"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 items-center gap-4">
|
||||||
|
<div class="w-11 text-sm font-medium">
|
||||||
|
{formatMsToTime(currentTime() * 1000)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "21px",
|
||||||
|
}}
|
||||||
|
onMouseMove={onDrag}
|
||||||
|
onMouseUp={endDrag}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="marker start"
|
||||||
|
onMouseDown={startDrag("start")}
|
||||||
|
style={{ left: `${startPos()}%` }}
|
||||||
|
>
|
||||||
|
<div class="handle"></div>
|
||||||
|
<div class="handle"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="slider-body"
|
||||||
|
// onMouseDown={startDrag("body")}
|
||||||
|
style={{
|
||||||
|
left: `calc(${startPos()}% + 3px)`,
|
||||||
|
width: `calc(${endPos() - startPos()}% - 0px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="marker end"
|
||||||
|
onMouseDown={startDrag("end")}
|
||||||
|
style={{ left: `${endPos()}%` }}
|
||||||
|
>
|
||||||
|
<div class="handle"></div>
|
||||||
|
<div class="handle"></div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
step={0.01}
|
||||||
|
max={duration() - 0.1}
|
||||||
|
value={currentTime()}
|
||||||
|
onInput={(e) => {
|
||||||
|
const time = parseFloat(e.currentTarget.value);
|
||||||
|
videoRef.currentTime = time;
|
||||||
|
setCurrentTime(time);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-11 text-sm font-medium">
|
||||||
|
{formatMsToTime(duration() * 1000)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getTrimDuration() > 0 ? (
|
||||||
|
<p class="text-xs block text-center py-2">
|
||||||
|
<span class="font-meidum me-1">
|
||||||
|
{formatMsToTime(getTrimDuration() * 1000)}
|
||||||
|
</span>
|
||||||
|
The selected portion of the recording will be saved.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class={"commentarea flex-none p-4 xl:ps-0 gap-2 "}>
|
||||||
|
<div class="flex flex-col ">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h4 class="text-lg font-medium mb-4">Save Spot</h4>
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3.5">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class={"text-base font-medium mb-2"}>Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name this Spot"
|
||||||
|
maxlength={64}
|
||||||
|
value={name()}
|
||||||
|
onFocus={() => setIsTyping(true)}
|
||||||
|
onBlur={() => setIsTyping(false)}
|
||||||
|
onInput={(e) => setName(e.currentTarget.value)}
|
||||||
|
class="input input-bordered w-full input-sm text-base mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={"text-base font-medium"}>Comments</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Add more details..."
|
||||||
|
value={description()}
|
||||||
|
maxLength={256}
|
||||||
|
onFocus={() => setIsTyping(true)}
|
||||||
|
onBlur={() => setIsTyping(false)}
|
||||||
|
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
class="textarea textarea-bordered w-full textarea-sm text-base leading-normal mt-1"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={"flex flex-col gap-3 justify-end mt-4"}>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
onClick={onSave}
|
||||||
|
class={"btn btn-primary btn-sm text-white text-base"}
|
||||||
|
>
|
||||||
|
Save Spot
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={onCancel}
|
||||||
|
class={
|
||||||
|
"btn btn-outline btn-primary btn-sm text-base hover:bg-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs">
|
||||||
|
Spots are saved to your{" "}
|
||||||
|
<a
|
||||||
|
href="https://foss.openreplay.com/spots"
|
||||||
|
class="text-primary no-underline"
|
||||||
|
target="blank"
|
||||||
|
>
|
||||||
|
OpenReplay account.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SavingControls;
|
||||||
50
spot/entrypoints/content/dragControls.css
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
.draggable-markers {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #FCC100;
|
||||||
|
cursor: ew-resize;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 10px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker.start {
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker.end {
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
background-color: black;
|
||||||
|
height: 50%;
|
||||||
|
width: 1px;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-body {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(252, 193, 0, 0.10);
|
||||||
|
border-top: 1px solid #FCC100;
|
||||||
|
border-bottom: 1px solid #FCC100;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
100
spot/entrypoints/content/eventTrackers.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { onCLS, onINP, onLCP, Metric } from "web-vitals";
|
||||||
|
|
||||||
|
export const clicksArray: { time: number; label: string }[] = [];
|
||||||
|
export let clickInt: ReturnType<typeof setInterval> | null = null;
|
||||||
|
export let locationInt: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let vitalsSet = false;
|
||||||
|
|
||||||
|
export function startLocationRecording() {
|
||||||
|
let currentLocation = location.href;
|
||||||
|
const sendLocation = (msg: Record<string, any>) => {
|
||||||
|
void browser.runtime.sendMessage({
|
||||||
|
type: "ort:bump-location",
|
||||||
|
location: msg,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkVitals = (val: Metric) => {
|
||||||
|
if (locationInt !== null) {
|
||||||
|
void browser.runtime.sendMessage({
|
||||||
|
type: "ort:bump-vitals",
|
||||||
|
vital: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!vitalsSet) {
|
||||||
|
onCLS(checkVitals);
|
||||||
|
onINP(checkVitals);
|
||||||
|
onLCP(checkVitals);
|
||||||
|
vitalsSet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grabNavTimingData = () => {
|
||||||
|
const navTiming = performance.getEntriesByType("navigation");
|
||||||
|
return {
|
||||||
|
fcpTime: navTiming[0].domContentLoadedEventEnd,
|
||||||
|
visuallyComplete: navTiming[0].domComplete,
|
||||||
|
timeToInteractive: navTiming[0].domInteractive,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const initMsg = {
|
||||||
|
time: Date.now(),
|
||||||
|
location: location.href,
|
||||||
|
navTiming: grabNavTimingData(),
|
||||||
|
};
|
||||||
|
sendLocation(initMsg);
|
||||||
|
locationInt = setInterval(() => {
|
||||||
|
const newLocation = location.href;
|
||||||
|
if (currentLocation !== newLocation) {
|
||||||
|
const newMsg = {
|
||||||
|
time: Date.now(),
|
||||||
|
location: newLocation,
|
||||||
|
navTiming: grabNavTimingData(),
|
||||||
|
};
|
||||||
|
sendLocation(newMsg);
|
||||||
|
currentLocation = newLocation;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLocationRecording() {
|
||||||
|
if (locationInt) {
|
||||||
|
clearInterval(locationInt);
|
||||||
|
locationInt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackClick(e: any) {
|
||||||
|
const parentShadowRoot = document.querySelector("spot-ui");
|
||||||
|
// ignore clicks inside ctx shadowRoot
|
||||||
|
if (e.target && parentShadowRoot?.contains(e.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clickObj = {
|
||||||
|
time: Date.now(),
|
||||||
|
label: e.target?.tagName || "unknown",
|
||||||
|
};
|
||||||
|
if (e.target && e.target.tagName !== "INPUT") {
|
||||||
|
clickObj.label = e.target.innerText || e.target.tagName;
|
||||||
|
}
|
||||||
|
clicksArray.push(clickObj);
|
||||||
|
}
|
||||||
|
export function startClickRecording() {
|
||||||
|
clicksArray.length = 0;
|
||||||
|
document.addEventListener("click", trackClick);
|
||||||
|
clickInt = setInterval(() => {
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: "ort:bump-clicks",
|
||||||
|
clicks: clicksArray,
|
||||||
|
});
|
||||||
|
clicksArray.length = 0;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
export function stopClickRecording() {
|
||||||
|
document.removeEventListener("click", trackClick);
|
||||||
|
if (clickInt) {
|
||||||
|
clearInterval(clickInt);
|
||||||
|
clickInt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
367
spot/entrypoints/content/index.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
import { render } from "solid-js/web";
|
||||||
|
import {
|
||||||
|
startLocationRecording,
|
||||||
|
stopLocationRecording,
|
||||||
|
startClickRecording,
|
||||||
|
stopClickRecording,
|
||||||
|
} from "./eventTrackers";
|
||||||
|
import ControlsBox from "@/entrypoints/content/ControlsBox";
|
||||||
|
|
||||||
|
import { convertBlobToBase64, getChromeFullVersion } from "./utils";
|
||||||
|
import "./style.css";
|
||||||
|
import "~/assets/main.css";
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ["*://*/*"],
|
||||||
|
cssInjectionMode: "ui",
|
||||||
|
|
||||||
|
async main(ctx) {
|
||||||
|
const ui = await createShadowRootUi(ctx, {
|
||||||
|
name: "spot-ui",
|
||||||
|
position: "inline",
|
||||||
|
anchor: "body",
|
||||||
|
append: "first",
|
||||||
|
onMount: (container) => {
|
||||||
|
return render(
|
||||||
|
() => (
|
||||||
|
<ControlsBox
|
||||||
|
getMicStatus={getMicStatus}
|
||||||
|
pause={pause}
|
||||||
|
resume={resume}
|
||||||
|
stop={stop}
|
||||||
|
getVideoData={getVideoData}
|
||||||
|
onClose={onClose}
|
||||||
|
getClockStart={getClockStart}
|
||||||
|
muteMic={muteMic}
|
||||||
|
unmuteMic={unmuteMic}
|
||||||
|
getInitState={() => recState}
|
||||||
|
callRecording={countEnd}
|
||||||
|
onRestart={onRestart}
|
||||||
|
getErrorEvents={getErrorEvents}
|
||||||
|
getAudioPerm={getAudioPerm}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRemove: (unmount) => {
|
||||||
|
unmount?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let micResponse: boolean | null = null;
|
||||||
|
const getMicStatus = async () => {
|
||||||
|
return new Promise((res) => {
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: "ort:getMicStatus",
|
||||||
|
});
|
||||||
|
let int = setInterval(() => {
|
||||||
|
if (micResponse !== null) {
|
||||||
|
clearInterval(int);
|
||||||
|
res(micResponse);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// no perm - muted - unmuted
|
||||||
|
let audioPerm = 0;
|
||||||
|
const getAudioPerm = () => audioPerm
|
||||||
|
let clockStart = 0;
|
||||||
|
let recState = "stopped";
|
||||||
|
const getClockStart = () => {
|
||||||
|
return clockStart;
|
||||||
|
};
|
||||||
|
let data: Record<string, any> | null = null;
|
||||||
|
const videoChunks: string[] = [];
|
||||||
|
let chunksReady = false;
|
||||||
|
let errorsReady = false;
|
||||||
|
const errorData: { title: string; time: number }[] = [];
|
||||||
|
|
||||||
|
const getErrorEvents = async (): Promise<any> => {
|
||||||
|
let tries = 0;
|
||||||
|
browser.runtime.sendMessage({ type: "ort:get-error-events" });
|
||||||
|
return new Promise((res) => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (errorsReady) {
|
||||||
|
clearInterval(interval);
|
||||||
|
errorsReady = false;
|
||||||
|
res(errorData);
|
||||||
|
}
|
||||||
|
// 3 sec timeout
|
||||||
|
if (tries > 30) {
|
||||||
|
clearInterval(interval);
|
||||||
|
res([]);
|
||||||
|
}
|
||||||
|
tries += 1;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVideoData = async (): Promise<any> => {
|
||||||
|
let tries = 0;
|
||||||
|
return new Promise((res) => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (data && chunksReady) {
|
||||||
|
clearInterval(interval);
|
||||||
|
videoChunks.length = 0;
|
||||||
|
chunksReady = false;
|
||||||
|
res(data);
|
||||||
|
}
|
||||||
|
// 10 sec timeout
|
||||||
|
if (tries > 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
res(null);
|
||||||
|
}
|
||||||
|
tries += 1;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
recState = "stopped";
|
||||||
|
stopClickRecording();
|
||||||
|
stopLocationRecording();
|
||||||
|
const result = await browser.runtime.sendMessage({ type: "ort:stop" });
|
||||||
|
if (result.status === "full") {
|
||||||
|
chunksReady = true;
|
||||||
|
data = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result.status === "parts") {
|
||||||
|
return new Promise((res) => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (chunksReady) {
|
||||||
|
data = Object.assign({}, result, {
|
||||||
|
base64data: videoChunks.concat([]),
|
||||||
|
});
|
||||||
|
clearInterval(interval);
|
||||||
|
res(true);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
recState = "paused";
|
||||||
|
browser.runtime.sendMessage({ type: "ort:pause" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
recState = "recording";
|
||||||
|
browser.runtime.sendMessage({ type: "ort:resume" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const muteMic = () => {
|
||||||
|
browser.runtime.sendMessage({ type: "ort:mute-microphone" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmuteMic = () => {
|
||||||
|
browser.runtime.sendMessage({ type: "ort:unmute-microphone" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = async (
|
||||||
|
save: boolean,
|
||||||
|
spotObj?: {
|
||||||
|
blob?: Blob;
|
||||||
|
name?: string;
|
||||||
|
comment?: string;
|
||||||
|
useHook?: boolean;
|
||||||
|
thumbnail?: string;
|
||||||
|
crop?: [number, number];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!save || !spotObj) {
|
||||||
|
await chrome.runtime.sendMessage({
|
||||||
|
type: "ort:discard",
|
||||||
|
});
|
||||||
|
stopClickRecording();
|
||||||
|
stopLocationRecording();
|
||||||
|
ui.remove();
|
||||||
|
recState = "stopped";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { name, comment, useHook, thumbnail, crop, blob } = spotObj;
|
||||||
|
const videoData = await convertBlobToBase64(blob);
|
||||||
|
const resolution = `${window.screen.width}x${window.screen.height}`;
|
||||||
|
const browserVersion = getChromeFullVersion();
|
||||||
|
const spot = {
|
||||||
|
name,
|
||||||
|
comment,
|
||||||
|
useHook,
|
||||||
|
preview: thumbnail,
|
||||||
|
resolution,
|
||||||
|
browserVersion,
|
||||||
|
crop,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "ort:save-spot",
|
||||||
|
spot,
|
||||||
|
});
|
||||||
|
let index = 0;
|
||||||
|
for (let part of videoData.result) {
|
||||||
|
if (part) {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "ort:save-spot-part",
|
||||||
|
part,
|
||||||
|
index,
|
||||||
|
total: videoData.result.length,
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.remove();
|
||||||
|
} catch (e) {
|
||||||
|
console.trace(
|
||||||
|
"error saving video",
|
||||||
|
spot,
|
||||||
|
videoData,
|
||||||
|
resolution,
|
||||||
|
browserVersion,
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.data.type === "orspot:ping") {
|
||||||
|
window.postMessage({ type: "orspot:pong" }, "*");
|
||||||
|
}
|
||||||
|
if (event.data.type === "orspot:token") {
|
||||||
|
window.postMessage({ type: "orspot:logged" }, "*");
|
||||||
|
void browser.runtime.sendMessage({
|
||||||
|
type: "ort:login-token",
|
||||||
|
token: event.data.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.data.type === "orspot:invalidate") {
|
||||||
|
void browser.runtime.sendMessage({
|
||||||
|
type: "ort:invalidate-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.data.type === "ort:bump-logs") {
|
||||||
|
void chrome.runtime.sendMessage({
|
||||||
|
type: "ort:bump-logs",
|
||||||
|
logs: event.data.logs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function startConsoleTracking() {
|
||||||
|
const scriptEl = document.createElement("script");
|
||||||
|
scriptEl.src = browser.runtime.getURL("/injected.js");
|
||||||
|
document.head.appendChild(scriptEl);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.postMessage({ type: "injected:start" });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopConsoleTracking() {
|
||||||
|
window.postMessage({ type: "injected:stop" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRestart() {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: "ort:restart",
|
||||||
|
});
|
||||||
|
stopClickRecording();
|
||||||
|
stopLocationRecording();
|
||||||
|
stopConsoleTracking();
|
||||||
|
recState = "stopped";
|
||||||
|
ui.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountNotifications() {
|
||||||
|
const scriptEl = document.createElement("script");
|
||||||
|
scriptEl.src = browser.runtime.getURL("/notifications.js");
|
||||||
|
document.head.appendChild(scriptEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmountNotifications() {
|
||||||
|
window.postMessage({ type: "ornotif:stop" });
|
||||||
|
}
|
||||||
|
|
||||||
|
mountNotifications();
|
||||||
|
|
||||||
|
let onEndObj = {};
|
||||||
|
async function countEnd(): Promise<boolean> {
|
||||||
|
return browser.runtime
|
||||||
|
.sendMessage({ ...onEndObj, type: "ort:countend" })
|
||||||
|
.then((r: boolean) => {
|
||||||
|
onEndObj = {};
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void browser.runtime.sendMessage({ type: "ort:content-ready" });
|
||||||
|
browser.runtime.onMessage.addListener((message: any, resp) => {
|
||||||
|
if (message.type === "content:mount") {
|
||||||
|
if (recState === "count") return;
|
||||||
|
recState = "count";
|
||||||
|
onEndObj = {
|
||||||
|
area: message.area,
|
||||||
|
mic: message.mic,
|
||||||
|
audioId: message.audioId,
|
||||||
|
};
|
||||||
|
audioPerm = message.audioPerm;
|
||||||
|
ui.mount();
|
||||||
|
}
|
||||||
|
if (message.type === "content:start") {
|
||||||
|
if (recState === "recording") return;
|
||||||
|
clockStart = message.time;
|
||||||
|
recState = "recording";
|
||||||
|
micResponse = null;
|
||||||
|
startClickRecording();
|
||||||
|
startLocationRecording();
|
||||||
|
startConsoleTracking();
|
||||||
|
browser.runtime.sendMessage({ type: "ort:started" });
|
||||||
|
if (message.shouldMount) {
|
||||||
|
ui.mount();
|
||||||
|
}
|
||||||
|
return "pong";
|
||||||
|
}
|
||||||
|
if (message.type === "notif:display") {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
type: "ornotif:display",
|
||||||
|
message: message.message,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (message.type === "content:unmount") {
|
||||||
|
stopClickRecording();
|
||||||
|
stopLocationRecording();
|
||||||
|
stopConsoleTracking();
|
||||||
|
recState = "stopped";
|
||||||
|
ui.remove();
|
||||||
|
return "unmounted";
|
||||||
|
}
|
||||||
|
if (message.type === "content:video-chunk") {
|
||||||
|
videoChunks[message.index] = message.data;
|
||||||
|
if (message.total === message.index + 1) {
|
||||||
|
chunksReady = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.type === "content:spot-saved") {
|
||||||
|
window.postMessage({ type: "ornotif:copy", url: message.url });
|
||||||
|
}
|
||||||
|
if (message.type === "content:stop") {
|
||||||
|
window.postMessage({ type: "content:trigger-stop" }, "*");
|
||||||
|
}
|
||||||
|
if (message.type === "content:mic-status") {
|
||||||
|
micResponse = message.micStatus;
|
||||||
|
}
|
||||||
|
if (message.type === "content:error-events") {
|
||||||
|
errorsReady = true;
|
||||||
|
errorData.push(...message.errorData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
307
spot/entrypoints/content/style.css
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
all: initial;
|
||||||
|
contain: content; /* Boom. CSS containment FTW. */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.body {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savingcontainer {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0px 2px 0px 0px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsarea > input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 34px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redcircle {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: red;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid white;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ff4d4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitetriangle {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 10px solid white;
|
||||||
|
border-top: 10px solid transparent;
|
||||||
|
border-bottom: 10px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetriangle {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 16px solid #394eff;
|
||||||
|
border-top: 12px solid transparent;
|
||||||
|
border-bottom: 12px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluesquare {
|
||||||
|
min-width: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
background-color: #394eff;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerarea {
|
||||||
|
min-width: 56px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitestripe {
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .inputlabel {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: black;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.bluebutton {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #394eff;
|
||||||
|
background: #394eff;
|
||||||
|
|
||||||
|
box-shadow: 0px 2px 0px 0px rgba(0, 0, 0, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluebutton:hover {
|
||||||
|
background: #2338df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearbutton {
|
||||||
|
display: flex;
|
||||||
|
padding: 4px 15px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
background-color: #d0d4f2;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin-top: -0.6rem;
|
||||||
|
background-color: #394eff;
|
||||||
|
border-radius: 2px;
|
||||||
|
height: 1.8rem;
|
||||||
|
width: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-track {
|
||||||
|
background-color: #d0d4f2;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
background-color: #394eff;
|
||||||
|
border: 2px solid #303f9f;
|
||||||
|
border-radius: 2px;
|
||||||
|
height: 2rem;
|
||||||
|
width: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
border: 1px solid #d0d4f2;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processingloader {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 999999;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 11px solid blue;
|
||||||
|
border-top: 8px solid transparent;
|
||||||
|
border-bottom: 8px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-icon::before,
|
||||||
|
.pause-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: 12px;
|
||||||
|
background: blue;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-icon::before {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-icon::after {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* style.css */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 99999;
|
||||||
|
transition: all 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: transparent;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
transition: all 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* style.css */
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-controls {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 5%;
|
||||||
|
left: 45%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 999999;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupanimated {
|
||||||
|
animation: slideInUp 0.25s ease-in-out forwards;
|
||||||
|
}
|
||||||
56
spot/entrypoints/content/utils.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
export const STATES = {
|
||||||
|
count: "count",
|
||||||
|
recording: "recording",
|
||||||
|
saving: "saving",
|
||||||
|
idle: "idle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function formatMsToTime(millis: number) {
|
||||||
|
const minutes = Math.floor(millis / 60000);
|
||||||
|
const seconds = ((millis % 60000) / 1000).toFixed(0);
|
||||||
|
return parseInt(seconds) === 60
|
||||||
|
? minutes.toString().padStart(2, "0") + 1 + ":00"
|
||||||
|
: minutes.toString().padStart(2, "0") +
|
||||||
|
":" +
|
||||||
|
(parseInt(seconds) < 10 ? "0" : "") +
|
||||||
|
seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardLimit = 24 * 1024 * 1024; // 24 MB
|
||||||
|
export function convertBlobToBase64(
|
||||||
|
blob: Blob,
|
||||||
|
): Promise<{ result: string[]; size: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const parts = [];
|
||||||
|
const base64data = reader.result as string;
|
||||||
|
if (base64data && base64data.length > hardLimit) {
|
||||||
|
const chunkSize = hardLimit;
|
||||||
|
for (let i = 0; i < base64data.length; i += chunkSize) {
|
||||||
|
parts.push(base64data.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push(base64data);
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
result: parts,
|
||||||
|
size: base64data.length,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChromeFullVersion() {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const match = userAgent.match(
|
||||||
|
/Chrom(e|ium)\/([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return `${match[2]}.${match[3]}.${match[4]}.${match[5]}`;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
153
spot/entrypoints/injected.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
export default defineUnlistedScript(() => {
|
||||||
|
const printError =
|
||||||
|
"InstallTrigger" in window // detect Firefox
|
||||||
|
? (e) => e.message + "\n" + e.stack
|
||||||
|
: (e) => e.stack || e.message;
|
||||||
|
|
||||||
|
function printString(arg) {
|
||||||
|
if (arg === undefined) {
|
||||||
|
return "undefined";
|
||||||
|
}
|
||||||
|
if (arg === null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return printError(arg);
|
||||||
|
}
|
||||||
|
if (Array.isArray(arg)) {
|
||||||
|
return `Array(${arg.length})`;
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printFloat(arg) {
|
||||||
|
if (typeof arg !== "number") return "NaN";
|
||||||
|
return arg.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printInt(arg) {
|
||||||
|
if (typeof arg !== "number") return "NaN";
|
||||||
|
return Math.floor(arg).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printObject(arg) {
|
||||||
|
if (arg === undefined) {
|
||||||
|
return "undefined";
|
||||||
|
}
|
||||||
|
if (arg === null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return printError(arg);
|
||||||
|
}
|
||||||
|
if (Array.isArray(arg)) {
|
||||||
|
const length = arg.length;
|
||||||
|
const values = arg.slice(0, 10).map(printString).join(", ");
|
||||||
|
return `Array(${length})[${values}]`;
|
||||||
|
}
|
||||||
|
if (typeof arg === "object") {
|
||||||
|
const res = [];
|
||||||
|
let i = 0;
|
||||||
|
for (const k in arg) {
|
||||||
|
if (++i === 10) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const v = arg[k];
|
||||||
|
res.push(k + ": " + printString(v));
|
||||||
|
}
|
||||||
|
return "{" + res.join(", ") + "}";
|
||||||
|
}
|
||||||
|
return arg.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printf(args) {
|
||||||
|
if (typeof args[0] === "string") {
|
||||||
|
args.unshift(
|
||||||
|
args.shift().replace(/%(o|s|f|d|i)/g, (s, t) => {
|
||||||
|
const arg = args.shift();
|
||||||
|
if (arg === undefined) return s;
|
||||||
|
switch (t) {
|
||||||
|
case "o":
|
||||||
|
return printObject(arg);
|
||||||
|
case "s":
|
||||||
|
return printString(arg);
|
||||||
|
case "f":
|
||||||
|
return printFloat(arg);
|
||||||
|
case "d":
|
||||||
|
case "i":
|
||||||
|
return printInt(arg);
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return args.map(printObject).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleMethods = ["log", "info", "warn", "error", "debug", "assert"];
|
||||||
|
|
||||||
|
const patchConsole = (console, ctx) => {
|
||||||
|
if (window.revokeSpotPatch || window.__or_proxy_revocable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = 0;
|
||||||
|
const reset = () => {
|
||||||
|
n = 0;
|
||||||
|
};
|
||||||
|
let int = setInterval(reset, 1000);
|
||||||
|
|
||||||
|
const sendConsoleLog = (level, args) => {
|
||||||
|
const msg = printf(args);
|
||||||
|
const truncated =
|
||||||
|
msg.length > 5000 ? `Truncated: ${msg.slice(0, 5000)}...` : msg;
|
||||||
|
const logs = [{ level, msg: truncated, time: Date.now() }];
|
||||||
|
window.postMessage({ type: "ort:bump-logs", logs }, "*");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (level) => ({
|
||||||
|
apply: function (target, thisArg, argumentsList) {
|
||||||
|
Reflect.apply(target, ctx, argumentsList);
|
||||||
|
n = n + 1;
|
||||||
|
if (n > 10) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
sendConsoleLog(level, argumentsList); // Pass the correct level
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__or_proxy_revocable = [];
|
||||||
|
consoleMethods.forEach((method) => {
|
||||||
|
if (consoleMethods.indexOf(method) === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fn = ctx.console[method];
|
||||||
|
// is there any way to preserve the original console trace?
|
||||||
|
const revProxy = Proxy.revocable(fn, handler(method));
|
||||||
|
console[method] = revProxy.proxy;
|
||||||
|
window.__or_proxy_revocable.push(revProxy);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(int);
|
||||||
|
window.__or_proxy_revocable.forEach((revocable) => {
|
||||||
|
revocable.revoke();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.data.type === "injected:start") {
|
||||||
|
if (!window.__or_revokeSpotPatch) {
|
||||||
|
window.__or_revokeSpotPatch = patchConsole(console, window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.data.type === "injected:stop") {
|
||||||
|
if (window.__or_revokeSpotPatch) {
|
||||||
|
window.__or_revokeSpotPatch();
|
||||||
|
window.__or_revokeSpotPatch = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
111
spot/entrypoints/notifications.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
export default defineUnlistedScript(() => {
|
||||||
|
async function copyToTheClipboard(textToCopy) {
|
||||||
|
const el = document.createElement("textarea");
|
||||||
|
el.value = textToCopy;
|
||||||
|
el.setAttribute("readonly", "");
|
||||||
|
el.style.position = "absolute";
|
||||||
|
el.style.left = "-9999px";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCSS() {
|
||||||
|
const cssText = `
|
||||||
|
.flex{display:flex}
|
||||||
|
.items-center {align-items:center}
|
||||||
|
.gap-3 {gap: .25rem}
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #394dfe;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = cssText;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNotification(event) {
|
||||||
|
const message = event.data.message || "Recording has started successfully.";
|
||||||
|
|
||||||
|
const notificationContent = `
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const notification = document.createElement("div");
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "2rem",
|
||||||
|
right: "2rem",
|
||||||
|
backgroundColor: "#E2E4F6",
|
||||||
|
color: "black",
|
||||||
|
padding: "1.5rem",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
opacity: "0.9",
|
||||||
|
transition: "opacity 300ms",
|
||||||
|
zIndex: 99999999,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(notification.style, styles);
|
||||||
|
notification.innerHTML = notificationContent;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Force reflow to ensure styles are applied
|
||||||
|
notification.offsetHeight; // Trigger reflow
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.opacity = "0";
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(notification);
|
||||||
|
}, 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNotificationListener() {
|
||||||
|
function handleMessage(event) {
|
||||||
|
if (event.data.type === "ornotif:display") {
|
||||||
|
createNotification(event);
|
||||||
|
}
|
||||||
|
if (event.data.type === "ornotif:copy") {
|
||||||
|
copyToTheClipboard(event.data.url)
|
||||||
|
.then(() => {
|
||||||
|
createNotification({
|
||||||
|
data: { message: 'Recording opened in a new tab. Link is copied to clipboard.' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.data.type === "ornotif:stop") {
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
|
return function cleanup() {
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
injectCSS();
|
||||||
|
if (!window.__or_clear_notifications) {
|
||||||
|
window.__or_clear_notifications = initNotificationListener();
|
||||||
|
}
|
||||||
|
});
|
||||||