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>
This commit is contained in:
Delirium 2024-08-29 13:35:58 +02:00 committed by GitHub
parent a788fadb1c
commit 1326bb2eae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 25036 additions and 1114 deletions

View file

@ -19,10 +19,12 @@ jobs:
uses: actions/checkout@v2
- name: Cache node modules
uses: actions/cache@v1
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
path: |
/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: |
${{ runner.OS }}-build-
${{ runner.OS }}-

View file

@ -5,6 +5,7 @@ import { Loader } from 'UI';
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
import APIClient from './api_client';
import { getScope } from "./duck/user";
import * as routes from './routes';
import { OB_DEFAULT_TAB } from 'App/routes';
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
@ -28,6 +29,7 @@ const components: any = {
UsabilityTestOverviewPure: lazy(() => import('Components/UsabilityTesting/TestOverview')),
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
ScopeSetup: lazy(() => import('Components/ScopeForm')),
};
const enhancedComponents: any = {
@ -47,6 +49,7 @@ const enhancedComponents: any = {
UsabilityTestOverview: withSiteIdUpdater(components.UsabilityTestOverviewPure),
SpotsList: withSiteIdUpdater(components.SpotsListPure),
Spot: components.SpotPure,
ScopeSetup: components.ScopeSetup
};
const withSiteId = routes.withSiteId;
@ -92,6 +95,7 @@ const USABILITY_TESTING_VIEW_PATH = routes.usabilityTestingView();
const SPOTS_LIST_PATH = routes.spotsList();
const SPOT_PATH = routes.spot();
const SCOPE_SETUP = routes.scopeSetup();
interface Props {
isEnterprise: boolean;
@ -100,6 +104,7 @@ interface Props {
jwt: string;
sites: Map<string, any>;
onboarding: boolean;
spotOnly?: boolean;
}
function PrivateRoutes(props: Props) {
@ -107,6 +112,7 @@ function PrivateRoutes(props: Props) {
const redirectToOnboarding =
!onboarding && localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true';
const siteIdList: any = sites.map(({ id }) => id).toJS();
return (
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
<Switch key="content">
@ -115,21 +121,40 @@ function PrivateRoutes(props: Props) {
path={withSiteId(ONBOARDING_PATH, siteIdList)}
component={enhancedComponents.Onboarding}
/>
<Route
exact
strict
path={SPOTS_LIST_PATH}
component={enhancedComponents.SpotsList}
/>
<Route
exact
strict
path={SPOT_PATH}
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,
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,
case "/integrations/msteams":
client.post("integrations/msteams/add", {
code: location.search.split("=")[1],
state: props.tenantId
});
break;
}
@ -152,7 +177,7 @@ function PrivateRoutes(props: Props) {
withSiteId(DASHBOARD_PATH, siteIdList),
withSiteId(DASHBOARD_SELECT_PATH, siteIdList),
withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList),
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList),
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList)
]}
component={enhancedComponents.Dashboard}
/>
@ -225,7 +250,7 @@ function PrivateRoutes(props: Props) {
withSiteId(FFLAG_READ_PATH, siteIdList),
withSiteId(FFLAG_CREATE_PATH, siteIdList),
withSiteId(NOTES_PATH, siteIdList),
withSiteId(BOOKMARKS_PATH, siteIdList),
withSiteId(BOOKMARKS_PATH, siteIdList)
]}
component={enhancedComponents.SessionsOverview}
/>
@ -241,23 +266,12 @@ function PrivateRoutes(props: Props) {
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
component={enhancedComponents.LiveSession}
/>
<Route
exact
strict
path={withSiteId(SPOTS_LIST_PATH, siteIdList)}
component={enhancedComponents.SpotsList}
/>
<Route
exact
strict
path={withSiteId(SPOT_PATH, siteIdList)}
component={enhancedComponents.Spot}
/>
{Object.entries(routes.redirects).map(([fr, to]) => (
<Redirect key={fr} exact strict from={fr} to={to} />
))}
<AdditionalRoutes redirect={withSiteId(routes.sessions(), siteId)} />
</>}
{props.spotOnly ? <Redirect to={withSiteId(SPOTS_LIST_PATH, siteId)} /> : null}
</Switch>
</Suspense>
);
@ -269,6 +283,7 @@ export default connect((state: any) => ({
sites: state.getIn(['site', 'list']),
siteId: state.getIn(['site', 'siteId']),
jwt: state.getIn(['user', 'jwt']),
spotOnly: getScope(state) === 'spot',
tenantId: state.getIn(['user', 'account', 'tenantId']),
isEnterprise:
state.getIn(['user', 'account', 'edition']) === 'ee' ||

View file

@ -29,7 +29,7 @@ function PublicRoutes(props: Props) {
<Switch>
<Route exact strict path={SPOT_PATH} component={Spot} />
<Route exact strict path={FORGOT_PASSWORD} component={ForgotPassword} />
<Route exact strict path={LOGIN_PATH} component={props.changePassword ? UpdatePassword : Login} />
<Route exact strict path={LOGIN_PATH} component={Login} />
<Route exact strict path={SIGNUP_PATH} component={Signup} />
<Redirect to={LOGIN_PATH} />
</Switch>

View file

@ -9,8 +9,8 @@ import PublicRoutes from 'App/PublicRoutes';
import {
GLOBAL_DESTINATION_PATH,
IFRAME,
JWT_PARAM,
} from 'App/constants/storageKeys';
JWT_PARAM, SPOT_ONBOARDING
} from "App/constants/storageKeys";
import Layout from 'App/layout/Layout';
import { withStore } from "App/mstore";
import { checkParam } from 'App/utils';
@ -23,7 +23,9 @@ import { init as initSite } from 'Duck/site';
import { fetchUserInfo, setJwt } from 'Duck/user';
import { fetchTenants } from 'Duck/user';
import { Loader } from 'UI';
import { spotsList } from "./routes";
import * as routes from './routes';
import { toast } from 'react-toastify'
interface RouterProps
extends RouteComponentProps,
@ -43,9 +45,11 @@ interface RouterProps
};
};
mstore: any;
setJwt: (jwt: string) => any;
setJwt: (params: { jwt: string, spotJwt: string | null }) => any;
fetchMetadata: (siteId: string) => void;
initSite: (site: any) => void;
scopeSetup: boolean;
localSpotJwt: string | null;
}
const Router: React.FC<RouterProps> = (props) => {
@ -58,18 +62,62 @@ const Router: React.FC<RouterProps> = (props) => {
fetchUserInfo,
fetchSiteList,
history,
match: {
params: { siteId: siteIdFromPath },
},
setSessionPath,
scopeSetup,
localSpotJwt,
} = props;
const params = new URLSearchParams(location.search)
const spotCb = params.get('spotCallback');
const spotReqSent = React.useRef(false)
const [isIframe, setIsIframe] = React.useState(false);
const [isJwt, setIsJwt] = React.useState(false);
const handleJwtFromUrl = () => {
const urlJWT = new URLSearchParams(location.search).get('jwt');
if (urlJWT) {
props.setJwt(urlJWT);
const params = new URLSearchParams(location.search)
const urlJWT = params.get('jwt');
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 = () => {
@ -123,8 +171,20 @@ const Router: React.FC<RouterProps> = (props) => {
useEffect(() => {
if (prevIsLoggedIn !== isLoggedIn && isLoggedIn) {
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(() => {
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
@ -153,7 +213,9 @@ const Router: React.FC<RouterProps> = (props) => {
location.pathname.includes('/assist/') ||
location.pathname.includes('multiview') ||
location.pathname.includes('/view-spot/') ||
location.pathname.includes('/spots/');
location.pathname.includes('/spots/') ||
location.pathname.includes('/scope-setup')
if (isIframe) {
return (
@ -164,7 +226,7 @@ const Router: React.FC<RouterProps> = (props) => {
return isLoggedIn ? (
<NewModalProvider>
<ModalProvider>
<Loader loading={loading || !siteId} className="flex-1">
<Loader loading={loading} className="flex-1">
<Layout hideHeader={hideHeader} siteId={siteId}>
<PrivateRoutes />
</Layout>
@ -186,14 +248,17 @@ const mapStateToProps = (state: Map<string, any>) => {
'loading',
]);
const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']);
const scopeSetup = state.getIn(['user', 'scopeSetup'])
const loading = Boolean(userInfoLoading) || Boolean(sitesLoading)
return {
siteId,
changePassword,
sites: state.getIn(['site', 'list']),
jwt,
localSpotJwt: state.getIn(['user', 'spotJwt']),
isLoggedIn: jwt !== null && !changePassword,
loading: siteId === null || userInfoLoading || sitesLoading,
scopeSetup,
loading,
email: state.getIn(['user', 'account', 'email']),
account: state.getIn(['user', 'account']),
organisation: state.getIn(['user', 'account', 'name']),

View file

@ -201,11 +201,11 @@ export default class APIClient {
const data = await response.json();
const refreshedJwt = data.jwt;
store.dispatch(setJwt(refreshedJwt));
store.dispatch(setJwt({ jwt: refreshedJwt, }));
return refreshedJwt;
} catch (error) {
console.error('Error refreshing token:', error);
store.dispatch(setJwt(null));
store.dispatch(setJwt({ jwt: null }));
throw error;
}
}

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

View 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

View 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

View 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

View file

@ -9,8 +9,14 @@ import { toast } from 'react-toastify';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { useStore } from 'App/mstore';
import { forgotPassword, signup } from 'App/routes';
import { fetchTenants, loginSuccess, setJwt } from 'Duck/user';
import { forgotPassword, signup, spotsList } from 'App/routes';
import {
fetchTenants,
loadingLogin,
loginFailure,
loginSuccess,
setJwt,
} from 'Duck/user';
import { Button, Form, Icon, Input, Link, Loader, Tooltip } from 'UI';
import Copyright from 'Shared/Copyright';
@ -27,6 +33,8 @@ interface LoginProps {
loginSuccess: typeof loginSuccess;
setJwt: typeof setJwt;
fetchTenants: typeof fetchTenants;
loadingLogin: typeof loadingLogin;
loginFailure: typeof loginFailure;
location: Location;
}
@ -38,6 +46,8 @@ const Login: React.FC<LoginProps> = ({
setJwt,
fetchTenants,
location,
loadingLogin,
loginFailure,
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@ -60,8 +70,12 @@ const Login: React.FC<LoginProps> = ({
useEffect(() => {
fetchTenants();
const jwt = params.get('jwt');
const spotJwt = params.get('spotJwt');
if (spotJwt) {
handleSpotLogin(spotJwt);
}
if (jwt) {
setJwt(jwt);
setJwt({ jwt, spotJwt });
}
}, []);
@ -99,18 +113,27 @@ const Login: React.FC<LoginProps> = ({
};
const handleSubmit = (token?: string) => {
if (!email || !password) {
return;
}
loadingLogin();
loginStore.setEmail(email.trim());
loginStore.setPassword(password);
if (token) {
loginStore.setCaptchaResponse(token);
}
loginStore.generateJWT().then((resp) => {
loginStore
.generateJWT()
.then((resp) => {
if (resp) {
loginSuccess({ ...resp, spotJwt: resp.spotJwt ?? null });
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
handleSpotLogin(resp.spotJwt);
}
loginSuccess(resp)
setJwt(resp.jwt)
})
.catch((e) => {
loginFailure(e);
});
};
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@ -122,14 +145,10 @@ const Login: React.FC<LoginProps> = ({
}
};
const onSSOClick = () => {
if (window !== window.top) {
// if in iframe
window.parent.location.href = `${window.location.origin}/api/sso/saml2?iFrame=true`;
} else {
window.location.href = `${window.location.origin}/api/sso/saml2`;
}
};
const ssoLink =
window !== window.top
? `${window.location.origin}/api/sso/saml2?iFrame=true&spot=true`
: `${window.location.origin}/api/sso/saml2?spot=true`;
return (
<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')}>
{authDetails.sso ? (
<a href="#" rel="noopener noreferrer" onClick={onSSOClick}>
<a href={ssoLink} rel="noopener noreferrer">
<Button variant="text-primary" type="submit">
{`Login with SSO ${
authDetails.ssoProvider
@ -269,7 +288,7 @@ const Login: React.FC<LoginProps> = ({
hidden: !authDetails.enforceSSO,
})}
>
<a href="#" rel="noopener noreferrer" onClick={onSSOClick}>
<a href={ssoLink} rel="noopener noreferrer">
<Button variant="primary">{`Login with SSO ${
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
}`}</Button>
@ -294,6 +313,8 @@ const mapDispatchToProps = {
loginSuccess,
setJwt,
fetchTenants,
loadingLogin,
loginFailure,
};
export default withPageTitle('Login - OpenReplay')(

View 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);

View file

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

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Icon, Tooltip } from 'UI';
import {Link2} from 'lucide-react';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
@ -19,8 +20,8 @@ function SubHeader() {
{location && (
<div className={'w-full bg-white border-b border-gray-lighter'}>
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
<Icon size="20" name="event/link" className="mr-1" />
<Tooltip title="Open in new tab" delay={0}>
<Link2 className="mx-2" size={16} />
<Tooltip title="Open in new tab" delay={0} placement='bottom'>
<a href={location} target="_blank">
{location}
</a>

View file

@ -12,12 +12,13 @@ const Header = ({
onFilterChange,
showClose = true,
customStyle,
customClose,
...props
}) => (
<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="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>
);

View file

@ -6,7 +6,11 @@ import { addMessage } from 'Duck/assignments';
class IssueCommentForm extends React.PureComponent {
state = { comment: '' }
write = ({ target: { name, value } }) => this.setState({ comment: value });
write = (e) => {
e.stopPropagation();
const { target: { name, value } } = e
this.setState({ comment: value });
}
addComment = () => {
const { comment } = this.state;

View file

@ -23,7 +23,7 @@ import FeatureSelection, {
import OverviewPanelContainer from './components/OverviewPanelContainer';
import TimelinePointer from './components/TimelinePointer';
import TimelineScale from './components/TimelineScale';
import VerticalPointerLine from './components/VerticalPointerLine';
import VerticalPointerLine, { VerticalPointerLineComp } from './components/VerticalPointerLine';
function MobileOverviewPanelCont({
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({
selectedFeatures,
endTime,
@ -224,11 +253,15 @@ function PanelComponent({
sessionId,
zoomTab,
setZoomTab,
isSpot,
spotTime,
spotEndTime,
onClose,
}: any) {
return (
<React.Fragment>
<BottomBlock style={{ height: '100%' }}>
<BottomBlock.Header>
<BottomBlock.Header customClose={onClose}>
<div className="mr-4 flex items-center gap-2">
<span className={'font-semibold text-black'}>X-Ray</span>
{showSummary ? (
@ -265,6 +298,7 @@ function PanelComponent({
</>
) : null}
</div>
{isSpot ? null : (
<div className="flex items-center h-20 mr-4 gap-2">
<TimelineZoomButton />
<FeatureSelection
@ -272,6 +306,7 @@ function PanelComponent({
updateList={setSelectedFeatures}
/>
</div>
)}
</BottomBlock.Header>
<BottomBlock.Content className={'overflow-y-auto'}>
{summaryChecked ? <SummaryBlock sessionId={sessionId} /> : null}
@ -291,7 +326,7 @@ function PanelComponent({
</div>
}
>
<VerticalPointerLine />
{isSpot ? <VerticalPointerLineComp time={spotTime} endTime={spotEndTime} /> : <VerticalPointerLine />}
{selectedFeatures.map((feature: any, index: number) => (
<div
key={feature}
@ -310,7 +345,7 @@ function PanelComponent({
fetchPresented={fetchPresented}
/>
)}
endTime={endTime}
endTime={isSpot ? spotEndTime : endTime}
message={HELP_MESSAGE[feature]}
/>
{isMobile && feature === 'PERFORMANCE' ? (

View file

@ -7,9 +7,13 @@ function VerticalPointerLine() {
const { store } = React.useContext(PlayerContext)
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;
return <VerticalLine left={left} className="border-teal" />;
}

View file

@ -1 +1 @@
export { default } from './VerticalPointerLine'
export { default, VerticalPointerLineComp } from './VerticalPointerLine'

View file

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { useStore } from 'App/mstore';
import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp';
import { Icon } from 'UI';
import {Link2} from 'lucide-react';
import QueueControls from './QueueControls';
import Bookmark from 'Shared/Bookmark';
import SharePopup from '../shared/SharePopup/SharePopup';
@ -141,8 +142,8 @@ function SubHeader(props) {
{locationTruncated && (
<div className={'w-full bg-white border-b border-gray-lighter'}>
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
<Icon size="20" name="event/link" className="mr-1" />
<Tooltip title="Open in new tab" delay={0}>
<Link2 className="mx-2" size={16} />
<Tooltip title="Open in new tab" delay={0} placement='bottom'>
<a href={currentLocation} target="_blank" className="truncate">
{locationTruncated}
</a>

View file

@ -1,3 +1,4 @@
import { Button, Card } from 'antd';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React from 'react';
@ -5,12 +6,15 @@ import { connect } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import { useStore } from 'App/mstore';
import { EscapeButton, Loader } from 'UI';
import { EscapeButton, Icon, Loader } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import {
debounceUpdate,
getDefaultPanelHeight,
} from '../../Session/Player/ReplayPlayer/PlayerInst';
import { SpotOverviewPanelCont } from '../../Session_/OverviewPanel/OverviewPanel';
import withPermissions from '../../hocs/withPermissions';
import SpotConsole from './components/Panels/SpotConsole';
import SpotNetwork from './components/Panels/SpotNetwork';
@ -32,6 +36,11 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
const { spotId } = useParams<{ spotId: string }>();
const [activeTab, setActiveTab] = React.useState<Tab | null>(null);
React.useEffect(() => {
if (spotStore.currentSpot) {
document.title = spotStore.currentSpot.title + ' - OpenReplay'
}
}, [spotStore.currentSpot])
React.useEffect(() => {
if (!loggedIn) {
const query = new URLSearchParams(window.location.search);
@ -98,6 +107,12 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
});
const ev = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return false;
}
if (e.key === 'Escape') {
spotPlayerStore.setIsFullScreen(false);
}
@ -116,6 +131,19 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
const highest = 16;
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);
@ -127,8 +155,47 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
}, []);
if (!spotStore.currentSpot) {
return (
<div className={'w-screen h-screen flex items-center justify-center'}>
<div
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>
);
}
@ -166,7 +233,6 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
// }]
// };
console.log(spotStore.currentSpot)
return (
<div
className={cn(
@ -198,6 +264,7 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
videoURL={spotStore.currentSpot.videoURL!}
streamFile={spotStore.currentSpot.streamFile}
thumbnail={spotStore.currentSpot.thumbnail}
checkReady={() => spotStore.checkIsProcessed(spotId)}
/>
</div>
{!isFullScreen && spotPlayerStore.activePanel ? (
@ -227,6 +294,9 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) {
panelHeight={panelHeight}
/>
) : null}
{spotPlayerStore.activePanel === PANELS.OVERVIEW ? (
<SpotOverviewConnector />
) : null}
</div>
) : null}
</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) {
const userEmail = state.getIn(['user', 'account', 'name']);
const loggedIn = !!userEmail;

View file

@ -1,15 +1,10 @@
import { DownOutlined, LinkOutlined, StopOutlined } from '@ant-design/icons';
import { Button, Dropdown, Segmented } from 'antd';
import { DownOutlined, CopyOutlined, StopOutlined } from '@ant-design/icons';
import { Button, Dropdown, Menu, Segmented, Modal } from 'antd';
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 { confirm } from 'UI';
import { durationFormatted } from "../../../../date";
const HOUR_SECS = 60 * 60;
const DAY_SECS = 24 * HOUR_SECS;
const WEEK_SECS = 7 * DAY_SECS;
import { formatExpirationTime, HOUR_SECS, DAY_SECS, WEEK_SECS } from 'App/utils/index';
enum Intervals {
hour,
@ -20,9 +15,11 @@ enum Intervals {
function AccessModal() {
const { spotStore } = useStore();
const [isCopied, setIsCopied] = React.useState(false);
const [isPublic, setIsPublic] = React.useState(!!spotStore.pubKey);
const [generated, setGenerated] = React.useState(!!spotStore.pubKey);
const [isCopied, setIsCopied] = useState(false);
const [isPublic, setIsPublic] = useState(!!spotStore.pubKey);
const [generated, setGenerated] = useState(!!spotStore.pubKey);
const [selectedInterval, setSelectedInterval] = useState<Intervals>(Intervals.hour);
const [loadingKey, setLoadingKey] = useState(false);
const expirationValues = {
[Intervals.hour]: HOUR_SECS,
@ -37,68 +34,52 @@ function AccessModal() {
const menuItems = [
{
key: Intervals.hour,
label: <div>One Hour</div>,
key: Intervals.hour.toString(),
label: <div>1 Hour</div>,
},
{
key: Intervals.threeHours,
label: <div>Three Hours</div>,
key: Intervals.threeHours.toString(),
label: <div>3 Hours</div>,
},
{
key: Intervals.day,
label: <div>One Day</div>,
key: Intervals.day.toString(),
label: <div>1 Day</div>,
},
{
key: Intervals.week,
label: <div>One Week</div>,
key: Intervals.week.toString(),
label: <div>1 Week</div>,
},
];
const onMenuClick = ({ key }: { key: Intervals }) => {
const val = expirationValues[key];
if (
spotStore.pubKey?.expiration &&
Math.abs(spotStore.pubKey?.expiration - val) / val < 0.1
) {
return;
}
void spotStore.generateKey(spotId, val);
const onMenuClick = async (info: { key: string }) => {
const val = expirationValues[Number(info.key) as Intervals];
setSelectedInterval(Number(info.key) as Intervals);
await spotStore.generateKey(spotId, val);
};
const changeAccess = async (toPublic: boolean) => {
if (isPublic && !toPublic && spotStore.pubKey) {
if (
await confirm({
header: 'Confirm',
confirmButton: 'Disable',
confirmation:
'Are you sure you want to disable public sharing for this spot?',
})
) {
void spotStore.generateKey(spotId, 0);
}
}
await spotStore.generateKey(spotId, 0);
setIsPublic(toPublic);
} else {
setIsPublic(toPublic);
}
};
const revokeKey = async () => {
if (
await confirm({
header: 'Confirm',
confirmButton: 'Disable',
confirmation:
'Are you sure you want to disable public sharing for this spot?',
})
) {
void spotStore.generateKey(spotId, 0);
await spotStore.generateKey(spotId, 0);
setGenerated(false);
setIsPublic(false);
}
};
const generateInitial = async () => {
setLoadingKey(true);
const k = await spotStore.generateKey(
spotId,
expirationValues[Intervals.hour]
);
setGenerated(!!k);
setLoadingKey(false);
};
const onCopy = () => {
@ -108,12 +89,8 @@ function AccessModal() {
};
return (
<div
className={'flex flex-col gap-4 align-start'}
style={{ width: 420, height: generated ? 240 : 200 }}
>
<div className={'flex flex-col gap-4 align-start w-96 p-1'} >
<div>
<div className={'font-semibold mb-2'}>Who can access this Spot</div>
<Segmented
options={[
{
@ -132,10 +109,10 @@ function AccessModal() {
{!isPublic ? (
<>
<div>
<div className={'text-disabled-text'}>
All team members in your project will able to view this Spot
<div className={'text-black/50'}>
Link for internal team members
</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}
</div>
</div>
@ -143,37 +120,41 @@ function AccessModal() {
<Button
size={'small'}
onClick={onCopy}
type={'text'}
icon={<LinkOutlined />}
type={'default'}
icon={<CopyOutlined />}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</>
) : !generated ? (
<div className={'w-fit'}>
<div className={'w-fit p-1'}>
<Button
loading={spotStore.isLoading}
onClick={generateInitial}
type={'primary'}
ghost
size='small'
className='mt-1'
>
Enable Public Sharing
</Button>
</div>
) : (
<>
<div className='flex flex-col gap-4 px-1'>
<div>
<div className={'text-disabled-text'}>Anyone with following link will be able to view this spot</div>
<div className={'px-2 py-1 border rounded bg-[#FAFAFA] whitespace-nowrap overflow-ellipsis overflow-hidden'}>
<div className={'text-black/50'}>Anyone with the following link can access this Spot</div>
<div className={'px-2 py-1 rounded-lg bg-indigo-50 whitespace-nowrap overflow-ellipsis overflow-hidden'}>
{spotLink}
</div>
</div>
<div className={'flex items-center gap-2'}>
<div>Link expires in</div>
<Dropdown menu={{ items: menuItems, onClick: onMenuClick }}>
<div>
{spotStore.isLoading ? 'Loading' : durationFormatted(spotStore.pubKey!.expiration * 1000)}
<Dropdown overlay={<Menu items={menuItems} onClick={onMenuClick} />}>
<div className='flex items-center cursor-pointer'>
{loadingKey ? 'Loading' : formatExpirationTime(expirationValues[selectedInterval])}
<DownOutlined />
</div>
</Dropdown>
@ -181,23 +162,23 @@ function AccessModal() {
<div className={'flex items-center gap-2'}>
<div className={'w-fit'}>
<Button
type={'primary'}
ghost
type={'default'}
size={'small'}
onClick={onCopy}
icon={<LinkOutlined />}
icon={<CopyOutlined />}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
<Button type={'text'} icon={<StopOutlined />} onClick={revokeKey}>
<Button type={'text'} size='small' icon={<StopOutlined />} onClick={revokeKey}>
Disable Public Sharing
</Button>
</div>
</div>
</>
)}
</div>
);
}
export default AccessModal;
export default observer(AccessModal);

View file

@ -1,79 +1,102 @@
import { CloseOutlined } from '@ant-design/icons';
import { SendOutlined } from '@ant-design/icons';
import { Button, Input, Tooltip } from 'antd';
import cn from 'classnames';
import { X } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { connect } from 'react-redux';
import { toast } from 'react-toastify';
import { resentOrDate } from 'App/date';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { SendOutlined } from '@ant-design/icons';
function CommentsSection({
onClose,
}: {
onClose?: () => void;
}) {
function CommentsSection({ onClose }: { onClose?: () => void }) {
const { spotStore } = useStore();
const comments = spotStore.currentSpot?.comments ?? [];
return (
<div
className={'h-full p-4 bg-white border border-gray-light'}
className={'h-full p-4 bg-white border-l'}
style={{ minWidth: 320, width: 320 }}
>
<div className={'flex items-center justify-between'}>
<div className={'font-semibold'}>Comments</div>
<div onClick={onClose} className={'p-1 cursor-pointer'}>
<X size={16} />
</div>
<div className={'flex items-center justify-between mb-2'}>
<div className={'font-medium text-lg'}>Comments</div>
<Button onClick={onClose} type="text" size="small">
<CloseOutlined />
</Button>
</div>
<div
className={'overflow-y-auto flex flex-col gap-4 mt-2'}
style={{ height: 'calc(100vh - 132px)' }}
>
{comments.map((comment) => (
<div key={comment.createdAt} className={'flex flex-col gap-2'}>
<div
key={comment.createdAt}
className={'flex flex-col gap-2 border-b border-dotted pb-2'}
>
<div className={'flex items-center gap-2'}>
<div
className={
'w-8 h-8 bg-tealx rounded-full flex items-center justify-center color-white uppercase'
'w-9 h-9 text-xs bg-tealx rounded-full flex items-center justify-center color-white uppercase'
}
>
{comment.user[0]}
</div>
<div className={'font-semibold'}>{comment.user}</div>
</div>
<div>{comment.text}</div>
<div className={'text-disabled-text'}>
<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>{comment.text}</div>
</div>
))}
<BottomSectionContainer disableComments={comments.length > 5} />
<BottomSectionContainer
unloggedLimit={comments.length > 5}
loggedLimit={comments.length > 25}
/>
</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 [userName, setUserName] = React.useState<string>(userEmail ?? '');
const { spotStore } = useStore();
const addComment = async () => {
try {
await spotStore.addComment(
spotStore.currentSpot!.spotId,
commentText,
userName
);
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 (
<div
className={cn(
'rounded-xl border p-4 mt-auto',
'mt-auto border-t p-2',
loggedIn ? 'bg-white' : 'bg-active-dark-blue'
)}
>
@ -84,7 +107,7 @@ function BottomSection({ loggedIn, userEmail, disableComments }: { disableCommen
disabled={loggedIn}
placeholder={'Add a name'}
required
className={'w-full'}
className={'w-full disabled:hidden'}
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
@ -94,16 +117,27 @@ function BottomSection({ loggedIn, userEmail, disableComments }: { disableCommen
autoSize={{ minRows: 3, maxRows: 3 }}
maxLength={120}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onChange={(e) => {
e.preventDefault();
e.stopPropagation();
setCommentText(e.target.value);
}}
placeholder="Add a comment..."
/>
</div>
<Tooltip title={!disableComments ? "" : "Limited to 5 Messages. Join team to send more."}>
<Tooltip
title={
!disableSubmit
? ''
: unlogged ? 'Limited to 5 Messages. Join team to send more.' : 'Limited to 25 Messages.'
}
>
<Button
type={'primary'}
onClick={addComment}
disabled={disableSubmit}
icon={<SendOutlined />}
shape={"circle"}
icon={<SendOutlined className="ps-0.5" />}
shape={'circle'}
/>
</Tooltip>
</div>

View file

@ -16,8 +16,10 @@ import spotPlayerStore from '../../spotPlayerStore';
function SpotConsole({ onClose }: { onClose: () => void }) {
const [activeTab, setActiveTab] = React.useState(TABS[0]);
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 filteredList = React.useMemo(() => {
@ -29,7 +31,7 @@ function SpotConsole({ onClose }: { onClose: () => void }) {
}, [activeTab]);
const jump = (t: number) => {
spotPlayerStore.setTime(t);
spotPlayerStore.setTime(t / 1000);
};
return (
@ -48,7 +50,7 @@ function SpotConsole({ onClose }: { onClose: () => void }) {
<BottomBlock.Content className={'overflow-y-auto'}>
<NoContent
title={
<div className="capitalize flex items-center mt-16">
<div className="capitalize flex items-center">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>

View file

@ -26,7 +26,7 @@ function SpotNetwork({ panelHeight, onClose }: { panelHeight: number, onClose: (
websocketListNow={[]}
/* @ts-ignore */
player={{ jump: (t) => spotPlayerStore.setTime(t) }}
activeIndex={index}
activeOutsideIndex={index}
onClose={onClose}
/>
);

View file

@ -1,5 +1,6 @@
import { CloseOutlined } from '@ant-design/icons';
import { TYPES } from 'Types/session/event';
import { X } from 'lucide-react';
import { Button } from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
@ -28,14 +29,14 @@ function SpotActivity({ onClose }: { onClose: () => void }) {
};
return (
<div
className={'h-full bg-white border border-gray-light'}
className={'h-full bg-white border-l'}
style={{ minWidth: 320, width: 320 }}
>
<div className={'flex items-center justify-between p-4'}>
<div className={'font-semibold'}>Activity</div>
<div onClick={onClose} className={'p-1 cursor-pointer'}>
<X size={16} />
</div>
<div className={'font-medium text-lg'}>Activity</div>
<Button type="text" size="small" onClick={onClose}>
<CloseOutlined />
</Button>
</div>
<div
className={'overflow-y-auto'}

View file

@ -2,19 +2,21 @@ import { observer } from 'mobx-react-lite';
import React from 'react';
import { Tooltip } from 'antd'
import { Icon } from 'UI';
import {Link2} from 'lucide-react';
import spotPlayerStore from '../spotPlayerStore';
function SpotLocation() {
const currUrl = spotPlayerStore.getClosestLocation(
spotPlayerStore.time
)?.location;
const displayUrl = currUrl.length > 170 ? `${currUrl.slice(0, 170)}...` : currUrl;
return (
<div className={'w-full bg-white border-b border-gray-lighter'}>
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
<Icon size="20" name="event/link" className="mr-1" />
<Tooltip title="Open in new tab">
<Link2 className="mx-2" size={16} />
<Tooltip title="Open in new tab" placement='bottom'>
<a href={currUrl} target="_blank" className="truncate">
{currUrl}
{displayUrl}
</a>
</Tooltip>
</div>

View file

@ -94,6 +94,11 @@ function SpotPlayerControls() {
<div className={'ml-auto'} />
<ControlButton
label={'X-Ray'}
onClick={() => togglePanel(PANELS.OVERVIEW)}
active={spotPlayerStore.activePanel === PANELS.OVERVIEW}
/>
<ControlButton
label={'Console'}
onClick={() => togglePanel(PANELS.CONSOLE)}

View file

@ -1,23 +1,24 @@
import {
ArrowLeftOutlined,
CommentOutlined,
LinkOutlined,
SettingOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button, Popover } from 'antd';
import { ArrowLeftOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined, MoreOutlined, SettingOutlined, UserSwitchOutlined } from '@ant-design/icons';
import { Badge, Button, Dropdown, MenuProps, Popover, Tooltip, message } from 'antd';
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 { Link } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { useStore } from 'App/mstore';
import { spotsList } from 'App/routes';
import { hashString } from 'App/types/session/session';
import { Avatar, Icon } from 'UI';
import { TABS, Tab } from '../consts';
import AccessModal from './AccessModal';
const spotLink = spotsList();
function SpotPlayerHeader({
@ -43,53 +44,96 @@ function SpotPlayerHeader({
platform: string | null;
hasShareAccess: boolean;
}) {
const [isCopied, setIsCopied] = React.useState(false);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const { spotStore } = useStore();
const comments = spotStore.currentSpot?.comments ?? [];
const [dropdownOpen, setDropdownOpen] = useState(false);
const history = useHistory();
const onCopy = () => {
setIsCopied(true);
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 (
<div
className={
'flex items-center gap-4 p-4 w-full bg-white border-b border-gray-light'
}
className={'flex items-center gap-1 p-2 py-1 w-full bg-white border-b'}
>
<div>
{isLoggedIn ? (
<Link to={spotLink}>
<div className={'flex items-center gap-2'}>
<ArrowLeftOutlined />
<div className={'font-semibold'}>All Spots</div>
</div>
</Link>
<Button
type="text"
onClick={navigateToSpotsList}
icon={<ArrowLeftOutlined />}
className="px-2"
>
All Spots
</Button>
) : (
<>
<div className={'flex items-center gap-2'}>
<Icon name={'orSpot'} size={24} />
<a href="https://openreplay.com/spot" target="_blank">
<Button
type="text"
className="orSpotBranding flex gap-1 items-center py-2"
>
<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 className={'text-disabled-text text-xs'}>by OpenReplay</div>
</div>
</Button>
</a>
</>
)}
</div>
<div
className={'h-full rounded-xl bg-gray-light mx-2'}
style={{ width: 1 }}
/>
<div className={'h-full rounded-xl border-l mr-2'} style={{ width: 1 }} />
<div className={'flex items-center gap-2'}>
<Avatar seed={hashString(user)} />
<div>
<div>{title}</div>
<div className={'flex items-center gap-2 text-disabled-text'}>
<Tooltip title={title}>
<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>·</div>
<div>{date}</div>
<div className="capitalize">{date}</div>
{browserVersion && (
<>
<div>·</div>
<div>Chrome v{browserVersion}</div>
<div className="capitalize">Chrome v{browserVersion}</div>
</>
)}
{resolution && (
@ -101,7 +145,7 @@ function SpotPlayerHeader({
{platform && (
<>
<div>·</div>
<div>{platform}</div>
<div className="capitalize">{platform}</div>
</>
)}
</div>
@ -113,24 +157,30 @@ function SpotPlayerHeader({
<Button
size={'small'}
onClick={onCopy}
type={'primary'}
icon={<LinkOutlined />}
type={'default'}
icon={<CopyOutlined />}
>
{isCopied ? 'Copied!' : 'Copy Link'}
Copy
</Button>
{hasShareAccess ? (
<Popover open={dropdownOpen} content={<AccessModal />}>
<Popover trigger={'click'} content={<AccessModal />}>
<Button
size={'small'}
onClick={() => setDropdownOpen(!dropdownOpen)}
icon={<SettingOutlined />}
onClick={() => setDropdownOpen(!dropdownOpen)}
>
Manage Access
</Button>
</Popover>
) : null}
<Dropdown
menu={{ items, onClick: onMenuClick }}
placement="bottomRight"
>
<Button icon={<MoreOutlined />} size={'small'}></Button>
</Dropdown>
<div
className={'h-full rounded-xl bg-gray-light mx-2'}
className={'h-full rounded-xl border-l mx-2'}
style={{ width: 1 }}
/>
</>
@ -143,6 +193,7 @@ function SpotPlayerHeader({
>
Activity
</Button>
<Button
size={'small'}
disabled={activeTab === TABS.COMMENTS}
@ -150,11 +201,36 @@ function SpotPlayerHeader({
icon={<CommentOutlined />}
>
Comments
{comments.length > 0 && (
<Badge count={comments.length} className="mr-2" style={{ fontSize: '10px' }} size='small' color='#454545' />
)}
</Button>
</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) => {
const jwt = state.getIn(['user', 'jwt']);
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;
return { isLoggedIn: !!jwt, hasShareAccess };
})(SpotPlayerHeader);
})(observer(SpotPlayerHeader));

View file

@ -1,3 +1,4 @@
import Hls from 'hls.js';
import { observer } from 'mobx-react-lite';
import React from 'react';
@ -19,20 +20,52 @@ function SpotVideoContainer({
videoURL,
streamFile,
thumbnail,
checkReady,
}: {
videoURL: string;
streamFile?: string;
thumbnail?: string;
checkReady: () => Promise<boolean>;
}) {
const [videoLink, setVideoLink] = React.useState<string>(videoURL);
const { spotStore } = useStore();
const [prevIsProcessing, setPrevIsProcessing] = React.useState(false);
const [isProcessing, setIsProcessing] = React.useState(false);
const [isLoaded, setLoaded] = React.useState(false);
const videoRef = React.useRef<HTMLVideoElement>(null);
const playbackTime = React.useRef(0);
const hlsRef = React.useRef<Hls | null>(null);
React.useEffect(() => {
const startPlaying = () => {
if (spotPlayerStore.isPlaying && videoRef.current) {
videoRef.current
.play()
.then(() => {
console.debug('playing');
})
.catch((e) => {
console.error(e);
spotPlayerStore.setIsPlaying(false);
const onClick = () => {
spotPlayerStore.setIsPlaying(true);
document.removeEventListener('click', onClick);
};
document.addEventListener('click', onClick);
});
}
};
checkReady().then((isReady) => {
if (!isReady) {
setIsProcessing(true);
setPrevIsProcessing(true);
const int = setInterval(() => {
checkReady().then((r) => {
if (r) {
setIsProcessing(false);
clearInterval(int);
}
});
}, 5000)
}
import('hls.js').then(({ default: Hls }) => {
if (Hls.isSupported() && videoRef.current) {
videoRef.current.addEventListener('loadeddata', () => {
@ -40,30 +73,26 @@ function SpotVideoContainer({
});
if (streamFile) {
const hls = new Hls({
// not needed for small videos (we have 3 min limit and 720 quality with half kbps)
enableWorker: false,
// workerPath: '/hls-worker.js',
// 1MB buffer -- we have small videos anyways
// = 1MB, should be enough
maxBufferSize: 1000 * 1000,
});
const url = URL.createObjectURL(base64toblob(streamFile));
if (url && videoRef.current) {
hls.loadSource(url);
hls.attachMedia(videoRef.current);
if (spotPlayerStore.isPlaying) {
void videoRef.current.play();
}
startPlaying();
hlsRef.current = hls;
} else {
if (videoRef.current) {
videoRef.current.src = videoURL;
if (spotPlayerStore.isPlaying) {
void videoRef.current.play();
}
startPlaying();
}
}
} else {
const check = () => {
fetch(videoLink).then((r) => {
fetch(videoURL).then((r) => {
if (r.ok && r.status === 200) {
if (videoRef.current) {
videoRef.current.src = '';
@ -82,9 +111,7 @@ function SpotVideoContainer({
};
check();
videoRef.current.src = videoURL;
if (spotPlayerStore.isPlaying) {
void videoRef.current.play();
}
startPlaying();
}
} else {
if (videoRef.current) {
@ -92,12 +119,11 @@ function SpotVideoContainer({
setLoaded(true);
});
videoRef.current.src = videoURL;
if (spotPlayerStore.isPlaying) {
void videoRef.current.play();
}
startPlaying();
}
}
});
});
return () => {
hlsRef.current?.destroy();
};
@ -143,29 +169,46 @@ function SpotVideoContainer({
videoRef.current.playbackRate = spotPlayerStore.playbackRate;
}
}, [spotPlayerStore.playbackRate]);
const warnText = isProcessing ? 'Youre viewing the entire recording. The trimmed Spot is on its way.' : 'Your trimmed Spot is ready! Please reload the page.'
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
ref={videoRef}
poster={thumbnail}
autoPlay
className={
'object-contain absolute top-0 left-0 w-full h-full bg-gray-lightest cursor-pointer'
}
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>
)}
</>
);
}

View file

@ -70,6 +70,7 @@ const mapSpotNetworkToEv = (ev: SpotNetworkRequest): any => {
export const PANELS = {
CONSOLE: 'CONSOLE',
NETWORK: 'NETWORK',
OVERVIEW: 'OVERVIEW',
} as const;
export type PanelType = keyof typeof PANELS;
@ -78,13 +79,13 @@ class SpotPlayerStore {
time = 0;
duration = 0;
durationString = '';
isPlaying = false;
state = PlayingState.Paused
isPlaying = true;
state = PlayingState.Playing;
isMuted = false;
volume = 1;
playbackRate = 1;
isFullScreen = false;
logs: typeof PLog[] = [];
logs: ReturnType<typeof PLog>[] = [];
locations: Location[] = [];
clicks: Click[] = [];
network: ReturnType<typeof getResourceFromNetworkRequest>[] = [];
@ -103,7 +104,8 @@ class SpotPlayerStore {
this.time = 0;
this.duration = 0;
this.durationString = '';
this.isPlaying = false;
this.isPlaying = true;
this.state = PlayingState.Playing;
this.isMuted = false;
this.volume = 1;
this.playbackRate = 1;
@ -188,9 +190,9 @@ class SpotPlayerStore {
this.locations = locations.map((location) => ({
...location,
time: location.time - this.startTs,
fcpTime: location.navTiming.fcpTime,
timeToInteractive: location.navTiming.timeToInteractive,
visuallyComplete: location.navTiming.visuallyComplete,
fcpTime: location.navTiming.fcpTime ? Math.round(location.navTiming.fcpTime) : null,
timeToInteractive: location.navTiming.timeToInteractive ? Math.round(location.navTiming.timeToInteractive) : null,
visuallyComplete: location.navTiming.visuallyComplete ? Math.round(location.navTiming.visuallyComplete) : null,
}));
this.clicks = clicks.map((click) => ({

View 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 havent 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;

View file

@ -1,19 +1,28 @@
import { CopyOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, GlobalOutlined, MessageOutlined, MoreOutlined, SlackOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown } from 'antd';
import {
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 React from 'react';
import { Link2 } from 'lucide-react';
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Spot } from 'App/mstore/types/spot';
import { spot as spotUrl, withSiteId } from 'App/routes';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import EditItemModal from './EditItemModal';
import EditItemModal from "./EditItemModal";
const backgroundUrl = '/assets/img/spotThumbBg.svg';
interface ISpotListItem {
spot: Spot;
@ -21,12 +30,23 @@ interface ISpotListItem {
onDelete: () => void;
onVideo: (id: string) => Promise<{ url: string }>;
onSelect: (selected: boolean) => void;
isSelected: boolean;
}
function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotListItem) {
const [isEdit, setIsEdit] = React.useState(false)
function SpotListItem({
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 { siteId } = useParams<{ siteId: string }>();
const menuItems = [
{
key: 'rename',
@ -38,17 +58,13 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
label: 'Download Video',
icon: <DownloadOutlined />,
},
{
key: 'copy',
label: 'Copy Spot URL',
icon: <CopyOutlined />,
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: 'Delete',
},
];
React.useEffect(() => {
menuItems.splice(1, 0, {
key: 'slack',
@ -59,13 +75,18 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
const onMenuClick = async ({ key }: any) => {
switch (key) {
case 'rename':
return setIsEdit(true)
return setIsEdit(true);
case 'download':
const { url } = await onVideo(spot.spotId)
await downloadFile(url, `${spot.title}.webm`)
const { url } = await onVideo(spot.spotId);
await downloadFile(url, `${spot.title}.webm`);
return;
case 'copy':
copy(`${window.location.origin}${withSiteId(spotUrl(spot.spotId.toString()), siteId)}`);
copy(
`${window.location.origin}${withSiteId(
spotUrl(spot.spotId.toString()),
siteId
)}`
);
return toast.success('Spot URL copied to clipboard');
case 'delete':
return onDelete();
@ -75,6 +96,7 @@ function SpotListItem({ spot, onRename, onDelete, onVideo, onSelect }: ISpotList
break;
}
};
const onSpotClick = (e: any) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
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) => {
onRename(spot.spotId, newName);
setIsEdit(false);
}
};
return (
<div
className={
'border rounded-xl overflow-hidden flex flex-col items-start hover:shadow'
}
className={`bg-white rounded-lg overflow-hidden shadow-sm border ${
isSelected ? 'border-teal/30' : 'border-transparent'
} transition flex flex-col items-start hover:border-teal`}
>
{isEdit ? (
<EditItemModal onSave={onSave} onClose={() => setIsEdit(false)} itemName={spot.title} />
<EditItemModal
onSave={onSave}
onClose={() => setIsEdit(false)}
itemName={spot.title}
/>
) : null}
<div style={{ cursor: 'pointer', width: '100%', height: 180, position: 'relative' }} onClick={onSpotClick}>
<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
className="block w-full h-full cursor-pointer transition hover:bg-teal/70 relative"
onClick={onSpotClick}
>
<img
src={spot.thumbnail}
alt={spot.title}
className={'w-full h-full object-cover'}
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={
'absolute bottom-4 right-4 bg-black text-white p-1 rounded'
'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 className={'px-2 py-4 w-full'}>
</div>
<div className={'px-4 py-4 w-full border-t'}>
<div className={'flex items-center gap-2'}>
<div>
<Checkbox onChange={({ target: { checked }}) => onSelect(checked)} />
</div>
<div className={'cursor-pointer'} onClick={onSpotClick}>{spot.title}</div>
</div>
<div
className={'flex items-center gap-2 text-disabled-text leading-4'}
style={{ fontSize: 12 }}
<Checkbox
checked={isSelected}
onChange={({ target: { checked } }) => onSelect(checked)}
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 className={'flex items-center gap-1 leading-4 text-xs opacity-50'}>
<div>
<GlobalOutlined />
<UserOutlined />
</div>
<div>{spot.user}</div>
<div>
<MessageOutlined />
<div className="ms-4">
<ClockCircleOutlined />
</div>
<div>{spot.createdAt}</div>
<div className={'ml-auto'}>

View 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;

View file

@ -1,83 +1,22 @@
import { DownOutlined } from '@ant-design/icons';
import { Button, Dropdown, Input } from 'antd';
import { Pin, Puzzle, Share2 } from 'lucide-react';
import { message } from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
import withPageTitle from 'HOCs/withPageTitle';
import withPermissions from 'App/components/hocs/withPermissions';
import { useStore } from 'App/mstore';
import { numberWithCommas } from 'App/utils';
import { Icon, Loader, Pagination } from "UI";
import withPermissions from "../../hocs/withPermissions";
import { Loader, NoContent, Pagination } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import EmptyPage from './EmptyPage';
import SpotListItem from './SpotListItem';
const visibilityOptions = {
all: 'All Spots',
own: 'My Spots',
} as const;
function SpotsListHeader({
disableButton,
onDelete,
}: {
disableButton: boolean;
onDelete: () => void;
}) {
const dropdownProps = {
items: [
{
label: 'All Spots',
key: 'all',
},
{
label: 'My Spots',
key: 'own',
},
],
onClick: ({ key }: any) => onFilterChange(key),
};
const { spotStore } = useStore();
const onSearch = (value: string) => {
spotStore.setQuery(value);
void spotStore.fetchSpots();
};
const onFilterChange = (key: 'all' | 'own') => {
spotStore.setFilter(key);
void spotStore.fetchSpots();
};
return (
<div className={'flex items-center px-4 gap-4 pb-4'}>
<Icon name={'orSpot'} size={24} />
<div className={'text-2xl capitalize mr-2'}>Spots</div>
<div className={'ml-auto'}>
<Button size={'small'} disabled={disableButton} onClick={onDelete}>
Delete Selected
</Button>
</div>
<Dropdown menu={dropdownProps}>
<div className={'cursor-pointer flex items-center justify-end gap-2'}>
<div>{visibilityOptions[spotStore.filter]}</div>
<DownOutlined />
</div>
</Dropdown>
<div style={{ width: 210 }}>
<Input.Search
value={spotStore.query}
allowClear
name="spot-search"
placeholder="Filter by title"
onChange={(e) => spotStore.setQuery(e.target.value)}
onSearch={(value) => onSearch(value)}
/>
</div>
</div>
);
}
import SpotsListHeader from './SpotsListHeader';
function SpotsList() {
const [selectedSpots, setSelectedSpots] = React.useState<string[]>([]);
const [isEmptyState, setIsEmpty] = React.useState(false);
const { spotStore } = useStore();
React.useEffect(() => {
@ -89,13 +28,27 @@ function SpotsList() {
void spotStore.fetchSpots();
};
const onDelete = (spotId: string) => {
void spotStore.deleteSpot([spotId]);
const onDelete = async (spotId: string) => {
await spotStore.deleteSpot([spotId]);
setSelectedSpots(selectedSpots.filter((s) => s !== spotId));
};
const batchDelete = () => {
void spotStore.deleteSpot(selectedSpots);
const batchDelete = async () => {
const deletedCount = selectedSpots.length;
await spotStore.deleteSpot(selectedSpots);
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) => {
@ -106,46 +59,79 @@ function SpotsList() {
return spotStore.getVideo(id);
};
return (
<div className={'w-full'}>
<div
className={'mx-auto bg-white rounded border py-4'}
style={{ maxWidth: 1360 }}
>
<SpotsListHeader
disableButton={selectedSpots.length === 0}
onDelete={batchDelete}
/>
const handleSelectSpot = (spotId: string, isSelected: boolean) => {
if (isSelected) {
setSelectedSpots((prev) => [...prev, spotId]);
} else {
setSelectedSpots((prev) => prev.filter((id) => id !== spotId));
}
};
{spotStore.total === 0 ? (
spotStore.isLoading ? <Loader /> : <EmptyPage />
) : (
<>
const isSpotSelected = (spotId: string) => selectedSpots.includes(spotId);
const clearSelection = () => {
setSelectedSpots([]);
};
const isLoading = spotStore.isLoading;
const isEmpty = isEmptyState || spotStore.total === 0 && spotStore.query === ''
return (
<div className={'relative w-full mx-auto'} style={{ maxWidth: 1360 }}>
<div
className={
'py-2 px-0.5 border-t border-b border-gray-lighter grid grid-cols-3 gap-2'
'flex mx-auto p-2 px-4 bg-white rounded-t-lg shadow-sm w-full z-50 border-b'
}
>
{spotStore.spots.map((spot, index) => (
<SpotsListHeader
onDelete={batchDelete}
selectedCount={selectedSpots.length}
onClearSelection={clearSelection}
isEmpty={isEmpty}
isEmptyState={isEmptyState}
toggleEmptyState={() => setIsEmpty(!isEmptyState)}
/>
</div>
<div className={'pb-4 w-full'}>
{isEmpty ? (
isLoading ? (
<Loader />
) : (
<EmptyPage />
)
) : (
<>
<NoContent
className="w-full bg-white rounded-lg shadow-sm"
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>
}
>
<div
className={'py-2 border-gray-lighter grid grid-cols-3 gap-6'}
>
{spotStore.spots.map((spot) => (
<SpotListItem
key={index}
key={spot.spotId}
spot={spot}
onDelete={() => onDelete(spot.spotId)}
onRename={onRename}
onVideo={onVideo}
onSelect={(checked: boolean) => {
if (checked) {
setSelectedSpots([...selectedSpots, spot.spotId]);
} else {
setSelectedSpots(
selectedSpots.filter((s) => s !== spot.spotId)
);
onSelect={(checked: boolean) =>
handleSelectSpot(spot.spotId, checked)
}
}}
isSelected={isSpotSelected(spot.spotId)}
/>
))}
</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>
Showing{' '}
<span className="font-medium">
@ -177,65 +163,5 @@ function SpotsList() {
);
}
function EmptyPage() {
return (
<div className={'flex flex-col gap-4 items-center w-full border-t pt-2'}>
<div className={'font-semibold text-xl'}>Spot your first bug</div>
<div className={'text-disabled-text w-1/2'}>
Spot is a browser extension by OpenReplay, that captures detailed bug
reports including screen recordings and technical details that
developers need to troubleshoot an issue efficiently.
</div>
export default withPermissions(['SPOT'])(withPageTitle('Spot List - OpenReplay')(observer(SpotsList)));
<div className={'flex gap-4 mt-4'}>
<img src={'assets/img/spot1.jpg'} alt={'pin spot'} width={200} />
<div className={'flex flex-col gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={
'-ml-2 h-8 w-8 bg-[#FFF7E6] rounded-full flex items-center justify-center'
}
>
<span>1</span>
</div>
<div className={'font-semibold'}>Pin Spot extension (Optional)</div>
</div>
<div className={'flex items-center gap-2'}>
<Puzzle size={16} strokeWidth={1} />
<div>Open installed extensions</div>
</div>
<div className={'flex items-center gap-2'}>
<Pin size={16} strokeWidth={1} />
<div>Pin Spot, for easy access.</div>
</div>
</div>
</div>
<div className={'flex gap-4 mt-4'}>
<img src={'assets/img/spot2.jpg'} alt={'start recording'} width={200} />
<div className={'flex flex-col gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={
'-ml-2 h-8 w-8 bg-[#FFF7E6] rounded-full flex items-center justify-center'
}
>
<span>2</span>
</div>
<div className={'font-semibold'}>Capture and share a bug</div>
</div>
<div className={'flex items-center gap-2'}>
<Icon name={'orSpot'} size={16} />
<div>Click the Spot icon to log bugs!</div>
</div>
<div className={'flex items-center gap-2'}>
<Share2 size={16} strokeWidth={1} />
<div>Share it with your team</div>
</div>
</div>
</div>
</div>
);
}
export default withPermissions(['SPOT'])(observer(SpotsList))

View file

@ -1,8 +1,11 @@
import { Tooltip } from 'antd';
import cn from 'classnames';
import React from 'react';
import { connect } from 'react-redux';
import cn from 'classnames';
import { closeBottomBlock } from 'Duck/components/player';
import { CloseButton } from 'UI';
import stl from './header.module.css';
const Header = ({
@ -21,10 +24,23 @@ const Header = ({
showClose?: boolean;
onClose?: () => void;
}) => (
<div className={ cn("relative border-r border-l py-1", stl.header) } >
<div className={ cn("w-full h-full flex justify-between items-center", className) } >
<div className="w-full flex items-center justify-between">{ children }</div>
{ showClose && <CloseButton onClick={ onClose ? onClose : closeBottomBlock } size="18" className="ml-2" /> }
<div className={cn('relative border-r border-l py-1', stl.header)}>
<div
className={cn(
'w-full h-full flex justify-between items-center',
className
)}
>
<div className="w-full flex items-center justify-between">{children}</div>
{showClose && (
<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>
);

View file

@ -30,15 +30,34 @@ const LEVEL_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 = '') {
if (typeof s !== 'string') return '';
return s.split('\n').map((line, i) => (
return s.split('\n').map((line, i) => {
const parts = line.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 (
<div key={i + line.slice(0, 6)} className={cn({ 'ml-20': i !== 0 })}>
{line}
{formattedLine}
</div>
));
);
});
}
export const getIconProps = (level: LogLevel) => {
switch (level) {
case LogLevel.INFO:

View file

@ -22,6 +22,25 @@ function ConsoleRow(props: Props) {
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 (
<div
style={style}
@ -46,7 +65,7 @@ function ConsoleRow(props: Props) {
<Icon name={expanded ? 'caret-down-fill' : 'caret-right-fill'} className="mr-2" />
)}
<span className='font-mono '>
{renderWithNL(lines.pop())}
{renderWithNL(titleLine)}
</span>
</div>
{log.errorId &&
@ -56,9 +75,9 @@ function ConsoleRow(props: Props) {
</div>
{canExpand &&
expanded &&
lines.map((l: string, i: number) => (
<div key={l.slice(0, 4) + i} className="ml-4 mb-1" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>
{l}
restLines.map((l: string, i: number) => (
<div key={l.slice(0, 4) + i} className="ml-4 mb-1 text-xs" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>
{renderLine(l)}
</div>
))}
</div>

View file

@ -13,7 +13,7 @@ function JumpButton(props: Props) {
<div className="absolute right-2 top-0 bottom-0 my-auto flex items-center">
<Tooltip title={tooltip} disabled={!tooltip}>
<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) => {
e.stopPropagation();
props.onClick();

View file

@ -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 { formatBytes } from 'App/utils';
import { formatMs } from 'App/date';
import { useModal } from 'App/components/Modal';
import FetchDetailsModal from 'Shared/FetchDetailsModal';
import { MobilePlayerContext, PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from 'App/mstore';
import MobilePlayer from 'Player/mobile/IOSPlayer';
import WebPlayer from 'Player/web/WebPlayer';
import { Duration } from 'luxon';
import { observer } from 'mobx-react-lite';
import React, { useMemo, useState } from 'react';
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 InfoLine from '../BottomBlock/InfoLine';
import TimeTable from '../TimeTable';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import WSModal from './WSModal';
@ -96,7 +101,9 @@ function renderSize(r: any) {
content = (
<ul>
{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>
</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 (
<>
{cached ? (
<Tooltip title={'Served from cache'}>
<div className="flex items-center">
<span className="mr-1">{status}</span>
<span className="mr-1">{displayedStatus}</span>
<Icon name="wifi" size={16} />
</div>
</Tooltip>
) : (
status
displayedStatus
)}
</>
);
@ -165,7 +192,13 @@ function NetworkPanelCont({
}) {
const { player, store } = React.useContext(PlayerContext);
const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab } = store.get();
const {
domContentLoadedTime,
loadTime,
domBuildingTime,
tabStates,
currentTab,
} = store.get();
const {
fetchList = [],
resourceList = [],
@ -276,6 +309,7 @@ interface Props {
zoomEndTs?: number;
panelHeight: number;
onClose?: () => void;
activeOutsideIndex?: number;
}
export const NetworkPanelComp = observer(
@ -296,6 +330,7 @@ export const NetworkPanelComp = observer(
zoomStartTs,
zoomEndTs,
onClose,
activeOutsideIndex,
}: Props) => {
const { showModal } = useModal();
const [sortBy, setSortBy] = useState('time');
@ -308,12 +343,13 @@ export const NetworkPanelComp = observer(
} = useStore();
const filter = devTools[INDEX_KEY].filter;
const activeTab = devTools[INDEX_KEY].activeTab;
const activeIndex = devTools[INDEX_KEY].index;
const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index;
const socketList = useMemo(
() =>
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]
);
@ -362,7 +398,11 @@ export const NetworkPanelComp = observer(
transferredBodySize: 0,
}))
)
.filter((req) => (zoomEnabled ? req.time >= zoomStartTs! && req.time <= zoomEndTs! : true))
.filter((req) =>
zoomEnabled
? req.time >= zoomStartTs! && req.time <= zoomEndTs!
: true
)
.sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length, socketList]
);
@ -371,18 +411,27 @@ export const NetworkPanelComp = observer(
if (!showOnlyErrors) {
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]);
filteredList = useRegExListFilterMemo(
filteredList,
(it) => [it.status, it.name, it.type, it.method],
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]) =>
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 });
// AutoScroll
@ -401,7 +450,11 @@ export const NetworkPanelComp = observer(
};
const resourcesSize = useMemo(
() => resourceList.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0),
() =>
resourceList.reduce(
(sum, { decodedBodySize }) => sum + (decodedBodySize || 0),
0
),
[resourceList.length]
);
const transferredSize = useMemo(
@ -435,7 +488,9 @@ export const NetworkPanelComp = observer(
const showDetailsModal = (item: any) => {
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} />, {
right: true,
@ -472,7 +527,9 @@ export const NetworkPanelComp = observer(
>
<BottomBlock.Header onClose={onClose}>
<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">
Network
</span>
{isMobile ? null : (
<Tabs
className="uppercase"
@ -495,7 +552,7 @@ export const NetworkPanelComp = observer(
/>
</BottomBlock.Header>
<BottomBlock.Content>
<div className="flex items-center justify-between px-4">
<div className="flex items-center justify-between px-4 border-b bg-teal/5 h-8">
<div>
<Toggler
checked={showOnlyErrors}
@ -505,7 +562,10 @@ export const NetworkPanelComp = observer(
/>
</div>
<InfoLine>
<InfoLine.Point label={filteredList.length + ''} value=" requests" />
<InfoLine.Point
label={filteredList.length + ''}
value=" requests"
/>
<InfoLine.Point
label={formatBytes(transferredSize)}
value="transferred"
@ -522,7 +582,9 @@ export const NetworkPanelComp = observer(
display={domBuildingTime != null}
/>
<InfoLine.Point
label={domContentLoadedTime && formatMs(domContentLoadedTime.value)}
label={
domContentLoadedTime && formatMs(domContentLoadedTime.value)
}
value="DOMContentLoaded"
display={domContentLoadedTime != null}
dotColor={DOM_LOADED_TIME_COLOR}
@ -537,7 +599,7 @@ export const NetworkPanelComp = observer(
</div>
<NoContent
title={
<div className="capitalize flex items-center mt-16">
<div className="capitalize flex items-center">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>
@ -555,7 +617,9 @@ export const NetworkPanelComp = observer(
sortBy={sortBy}
sortAscending={sortAscending}
onJump={(row: any) => {
devTools.update(INDEX_KEY, { index: filteredList.indexOf(row) });
devTools.update(INDEX_KEY, {
index: filteredList.indexOf(row),
});
player.jump(row.time);
}}
activeIndex={activeIndex}

View file

@ -215,7 +215,7 @@ export default class TimeTable extends React.PureComponent<Props, State> {
{columns
.filter((i: any) => !i.hidden)
.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(row)
: row[dataKey || ''] || <i className="color-gray-light">{'empty'}</i>}
@ -338,7 +338,7 @@ export default class TimeTable extends React.PureComponent<Props, State> {
</div>
<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` }}>
{timeColumns.map((_, index) => (
<div key={`tc-${index}`} className={stl.timeCell} />

View file

@ -41,7 +41,7 @@ function LiveSessionList(props: Props) {
var timeoutId: any;
const { filters } = filter;
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
.map((i: any) => ({
label: capitalize(i),

View file

@ -19,7 +19,7 @@ export default React.memo(function SortOrderButton(props: Props) {
{ label: 'Ascending', value: 'Ascending', icon: <ArrowUpOutlined /> },
{ label: 'Descending', value: 'Descending', icon: <ArrowDownOutlined /> },
]}
defaultValue="Descending"
defaultValue="Ascending"
onChange={(value) => {
if (value === 'Ascending') {
onChange('asc');

View file

@ -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"/>
</g>
<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">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<filter id="filter0_i_450_13811" x="5.44946" y="3.64941" width="24.7933" height="26.5266" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<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"/>
<feOffset dx="-4" dy="-4"/>

View file

@ -12,7 +12,7 @@ interface Props {
function Dashboard_icn(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
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>
);
}

View file

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

View file

@ -12,14 +12,19 @@ interface Props {
children?: any;
image?: any;
style?: any;
className?: string;
}
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 ? (
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} />}
{title && <div className='flex'>{title}</div>}
{subtext && <div className={styles.subtext}>{subtext}</div>}

View file

@ -11,3 +11,4 @@ export const MOUSE_TRAIL = "__$session-mouseTrail$__"
export const IFRAME = "__$session-iframe$__"
export const JWT_PARAM = "__$session-jwt-param$__"
export const MENU_COLLAPSED = "__$global-menuCollapsed$__"
export const SPOT_ONBOARDING = "__$spot-onboarding$__"

View file

@ -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 Client from 'Types/client';
import { List, Map } from 'immutable';
import { deleteCookie } from 'App/utils';
import withRequestState, { RequestTypes } from './requestStateCreator';
export const LOGIN = new RequestTypes('user/LOGIN');
export const SIGNUP = new RequestTypes('user/SIGNUP');
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');
const FETCH_TENANTS = new RequestTypes('user/FETCH_TENANTS');
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');
export const UPDATE_PASSWORD = new RequestTypes('user/UPDATE_PASSWORD');
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 SET_ONBOARDING = 'user/SET_ONBOARDING';
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({
account: Account(),
@ -31,11 +39,14 @@ export const initialState = Map({
onboarding: false,
sites: List(),
jwt: null,
spotJwt: null,
errors: List(),
loginRequest: {
loading: false,
errors: []
}
errors: [],
},
scope: null,
scopeSetup: false,
});
const setClient = (state, data) => {
@ -49,32 +60,54 @@ export const DELETE = new RequestTypes('jwt/DELETE');
export function setJwt(data) {
return {
type: UPDATE_JWT,
data
data,
};
}
export const getScope = (state) => state.getIn(['user', 'scope']);
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case RESET_ERRORS:
return state.set('requestResetPassowrd', List());
case UPDATE_JWT:
return state.set('jwt', action.data);
return state
.set('jwt', action.data.jwt)
.set('spotJwt', action.data.spotJwt);
case LOGIN.REQUEST:
return state.set('loginRequest', { loading: true, errors: [] });
case RESET_PASSWORD.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.SUCCESS:
return state.set('passwordErrors', List());
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:
break;
case UPDATE_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:
return state.set('authDetails', action.data);
case UPDATE_PASSWORD.FAILURE:
@ -82,7 +115,10 @@ const reducer = (state = initialState, action = {}) => {
case LOGIN.FAILURE:
console.log('login failed', action);
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 DELETE.SUCCESS:
case DELETE.FAILURE:
@ -93,14 +129,15 @@ const reducer = (state = initialState, action = {}) => {
case FETCH_CLIENT.SUCCESS:
return setClient(state, action.data);
case PUSH_NEW_SITE:
return state.updateIn(['site', 'list'], list =>
list.push(action.newSite));
return state.updateIn(['site', 'list'], (list) =>
list.push(action.newSite)
);
case SET_ONBOARDING:
return state.set('onboarding', action.state);
case UPDATE_ACCOUNT_MODULE:
return state.updateIn(['account', 'settings', 'modules'], modules => {
return state.updateIn(['account', 'settings', 'modules'], (modules) => {
if (modules.includes(action.moduleKey)) {
return modules.filter(module => module !== action.moduleKey);
return modules.filter((module) => module !== action.moduleKey);
} else {
return modules.concat(action.moduleKey);
}
@ -109,112 +146,136 @@ const reducer = (state = initialState, action = {}) => {
return state;
};
export default withRequestState({
export default withRequestState(
{
signupRequest: SIGNUP,
// loginRequest: LOGIN,
updatePasswordRequest: UPDATE_PASSWORD,
requestResetPassowrd: REQUEST_RESET_PASSWORD,
resetPassword: RESET_PASSWORD,
fetchUserInfoRequest: [FETCH_ACCOUNT, FETCH_CLIENT, FETCH_TENANTS],
putClientRequest: PUT_CLIENT,
updateAccountRequest: UPDATE_ACCOUNT
}, reducer);
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 upgradeScope = () => ({
types: UPGRADE_ACCOUNT_SCOPE.toArray(),
call: (client) => client.post('/account/scope', { scope: 'full' }),
})
export const signup = params => dispatch => dispatch({
export const downgradeScope = () => ({
types: DOWNGRADE_ACCOUNT_SCOPE.toArray(),
call: (client) => client.post('/account/scope', { scope: 'spot' }),
})
export const login = (params) => ({
types: LOGIN.toArray(),
call: (client) => client.post('/login', params),
});
export const loadingLogin = () => ({
type: LOGIN.REQUEST,
});
export const loginSuccess = (data) => ({
type: LOGIN.SUCCESS,
data,
});
export const loginFailure = (errors) => ({
type: LOGIN.FAILURE,
errors,
});
export const signup = (params) => (dispatch) =>
dispatch({
types: SIGNUP.toArray(),
call: client => client.post('/signup', params)
});
call: (client) => client.post('/signup', params),
});
export const resetPassword = params => dispatch => dispatch({
export const resetPassword = (params) => (dispatch) =>
dispatch({
types: RESET_PASSWORD.toArray(),
call: client => client.post('/password/reset', params)
});
call: (client) => client.post('/password/reset', params),
});
export const requestResetPassword = params => dispatch => dispatch({
export const requestResetPassword = (params) => (dispatch) =>
dispatch({
types: REQUEST_RESET_PASSWORD.toArray(),
call: client => client.post('/password/reset-link', params)
});
call: (client) => client.post('/password/reset-link', params),
});
export const updatePassword = params => dispatch => dispatch({
export const updatePassword = (params) => (dispatch) =>
dispatch({
types: UPDATE_PASSWORD.toArray(),
call: client => client.post('/account/password', params)
});
call: (client) => client.post('/account/password', params),
});
export function fetchTenants() {
return {
types: FETCH_TENANTS.toArray(),
call: client => client.get('/signup')
call: (client) => client.get('/signup'),
};
}
export const fetchUserInfo = () => ({
types: FETCH_ACCOUNT.toArray(),
call: client => client.get('/account')
call: (client) => client.get('/account'),
});
export function logout() {
return {
types: DELETE.toArray(),
call: client => client.get('/logout')
call: (client) => client.get('/logout'),
};
}
export function updateClient(params) {
return {
types: PUT_CLIENT.toArray(),
call: client => client.post('/account', params),
params
call: (client) => client.post('/account', params),
params,
};
}
export function updateAccount(params) {
return {
types: UPDATE_ACCOUNT.toArray(),
call: client => client.post('/account', params)
call: (client) => client.post('/account', params),
};
}
export function resendEmailVerification(email) {
return {
types: RESEND_EMAIL_VERIFICATION.toArray(),
call: client => client.post('/re-validate', { email })
call: (client) => client.post('/re-validate', { email }),
};
}
export function pushNewSite(newSite) {
return {
type: PUSH_NEW_SITE,
newSite
newSite,
};
}
export function setOnboarding(state = false) {
return {
type: SET_ONBOARDING,
state
state,
};
}
export function resetErrors() {
return {
type: RESET_ERRORS
type: RESET_ERRORS,
};
}
export function updateModule(moduleKey) {
return {
type: UPDATE_ACCOUNT_MODULE,
moduleKey
moduleKey,
};
}

View 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;

View file

@ -1,16 +1,36 @@
import React from 'react';
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 { Divider, Menu, Tag, Typography } from 'antd';
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 * 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 { 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;
@ -18,10 +38,9 @@ const TabToUrlMap = {
all: sessions() as '/sessions',
bookmark: bookmarks() as '/bookmarks',
notes: notes() as '/notes',
flags: fflags() as '/feature-flags'
flags: fflags() as '/feature-flags',
};
interface Props extends RouteComponentProps {
siteId?: string;
modules: string[];
@ -29,22 +48,54 @@ interface Props extends RouteComponentProps {
activeTab: string;
isEnterprise: boolean;
isCollapsed?: boolean;
spotOnly?: boolean;
account: any;
}
function SideMenu(props: Props) {
// @ts-ignore
const { activeTab, siteId, modules, location, account, isEnterprise, isCollapsed } = props;
const {
activeTab,
siteId,
modules,
location,
account,
isEnterprise,
isCollapsed,
spotOnly,
} = props;
const isPreferencesActive = location.pathname.includes('/client/');
const [supportOpen, setSupportOpen] = React.useState(false);
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(() => {
const sourceMenu = isPreferencesActive ? preferences : main_menu;
return sourceMenu.map(category => {
const updatedItems = category.items.map(item => {
return sourceMenu
.filter((cat) => {
if (spotOnly) {
return spotOnlyCats.includes(cat.key);
}
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 };
@ -65,42 +116,46 @@ function SideMenu(props: Props) {
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);
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 };
});
// Check if all items are hidden in this category
const allItemsHidden = updatedItems.every(item => item.hidden);
const allItemsHidden = updatedItems.every((item) => item.hidden);
return {
...category,
items: updatedItems,
hidden: allItemsHidden // Set the hidden flag for the category
hidden: allItemsHidden,
};
});
}, [isAdmin, isEnterprise, isPreferencesActive, modules]);
}, [isAdmin, isEnterprise, isPreferencesActive, modules, spotOnly]);
React.useEffect(() => {
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) {
props.setActiveTab({ type: tab });
}
}, [location.pathname]);
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.BOOKMARKS]: () => 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.SPOTS]: () => withSiteId(routes.spotsList(), siteId),
[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.METADATA]: () => client(CLIENT_TABS.CUSTOM_FIELDS),
[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.NOTIFICATIONS]: () => client(CLIENT_TABS.NOTIFICATIONS),
[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) => {
@ -150,18 +206,21 @@ function SideMenu(props: Props) {
return false;
};
const pushTo = (path: string) => {
props.history.push(path);
};
return (
<>
<Menu
mode='inline' onClick={handleClick}
mode="inline"
onClick={handleClick}
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) => (
<React.Fragment key={category.key}>
@ -169,7 +228,9 @@ function SideMenu(props: Props) {
<>
{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);
if (item.key === MENU.EXIT) {
@ -177,7 +238,13 @@ function SideMenu(props: Props) {
<Menu.Item
key={item.key}
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')}
>
{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 ? (
<Menu.SubMenu
key={item.key}
title={<Text className={cn('ml-5 !rounded')}>{item.label}</Text>}
icon={<SVG name={item.icon} size={16} />}>
{/*style={{ paddingLeft: '30px' }}*/}
{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>)}
title={
<Text className={cn('ml-5 !rounded')}>
{item.label}
</Text>
}
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.Item
key={item.key}
icon={<Icon name={item.icon} size={16} color={isActive ? 'teal' : ''}
className={'hover-fill-teal'} />}
icon={
<Icon
name={item.icon}
size={16}
color={isActive ? 'teal' : ''}
className={'hover-fill-teal'}
/>
}
style={{ paddingLeft: '20px' }}
className={cn('!rounded-lg hover-fill-teal')}
itemIcon={item.leading ?
<Icon name={item.leading} size={16} color={isActive ? 'teal' : ''} /> : null}>
itemIcon={
item.leading ? (
<Icon
name={item.leading}
size={16}
color={isActive ? 'teal' : ''}
/>
) : null
}
>
{item.label}
</Menu.Item>
);
})}
</>
)}
</React.Fragment>
))}
</Menu>
<SupportModal
onClose={() => {
setSupportOpen(false);
}} open={supportOpen} />
{spotOnly && !isPreferencesActive ? (
<>
<InitORCard onOpenModal={handleModalOpen} />
<SpotToOpenReplayPrompt
isVisible={isModalVisible}
onCancel={handleModalClose}
/>
</>
) : null}
<SupportModal onClose={() => setSupportOpen(false)} open={supportOpen} />
</>
);
}
export default withRouter(
connect((state: any) => ({
connect(
(state: any) => ({
modules: state.getIn(['user', 'account', 'settings', 'modules']) || [],
activeTab: state.getIn(['search', 'activeTab', 'type']),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
account: state.getIn(['user', 'account'])
account: state.getIn(['user', 'account']),
spotOnly: getScope(state) === 'spot',
}),
{ setActiveTab }
)(SideMenu)

View 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);

View file

@ -1,35 +1,46 @@
import { Popover, Space } from 'antd';
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 HealthStatus from 'Components/Header/HealthStatus';
import { getInitials } from 'App/utils';
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 { getScope } from "../duck/user";
interface Props {
account: any;
siteId: any;
sites: any;
boardingCompletion: any;
spotOnly?: boolean;
}
function TopRight(props: Props) {
const { account } = props;
// @ts-ignore
return (
<Space style={{ lineHeight: '0'}}>
<Space style={{ lineHeight: '0' }}>
{props.spotOnly ? null : (
<>
<ProjectDropdown />
<GettingStartedProgress />
<Notifications />
{account.name ? <HealthStatus /> : null}
</>
)}
<Popover content={<UserMenu />} placement={'topRight'}>
<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="flex items-center cursor-pointer">
<div
className="bg-tealx rounded-full flex items-center justify-center color-white"
style={{ width: '32px', height: '32px' }}
>
{getInitials(account.name)}
</div>
</div>
@ -41,9 +52,10 @@ function TopRight(props: Props) {
function mapStateToProps(state: any) {
return {
account: state.getIn(['user', 'account']),
spotOnly: getScope(state) === 'spot',
siteId: state.getIn(['site', 'siteId']),
sites: state.getIn(['site', 'list']),
boardingCompletion: state.getIn(['dashboard', 'boardingCompletion'])
boardingCompletion: state.getIn(['dashboard', 'boardingCompletion']),
};
}

View file

@ -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,
]

View file

@ -72,7 +72,7 @@ class LoginStore {
this.setSpotJWT(resp.spotJwt)
return resp
} catch (e) {
console.error(e)
throw e
} finally {
this.setSpotJwtPending(false)
}

View file

@ -17,11 +17,16 @@ export default class SpotStore {
accessKey: string | undefined = undefined;
pubKey: { value: string; expiration: number } | null = null;
readonly order = 'desc';
accessError = false;
constructor() {
makeAutoObservable(this);
}
setAccessError(error: boolean) {
this.accessError = error;
}
clearCurrent = () => {
this.currentSpot = null;
this.pubKey = null;
@ -67,7 +72,7 @@ export default class SpotStore {
this.total = total;
}
async fetchSpots() {
fetchSpots = async () => {
const filters = {
page: this.page,
filterBy: this.filter,
@ -81,9 +86,10 @@ export default class SpotStore {
);
this.setSpots(response.spots.map((spot: any) => new Spot(spot)));
this.setTotal(response.total);
}
};
async fetchSpotById(id: string) {
try {
const response = await this.withLoader(() =>
spotService.fetchSpot(id, this.accessKey)
);
@ -92,11 +98,21 @@ export default class SpotStore {
this.setCurrentSpot(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) {
await this.withLoader(async () => {
await spotService.addComment(spotId, { comment, userName });
await spotService.addComment(
spotId,
{ comment, userName },
this.accessKey
);
const spot = this.currentSpot;
if (spot) {
spot.comments!.push({
@ -143,24 +159,40 @@ export default class SpotStore {
* @param expiration - in seconds
* @param id - spot id string
* */
async generateKey(id: string, expiration: number) {
generateKey = async (id: string, expiration: number) => {
try {
const { key } = await this.withLoader(() =>
spotService.generateKey(id, expiration)
);
const { key } = await this.withLoader(() => {
return spotService.generateKey(id, expiration);
});
this.setPubKey(key);
return key;
} catch (e) {
console.error('couldnt generate pubkey')
}
console.error('couldnt generate pubkey');
}
};
async getPubKey(id: string) {
getPubKey = async (id: string) => {
try {
const { key } = await this.withLoader(() => spotService.getKey(id));
const { key } = await this.withLoader(() => {
return spotService.getKey(id);
});
this.setPubKey(key);
} 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
}
}
}

View file

@ -70,7 +70,7 @@ interface IResource {
time: number,
type: ResourceType,
url: string,
status: string,
status: string | number,
method: string,
duration: number,
success: boolean,
@ -81,6 +81,7 @@ interface IResource {
encodedBodySize?: number,
decodedBodySize?: number,
responseBodySize?: number,
error?: string,
}
export interface IResourceTiming extends IResource {
@ -110,7 +111,7 @@ export interface IResourceRequest extends IResource {
export const Resource = (resource: IResource) => ({
...resource,
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,
})

View file

@ -144,6 +144,7 @@ export const usabilityTestingView = (id = ':testId', hash?: string | number): st
export const spotsList = (): string => '/spots';
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 = [
liveSession(''),

View file

@ -8,12 +8,13 @@ export default class LoginService extends BaseService {
'g-recaptcha-response': captchaResponse,
})
.then((r) => {
if (r.ok) {
return r.json();
}
})
.catch((e) => {
throw e;
return e.response.json()
.then((r: { errors: string[] }) => {
throw r.errors;
});
});
}
}

View file

@ -54,7 +54,6 @@ export default class SpotService extends BaseService {
async fetchSpot(id: string, accessKey?: string): Promise<GetSpotResponse> {
return this.client.get(`/spot/v1/spots/${id}${accessKey ? `?key=${accessKey}` : ''}`)
.then(r => r.json())
.catch(console.error)
}
async updateSpot(id: string, filter: UpdateSpotRequest) {
@ -71,10 +70,9 @@ export default class SpotService extends BaseService {
.catch(console.error)
}
async addComment(id: string, data: AddCommentRequest) {
return this.client.post(`/spot/v1/spots/${id}/comment`, data)
async addComment(id: string, data: AddCommentRequest, accessKey?: string) {
return this.client.post(`/spot/v1/spots/${id}/comment${accessKey ? `?key=${accessKey}` : ''}`, data)
.then(r => r.json())
.catch(console.error)
}
async getVideo(id:string) {
@ -98,4 +96,10 @@ export default class SpotService extends BaseService {
.then(r => r.json())
.catch(console.error)
}
async checkProcessingStatus(id: string) {
return this.client.get(`/spot/v1/spots/${id}/status`)
.then(r => r.json())
.catch(console.error)
}
}

View file

@ -14,7 +14,11 @@ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && window.e
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
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)));
store.subscribe(() => {

View file

@ -3,6 +3,19 @@ import chroma from 'chroma-js';
import * as htmlToImage from 'html-to-image';
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) {
let timeout = 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);
}

View file

@ -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') }
`;
// Log the generated CSS to the console
console.log(generatedCSS);
// Write the generated CSS to a file
fs.writeFileSync('app/styles/colors-autogen.css', generatedCSS);

View file

@ -6,17 +6,18 @@ const { collectFilenames } = require('./fs');
const path = require('path');
const svgRE = /\.svg$/;
const ICONS_DIRNAME = path.join(__dirname, '../app/svg/icons')
const UI_DIRNAME = path.join(__dirname, '../app/components/ui')
const icons = collectFilenames(ICONS_DIRNAME, n => svgRE.test(n));
const ICONS_DIRNAME = path.join(__dirname, '../app/svg/icons');
const UI_DIRNAME = path.join(__dirname, '../app/components/ui');
const icons = collectFilenames(ICONS_DIRNAME, (n) => svgRE.test(n));
const getDirectories = source =>
fs.readdirSync(source, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
const getDirectories = (source) =>
fs
.readdirSync(source, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const titleCase = (string) => {
return string[0].toUpperCase() + string.slice(1).toLowerCase();
}
};
const plugins = (removeFill = true) => {
return {
@ -28,24 +29,38 @@ const plugins = (removeFill = true) => {
removeViewBox: false,
inlineStyles: {
onlyMatchedOnce: false,
}
}
}
},
},
},
},
{
name: 'removeAttrs',
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',
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 });
dirs.forEach((dir) => {
fs.mkdirSync(`${UI_DIRNAME}/Icons/${dir.replaceAll('-', '_')}`, { recursive: true });
})
fs.mkdirSync(`${UI_DIRNAME}/Icons/${dir.replaceAll('-', '_')}`, {
recursive: true,
});
});
icons.forEach((icon) => {
const fileName = icon.slice(0, -4).replaceAll('-', '_').replaceAll('/', '_');
const name = fileName
const path = `${UI_DIRNAME}/Icons/${name}.tsx`
iconPaths.push({ path: `./Icons/${name}`, name, oldName: icon.slice(0, -4), fileName });
const name = fileName;
const path = `${UI_DIRNAME}/Icons/${name}.tsx`;
iconPaths.push({
path: `./Icons/${name}`,
name,
oldName: icon.slice(0, -4),
fileName,
});
const svg = fs.readFileSync(`${ICONS_DIRNAME}/${icon}`, 'utf-8');
const canOptimize = !icon.includes('integrations');
const keepOriginal = icon.includes('color')
const { data } = keepOriginal ? { data: svg } : optimize(svg, plugins(canOptimize));
fs.writeFileSync(path, `
const keepOriginal = icon.includes('color');
const { data } = keepOriginal
? { data: svg }
: optimize(svg, plugins(canOptimize));
fs.writeFileSync(
path,
`
/* Auto-generated, do not edit */
import React from 'react';
@ -80,12 +106,16 @@ interface Props {
function ${titleCase(fileName)}(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
${data.replaceAll(/xlink\:href/g, 'xlinkHref')
${data
.replaceAll(/xlink\:href/g, 'xlinkHref')
.replaceAll(/xmlns\:xlink/g, 'xmlnsXlink')
.replaceAll(/clip\-path/g, 'clipPath')
.replaceAll(/clip\-rule/g, 'clipRule')
// 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-opacity/g, 'fillOpacity')
.replaceAll(/stop-color/g, 'stopColor')
@ -94,28 +124,47 @@ function ${titleCase(fileName)}(props: Props) {
.replaceAll(/stroke-linejoin/g, 'strokeLinejoin')
.replaceAll(/stroke-miterlimit/g, 'strokeMiterlimit')
.replaceAll(/xml:space="preserve"/g, '')
}
.replaceAll(/flood-opacity/g, 'floodOpacity')
.replaceAll(
/color-interpolation-filters/g,
'colorInterpolationFilters'
)}
);
}
export default ${titleCase(fileName)};
`)
})
`
);
});
fs.writeFileSync(`${UI_DIRNAME}/Icons/index.ts`, `
fs.writeFileSync(
`${UI_DIRNAME}/Icons/index.ts`,
`
/* 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
fs.writeFileSync(`${UI_DIRNAME}/SVG.tsx`, `
fs.writeFileSync(
`${UI_DIRNAME}/SVG.tsx`,
`
/* Auto-generated, do not edit */
import React from 'react';
import {
${iconPaths.map(icon => ` ${titleCase(icon.fileName)}`).join(',\n')}
${iconPaths.map((icon) => ` ${titleCase(icon.fileName)}`).join(',\n')}
} 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 {
name: IconNames;
@ -129,16 +178,21 @@ interface Props {
const SVG = (props: Props) => {
const { name, size = 14, width = size, height = size, fill = '' } = props;
switch (name) {
${iconPaths.map(icon => {
${iconPaths
.map((icon) => {
return `
${icon.oldName !== icon.name ? `// case '${icon.oldName}':` : ''}
case '${icon.oldName}': return <${titleCase(icon.fileName)} width={ width } height={ height } fill={ fill } />;
`}
).join('')}
case '${icon.oldName}': return <${titleCase(
icon.fileName
)} width={ width } height={ height } fill={ fill } />;
`;
})
.join('')}
default:
console.trace('Unknown icon name ' + name);
}
}
SVG.displayName = 'SVG';
export default SVG;
`);
`
);

28
spot/.gitignore vendored Normal file
View 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
View file

@ -0,0 +1 @@
v20.16.0

1
spot/.prettierrc Normal file
View file

@ -0,0 +1 @@
{}

2
spot/README.md Normal file
View 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
View 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

View 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

View 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
View 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
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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
View 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

View 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

View 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

View 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

View 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
View 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
View 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

View 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
View 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
View 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

View 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();
});

View 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>

File diff suppressed because it is too large Load diff

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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;
}
}

View 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);
}
});
},
});

View 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;
}

View 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;
}
}

View 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;
}
}
});
});

View 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();
}
});

Some files were not shown because too many files have changed in this diff Show more