Highlight UI (#2951)
* ui: start highlight ui * ui: tag items * ui: connecting highlights to notes api... * Highlight feature refinements (#2948) * ui: move clips player to foss, connect notes api to hl * ui: tune note/hl editing, prevent zoom slider body from jumping around * ui: safe check for tag * ui: fix thumbnail gen * ui: fix thumbnail gen * ui: make player modal wider, add shadow * ui: custom warn barge for clips * ui: swap icon for note event wrapper * ui: rm other, fix cancel * ui: moving around creation modal * ui: bg tint * ui: rm disabled for text btn * ui: fix ownership sorting * ui: close player on bg click * ui: fix query, fix min distance for default range * ui: move hl list header out of list comp * ui: spot list header segmented size * Various improvements in highlights (#2955) * ui: update hl in hlPanel comp * ui: rm debug * ui: fix icons file --------- Co-authored-by: Sudheer Salavadi <connect.uxmaster@gmail.com>
This commit is contained in:
parent
622d0a7dfa
commit
2cd96b0df0
59 changed files with 2536 additions and 265 deletions
|
|
@ -36,6 +36,7 @@ const components: any = {
|
|||
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
|
||||
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
|
||||
ScopeSetup: lazy(() => import('Components/ScopeForm')),
|
||||
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList')),
|
||||
};
|
||||
|
||||
const enhancedComponents: any = {
|
||||
|
|
@ -58,6 +59,7 @@ const enhancedComponents: any = {
|
|||
SpotsList: withSiteIdUpdater(components.SpotsListPure),
|
||||
Spot: components.SpotPure,
|
||||
ScopeSetup: components.ScopeSetup,
|
||||
Highlights: components.HighlightsPure,
|
||||
};
|
||||
|
||||
const withSiteId = routes.withSiteId;
|
||||
|
|
@ -105,6 +107,8 @@ const SPOTS_LIST_PATH = routes.spotsList();
|
|||
const SPOT_PATH = routes.spot();
|
||||
const SCOPE_SETUP = routes.scopeSetup();
|
||||
|
||||
const HIGHLIGHTS_PATH = routes.highlights();
|
||||
|
||||
function PrivateRoutes() {
|
||||
const { projectsStore, userStore, integrationsStore } = useStore();
|
||||
const onboarding = userStore.onboarding;
|
||||
|
|
@ -236,6 +240,12 @@ function PrivateRoutes() {
|
|||
path={withSiteId(RECORDINGS_PATH, siteIdList)}
|
||||
component={enhancedComponents.Assist}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(HIGHLIGHTS_PATH, siteIdList)}
|
||||
component={enhancedComponents.Highlights}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
|
|
|
|||
62
frontend/app/components/Highlights/EditHlModal.tsx
Normal file
62
frontend/app/components/Highlights/EditHlModal.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { Checkbox, Modal, Input } from 'antd';
|
||||
|
||||
function EditHlModal({
|
||||
open,
|
||||
onSave,
|
||||
onCancel,
|
||||
text,
|
||||
visible,
|
||||
}: {
|
||||
open: boolean;
|
||||
onSave: (noteText: string, visible: boolean) => any;
|
||||
onCancel: () => any;
|
||||
text: string;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const [noteText, setNoteText] = React.useState(text);
|
||||
const [checkboxVisible, setVisible] = React.useState(visible);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNoteText(text);
|
||||
setVisible(visible);
|
||||
}, [text, visible])
|
||||
|
||||
const onEdit = (val: string) => {
|
||||
if (val.length > 200) {
|
||||
return;
|
||||
}
|
||||
setNoteText(val);
|
||||
};
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={'Edit Highlight'}
|
||||
open={open}
|
||||
okText={'Save'}
|
||||
width={350}
|
||||
centered
|
||||
onOk={() => onSave(noteText, visible)}
|
||||
onCancel={() => onCancel()}
|
||||
>
|
||||
<div className={'flex flex-col gap-2'}>
|
||||
<Input.TextArea
|
||||
placeholder={'Highlight note'}
|
||||
onChange={(e) => onEdit(e.target.value)}
|
||||
maxLength={200}
|
||||
value={noteText}
|
||||
/>
|
||||
<div>{noteText.length}/200 Characters remaining</div>
|
||||
<Checkbox
|
||||
checked={checkboxVisible}
|
||||
onChange={(e) => setVisible(e.target.checked)}
|
||||
>
|
||||
Team can see and edit this Highlight.
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditHlModal;
|
||||
114
frontend/app/components/Highlights/HighlightClip.tsx
Normal file
114
frontend/app/components/Highlights/HighlightClip.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React from 'react';
|
||||
import { tagProps } from "App/services/NotesService";
|
||||
import { GridItem } from 'App/components/Spots/SpotsList/SpotListItem';
|
||||
import { confirm } from "UI";
|
||||
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Tag } from "antd";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Eye, Link } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { resentOrDate } from 'App/date'
|
||||
|
||||
function HighlightClip({
|
||||
note = 'Highlight note',
|
||||
tag = 'ISSUE',
|
||||
user = 'user@openreplay.com',
|
||||
createdAt = '12/12/2025',
|
||||
hId = 1234,
|
||||
thumbnail = undefined,
|
||||
openEdit = () => undefined,
|
||||
onItemClick = () => undefined,
|
||||
onDelete = () => undefined,
|
||||
}: {
|
||||
note: string;
|
||||
tag: string;
|
||||
user: string;
|
||||
createdAt: string;
|
||||
hId: number;
|
||||
thumbnail?: string;
|
||||
openEdit: (id: any) => any;
|
||||
onItemClick: (id: any) => any;
|
||||
onDelete: (id: any) => any;
|
||||
}) {
|
||||
const copyToClipboard = () => {
|
||||
const currUrl = window.location.href;
|
||||
const hUrl = `${currUrl}?highlight=${hId}`;
|
||||
copy(hUrl);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <Link size={14} strokeWidth={1} />,
|
||||
label: 'Copy Link',
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Edit',
|
||||
},
|
||||
{
|
||||
key: 'visibility',
|
||||
icon: <Eye strokeWidth={1} size={14} />,
|
||||
label: 'Visibility',
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: 'Delete',
|
||||
},
|
||||
];
|
||||
|
||||
const onMenuClick = async ({ key }: any) => {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
return openEdit();
|
||||
case 'copy':
|
||||
copyToClipboard();
|
||||
toast.success('Highlight link copied to clipboard');
|
||||
return
|
||||
case 'delete':
|
||||
const res = await confirm({
|
||||
header: 'Are you sure delete this Highlight?',
|
||||
confirmation:
|
||||
'Deleting a Highlight will only remove this instance and its associated note. It will not affect the original session.',
|
||||
confirmButton: 'Yes, Delete',
|
||||
});
|
||||
if (res) {
|
||||
onDelete();
|
||||
}
|
||||
return;
|
||||
case 'visibility':
|
||||
return openEdit();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<GridItem
|
||||
title={note}
|
||||
onItemClick={onItemClick}
|
||||
thumbnail={thumbnail}
|
||||
setLoading={() => null}
|
||||
loading={false}
|
||||
copyToClipboard={copyToClipboard}
|
||||
user={user}
|
||||
createdAt={resentOrDate(createdAt, true)}
|
||||
menuItems={menuItems}
|
||||
onMenuClick={onMenuClick}
|
||||
modifier={
|
||||
tag ? <div className="left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
|
||||
<Tag
|
||||
color={tagProps[tag]}
|
||||
className="border-0 rounded-lg hover:inherit gap-2 w-14 text-center capitalize"
|
||||
bordered={false}
|
||||
>
|
||||
{tag.toLowerCase()}
|
||||
</Tag>
|
||||
</div> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default HighlightClip;
|
||||
79
frontend/app/components/Highlights/HighlightPlayer.tsx
Normal file
79
frontend/app/components/Highlights/HighlightPlayer.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import ClipsPlayer from '../Session/ClipsPlayer';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Loader } from 'UI';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface Clip {
|
||||
sessionId: string | undefined;
|
||||
range: [number, number];
|
||||
message: string;
|
||||
}
|
||||
|
||||
function HighlightPlayer({
|
||||
hlId,
|
||||
onClose,
|
||||
}: {
|
||||
hlId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { notesStore } = useStore();
|
||||
const [clip, setClip] = React.useState<Clip>({
|
||||
sessionId: undefined,
|
||||
range: [],
|
||||
message: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hlId) {
|
||||
notesStore.fetchNoteById(hlId).then((hl) => {
|
||||
if (!hl) {
|
||||
onClose();
|
||||
} else {
|
||||
setClip({
|
||||
range: [hl.startAt ?? 0, hl.endAt ?? 99999],
|
||||
sessionId: hl.sessionId,
|
||||
message: hl.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [hlId]);
|
||||
|
||||
const onBgClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'w-screen h-screen fixed top-0 left-0 flex items-center justify-center'
|
||||
}
|
||||
style={{ zIndex: 100, background: 'rgba(0,0,0, 0.15)' }}
|
||||
onClick={onBgClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg overflow-hidden',
|
||||
'rounded shadow boarder bg-white'
|
||||
)}
|
||||
style={{ width: 960 }}
|
||||
>
|
||||
<Loader loading={notesStore.loading}>
|
||||
<ClipsPlayer
|
||||
isHighlight
|
||||
onClose={onClose}
|
||||
clip={clip}
|
||||
currentIndex={0}
|
||||
isCurrent={true}
|
||||
autoplay={false}
|
||||
/>
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(HighlightPlayer);
|
||||
192
frontend/app/components/Highlights/HighlightsList.tsx
Normal file
192
frontend/app/components/Highlights/HighlightsList.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import React from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { iTag } from 'App/services/NotesService';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import { Pagination, NoContent, Loader } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import { withSiteId, highlights } from "App/routes";
|
||||
import HighlightClip from './HighlightClip';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import HighlightPlayer from "./HighlightPlayer";
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import EditHlModal from "./EditHlModal";
|
||||
import HighlightsListHeader from './HighlightsListHeader'
|
||||
|
||||
function HighlightsList() {
|
||||
const { notesStore, projectsStore } = useStore();
|
||||
const hist = useHistory();
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [editHl, setEditHl] = React.useState<Record<string, any>>({
|
||||
message: '',
|
||||
isPublic: false,
|
||||
});
|
||||
const { search } = useLocation();
|
||||
const highlight = new URLSearchParams(search).get('highlight');
|
||||
|
||||
const query = notesStore.query;
|
||||
const limit = notesStore.pageSize;
|
||||
const listLength = notesStore.notes.length;
|
||||
const activeTags = notesStore.activeTags;
|
||||
const page = notesStore.page;
|
||||
const ownOnly = notesStore.ownOnly
|
||||
const {
|
||||
data = { notes: [], total: 0 },
|
||||
isPending,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['notes', page, query, activeTags],
|
||||
queryFn: () => notesStore.fetchNotes(),
|
||||
retry: 3,
|
||||
});
|
||||
const { total, notes } = data;
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
notesStore.setQuery(value);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
notesStore.setQuery(e.target.value);
|
||||
};
|
||||
|
||||
const toggleTag = (tag?: iTag) => {
|
||||
notesStore.toggleTag(tag)
|
||||
};
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
notesStore.changePage(page);
|
||||
};
|
||||
|
||||
const onDelete = async (id: number) => {
|
||||
await notesStore.deleteNote(id);
|
||||
refetch();
|
||||
toast.success('Highlight deleted successfully');
|
||||
};
|
||||
|
||||
const onItemClick = (id: string) => {
|
||||
hist.replace(`?highlight=${id}`);
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
hist.replace(withSiteId(highlights(), projectsStore.active?.id));
|
||||
}
|
||||
|
||||
const onEdit = (id: string) => {
|
||||
const hl = notesStore.getNoteById(id);
|
||||
if (!hl) {
|
||||
return toast.error('Highlight not found in the list');
|
||||
}
|
||||
setEditHl(hl);
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
|
||||
const onSave = async (noteText: string, visible: boolean) => {
|
||||
if (!editHl) {
|
||||
return;
|
||||
}
|
||||
const newNote = {
|
||||
...editHl,
|
||||
message: noteText,
|
||||
isPublic: visible,
|
||||
}
|
||||
try {
|
||||
await notesStore.updateNote(editHl.noteId, newNote);
|
||||
toast.success('Highlight updated successfully');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error('Error updating highlight');
|
||||
}
|
||||
|
||||
setEditModalOpen(false);
|
||||
}
|
||||
|
||||
const toggleShared = (val: boolean) => {
|
||||
notesStore.toggleShared(val);
|
||||
refetch();
|
||||
}
|
||||
|
||||
const isEmpty = !isPending && total === 0;
|
||||
return (
|
||||
<div
|
||||
className={'relative w-full mx-auto bg-white rounded-lg'}
|
||||
style={{ maxWidth: 1360 }}
|
||||
>
|
||||
{highlight && <HighlightPlayer onClose={onClose} hlId={highlight} />}
|
||||
<HighlightsListHeader
|
||||
activeTags={activeTags}
|
||||
ownOnly={ownOnly}
|
||||
onSearch={onSearch}
|
||||
handleInputChange={handleInputChange}
|
||||
toggleTag={toggleTag}
|
||||
toggleShared={toggleShared}
|
||||
query={query}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'py-2 px-4 border-gray-lighter',
|
||||
isEmpty
|
||||
? 'h-96 flex items-center justify-center'
|
||||
: ' grid grid-cols-3 gap-6'
|
||||
)}
|
||||
>
|
||||
<Loader loading={isPending}>
|
||||
<NoContent
|
||||
show={isEmpty}
|
||||
subtext={
|
||||
<div className={'w-full text-center'}>
|
||||
Highlight and note observations during session replays and share
|
||||
them with your team.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<HighlightClip
|
||||
note={note.message}
|
||||
tag={note.tag}
|
||||
user={note.userName}
|
||||
createdAt={note.createdAt}
|
||||
hId={note.noteId}
|
||||
thumbnail={note.thumbnail}
|
||||
openEdit={() => onEdit(note.noteId)}
|
||||
onDelete={() => onDelete(note.noteId)}
|
||||
onItemClick={() => onItemClick(note.noteId)}
|
||||
/>
|
||||
))}
|
||||
</NoContent>
|
||||
</Loader>
|
||||
<EditHlModal
|
||||
open={editModalOpen}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
text={editHl?.message}
|
||||
visible={editHl?.isPublic}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-4 py-3 shadow-sm w-full bg-white rounded-lg mt-2',
|
||||
isEmpty ? 'hidden' : 'visible'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
Showing <span className="font-medium">{(page - 1) * limit + 1}</span>
|
||||
{' to '}
|
||||
<span className="font-medium">{(page - 1) * limit + listLength}</span>
|
||||
{' of '}
|
||||
<span className="font-medium">{numberWithCommas(total)}</span>
|
||||
{' highlights'}.
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={total}
|
||||
onPageChange={onPageChange}
|
||||
limit={limit}
|
||||
debounceRequest={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(HighlightsList);
|
||||
96
frontend/app/components/Highlights/HighlightsListHeader.tsx
Normal file
96
frontend/app/components/Highlights/HighlightsListHeader.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react'
|
||||
import { iTag, TAGS } from "App/services/NotesService";
|
||||
import { SortDropdown } from "Components/shared/SessionsTabOverview/components/SessionSort/SessionSort";
|
||||
import { Input, Segmented } from "antd";
|
||||
|
||||
function HighlightsListHeader({
|
||||
activeTags,
|
||||
ownOnly,
|
||||
toggleShared,
|
||||
toggleTag,
|
||||
query,
|
||||
onSearch,
|
||||
handleInputChange
|
||||
}: {
|
||||
activeTags: iTag[];
|
||||
ownOnly: boolean;
|
||||
toggleShared: (value: boolean) => void;
|
||||
toggleTag: (value?: iTag) => void;
|
||||
query: string;
|
||||
onSearch: (value: string) => void;
|
||||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={'flex p-2 px-4 w-full border-b gap-4 items-center'}>
|
||||
<h1 className={'text-2xl capitalize mr-2'}>Highlights</h1>
|
||||
<Segmented
|
||||
size="small"
|
||||
options={[
|
||||
{
|
||||
value: 'ALL',
|
||||
label: (
|
||||
<div
|
||||
className={
|
||||
activeTags.includes('ALL') || activeTags.length === 0
|
||||
? 'text-main'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
All
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...TAGS.map((tag: iTag) => ({
|
||||
value: tag,
|
||||
label: (
|
||||
<div
|
||||
className={
|
||||
activeTags.includes(tag)
|
||||
? 'text-main capitalize'
|
||||
: 'capitalize'
|
||||
}
|
||||
>
|
||||
{tag.toLowerCase()}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
onChange={(value: iTag) =>
|
||||
toggleTag(value === 'ALL' ? undefined : value)
|
||||
}
|
||||
/>
|
||||
<div className={'ml-auto'}>
|
||||
<SortDropdown
|
||||
sortOptions={[
|
||||
{
|
||||
key: 'own',
|
||||
label: 'Personal',
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: 'Team',
|
||||
},
|
||||
]}
|
||||
onSort={({ key }) => {
|
||||
toggleShared(key === 'own');
|
||||
}}
|
||||
current={ownOnly ? 'Personal' : 'Team'}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<Input.Search
|
||||
value={query}
|
||||
allowClear
|
||||
name="spot-search"
|
||||
placeholder="Filter by title"
|
||||
onChange={handleInputChange}
|
||||
onSearch={onSearch}
|
||||
className="rounded-lg"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HighlightsListHeader
|
||||
159
frontend/app/components/Session/ClipsPlayer.tsx
Normal file
159
frontend/app/components/Session/ClipsPlayer.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { createClipPlayer } from 'Player';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Loader } from 'UI';
|
||||
import {
|
||||
IPlayerContext,
|
||||
PlayerContext,
|
||||
defaultContextValue,
|
||||
} from './playerContext';
|
||||
|
||||
import ClipPlayerHeader from 'Components/Session/Player/ClipPlayer/ClipPlayerHeader';
|
||||
import ClipPlayerContent from 'Components/Session/Player/ClipPlayer/ClipPlayerContent';
|
||||
import Session from 'Types/session';
|
||||
import { sessionService } from '@/services';
|
||||
|
||||
let playerInst: IPlayerContext['player'] | undefined;
|
||||
|
||||
interface Props {
|
||||
clip: any;
|
||||
currentIndex: number;
|
||||
isCurrent: boolean;
|
||||
autoplay: boolean;
|
||||
onClose?: () => void;
|
||||
isHighlight?: boolean;
|
||||
}
|
||||
|
||||
function WebPlayer(props: Props) {
|
||||
const { clip, currentIndex, isCurrent, onClose, isHighlight } = props;
|
||||
const { sessionStore } = useStore();
|
||||
const prefetched = sessionStore.prefetched;
|
||||
const [windowActive, setWindowActive] = useState(!document.hidden);
|
||||
const [contextValue, setContextValue] =
|
||||
// @ts-ignore
|
||||
useState<IPlayerContext>(defaultContextValue);
|
||||
const openedAt = React.useRef<number>();
|
||||
const [session, setSession] = useState<Session | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clip.sessionId) return;
|
||||
|
||||
const fetchSession = async () => {
|
||||
if (clip.sessionId != null && clip?.sessionId !== '') {
|
||||
try {
|
||||
// const data = await sessionStore.fetchSessionData(props.sessionId);
|
||||
const data = await sessionService.getSessionInfo(clip.sessionId);
|
||||
setSession(new Session(data));
|
||||
} catch (error) {
|
||||
console.error('Error fetching session data:', error);
|
||||
}
|
||||
} else {
|
||||
console.error('No sessionID in route.');
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSession();
|
||||
}, [clip]);
|
||||
|
||||
React.useEffect(() => {
|
||||
openedAt.current = Date.now();
|
||||
if (windowActive) {
|
||||
const handleActivation = () => {
|
||||
if (!document.hidden) {
|
||||
setWindowActive(true);
|
||||
document.removeEventListener('visibilitychange', handleActivation);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleActivation);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
playerInst = undefined;
|
||||
if (!clip.sessionId || contextValue.player !== undefined || !session)
|
||||
return;
|
||||
|
||||
// @ts-ignore
|
||||
sessionStore.setUserTimezone(session?.timezone);
|
||||
const [WebPlayerInst, PlayerStore] = createClipPlayer(
|
||||
session,
|
||||
(state) => makeAutoObservable(state),
|
||||
toast,
|
||||
clip.range
|
||||
);
|
||||
|
||||
setContextValue({ player: WebPlayerInst, store: PlayerStore });
|
||||
playerInst = WebPlayerInst;
|
||||
// playerInst.pause();
|
||||
}, [session]);
|
||||
|
||||
const domFiles = session?.domURL?.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefetched && domFiles > 0) {
|
||||
playerInst?.reinit(session!);
|
||||
playerInst?.pause();
|
||||
}
|
||||
}, [session, domFiles, prefetched]);
|
||||
|
||||
const {
|
||||
firstVisualEvent: visualOffset,
|
||||
messagesProcessed,
|
||||
tabStates,
|
||||
ready,
|
||||
playing,
|
||||
} = contextValue.store?.get() || {};
|
||||
|
||||
const cssLoading =
|
||||
ready && tabStates
|
||||
? Object.values(tabStates).some(({ cssLoading }) => cssLoading)
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) {
|
||||
if (!isCurrent) {
|
||||
contextValue.player?.pause();
|
||||
}
|
||||
}
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
contextValue.player?.jump(clip.range[0]);
|
||||
setTimeout(() => {
|
||||
contextValue.player?.play();
|
||||
}, 500);
|
||||
}, [currentIndex]);
|
||||
|
||||
if (!session || !session?.sessionId)
|
||||
return (
|
||||
<Loader
|
||||
size={75}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
height: 75,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={contextValue}>
|
||||
{contextValue.player ? (
|
||||
<>
|
||||
<ClipPlayerHeader isHighlight={isHighlight} onClose={onClose} range={clip.range} session={session!} />
|
||||
<ClipPlayerContent message={clip.message} isHighlight={isHighlight} autoplay={props.autoplay} range={clip.range} session={session!} />
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</PlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(WebPlayer);
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import React, {useEffect, useState} from 'react';
|
||||
import cn from 'classnames';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {Button} from 'UI';
|
||||
import stl from './AutoplayTimer.module.css';
|
||||
import clsOv from './overlay.module.css';
|
||||
import AutoplayToggle from 'Shared/AutoplayToggle';
|
||||
import {useStore} from 'App/mstore';
|
||||
|
||||
function AutoplayTimer({history}: any) {
|
||||
let timer: NodeJS.Timer;
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [counter, setCounter] = useState(5);
|
||||
const {clipStore} = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (counter > 0) {
|
||||
timer = setTimeout(() => {
|
||||
setCounter(counter - 1);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (counter === 0) {
|
||||
clipStore.next();
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [counter]);
|
||||
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
setCancelled(true);
|
||||
};
|
||||
|
||||
if (cancelled) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(clsOv.overlay, stl.overlayBg, "z-10")}>
|
||||
<div className="border p-5 shadow-lg bg-white rounded">
|
||||
<div className="mb-5">
|
||||
Autoplaying next clip in <span className="font-medium">{counter}</span> seconds
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-10">
|
||||
<AutoplayToggle/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button variant="text-primary" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="px-2"/>
|
||||
<Button variant="outline" onClick={() => clipStore.next()}>Play Now</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(AutoplayTimer);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.overlayBg {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import React, {useContext} from 'react';
|
||||
import {Switch, Tooltip} from ".store/antd-virtual-7db13b4af6/package";
|
||||
import {CaretRightOutlined, PauseOutlined} from ".store/@ant-design-icons-virtual-de151eefe5/package";
|
||||
import {useStore} from "@/mstore";
|
||||
import {observer} from "mobx-react-lite";
|
||||
import {IPlayerContext, PlayerContext} from "Components/Session/playerContext";
|
||||
|
||||
function AutoplayToggle() {
|
||||
const {clipStore} = useStore();
|
||||
const playerContext = React.useContext<IPlayerContext>(PlayerContext);
|
||||
// const { player, store } = playerContext;
|
||||
const { autoplay } = playerContext.store.get();
|
||||
|
||||
const handleToggle = () => {
|
||||
console.log('Toggle Autoplay');
|
||||
clipStore.toggleAutoplay();
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Toggle Autoplay" placement="bottom">
|
||||
<Switch
|
||||
className="custom-switch"
|
||||
onChange={handleToggle}
|
||||
checked={clipStore.autoplay}
|
||||
checkedChildren={<CaretRightOutlined className="switch-icon"/>}
|
||||
unCheckedChildren={<PauseOutlined className="switch-icon"/>}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(AutoplayToggle);
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React, {useRef} from 'react';
|
||||
import {App, Button, ButtonProps} from "antd";
|
||||
import {useStore} from "@/mstore";
|
||||
import {observer} from "mobx-react-lite";
|
||||
import {Tour, TourProps} from ".store/antd-virtual-7db13b4af6/package";
|
||||
import {DislikeFilled, DislikeOutlined, LikeFilled, LikeOutlined} from "@ant-design/icons";
|
||||
|
||||
interface Props {
|
||||
clip?: any
|
||||
}
|
||||
|
||||
function ClipFeedback(props: Props) {
|
||||
const {clipStore} = useStore();
|
||||
const currentClip = clipStore.currentClip;
|
||||
const ref1 = useRef(null);
|
||||
const {message} = App.useApp();
|
||||
|
||||
const steps: TourProps['steps'] = [
|
||||
{
|
||||
title: 'Upload File',
|
||||
description: 'Put your files here.',
|
||||
cover: (
|
||||
<div>
|
||||
<Button>Upload</Button>
|
||||
</div>
|
||||
),
|
||||
target: () => ref1.current,
|
||||
},
|
||||
];
|
||||
|
||||
const interestStatus = currentClip?.interested;
|
||||
const disabled = interestStatus != null
|
||||
const isInterestedProps: ButtonProps = interestStatus === true ? {
|
||||
color: "primary",
|
||||
variant: "outlined",
|
||||
icon: <LikeFilled/>,
|
||||
} : {
|
||||
icon: <LikeOutlined/>,
|
||||
onClick: () => submitFeedback(true)
|
||||
};
|
||||
|
||||
const isNotInterestedProps: ButtonProps = interestStatus === false ? {
|
||||
color: "primary",
|
||||
variant: "outlined",
|
||||
icon: <DislikeFilled/>,
|
||||
} : {
|
||||
icon: <DislikeOutlined/>,
|
||||
onClick: () => submitFeedback(false)
|
||||
};
|
||||
|
||||
// if (disabled) {
|
||||
// isInterestedProps.disabled = true;
|
||||
// isNotInterestedProps.disabled = true;
|
||||
// } else {
|
||||
// isInterestedProps.disabled = false;
|
||||
// isNotInterestedProps.disabled = false;
|
||||
// }
|
||||
|
||||
const submitFeedback = async (isInterested: boolean) => {
|
||||
await clipStore.sendFeedback(isInterested).then(() => {
|
||||
message.success('Your feedback has been submitted');
|
||||
}).catch(() => {
|
||||
message.error('There was an error submitting your feedback');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 bottom-0 z-10 flex flex-col gap-4 mr-4" style={{marginBottom: '1rem'}}>
|
||||
{clipStore.tour && <Tour open={clipStore.tour} steps={steps} onClose={() => clipStore.toggleTour()}/>}
|
||||
<Button
|
||||
ref={ref1}
|
||||
shape="circle"
|
||||
{...isInterestedProps}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
{...isNotInterestedProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ClipFeedback);
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import cn from 'classnames';
|
||||
import stl from 'Components/Session_/Player/player.module.css';
|
||||
import {
|
||||
IPlayerContext,
|
||||
PlayerContext,
|
||||
} from 'Components/Session/playerContext';
|
||||
import ClipPlayerControls from 'Components/Session/Player/ClipPlayer/ClipPlayerControls';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import Session from 'Types/session';
|
||||
import styles from 'Components/Session_/playerBlock.module.css';
|
||||
import ClipPlayerOverlay from 'Components/Session/Player/ClipPlayer/ClipPlayerOverlay';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
range: [number, number];
|
||||
autoplay: boolean;
|
||||
isHighlight?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function ClipPlayerContent(props: Props) {
|
||||
const playerContext = React.useContext<IPlayerContext>(PlayerContext);
|
||||
const screenWrapper = React.useRef<HTMLDivElement>(null);
|
||||
const { time } = playerContext.store.get();
|
||||
const { range } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!playerContext.player) return;
|
||||
|
||||
const parentElement = findDOMNode(
|
||||
screenWrapper.current
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
if (parentElement && playerContext.player) {
|
||||
playerContext.player?.attach(parentElement);
|
||||
playerContext.player?.play();
|
||||
}
|
||||
}, [playerContext.player]);
|
||||
|
||||
React.useEffect(() => {
|
||||
playerContext.player.scale();
|
||||
}, [playerContext.player]);
|
||||
|
||||
useEffect(() => {
|
||||
if (time < range[0]) {
|
||||
playerContext.player?.jump(range[0]);
|
||||
}
|
||||
if (time > range[1]) {
|
||||
playerContext.store.update({ completed: true });
|
||||
playerContext.player?.pause();
|
||||
}
|
||||
}, [time]);
|
||||
|
||||
if (!playerContext.player) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')}
|
||||
>
|
||||
<div className={cn(stl.playerBody, 'flex-1 flex flex-col relative')}>
|
||||
<div className={cn(stl.playerBody, 'flex flex-1 flex-col relative')}>
|
||||
<div className="relative flex-1 overflow-hidden group">
|
||||
<ClipPlayerOverlay autoplay={props.autoplay} />
|
||||
<div
|
||||
className={cn(stl.screenWrapper, stl.checkers)}
|
||||
ref={screenWrapper}
|
||||
data-openreplay-obscured
|
||||
style={{ height: '500px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.isHighlight && props.message ? (
|
||||
<div className={'shadow-inner p-3 bg-yellow flex gap-2 w-full items-center'}>
|
||||
<Icon name="chat-square-quote" color="inherit" size={18} />
|
||||
<div className={'leading-none font-medium'}>
|
||||
{props.message}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ClipPlayerControls
|
||||
session={props.session}
|
||||
range={props.range}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ClipPlayerContent);
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import Timeline from 'Components/Session/Player/ClipPlayer/Timeline';
|
||||
import { PlayButton, PlayingState } from '@/player-ui';
|
||||
import { PlayerContext } from 'Components/Session/playerContext';
|
||||
import { observer } from '.store/mobx-react-lite-virtual-356dc1206a/package';
|
||||
import { Button } from 'antd';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { CirclePlay } from 'lucide-react';
|
||||
import { withSiteId } from '@/routes';
|
||||
import * as routes from '@/routes';
|
||||
import { useStore } from '@/mstore';
|
||||
import Session from 'Types/session';
|
||||
|
||||
function ClipPlayerControls({
|
||||
session,
|
||||
range,
|
||||
}: {
|
||||
session: Session;
|
||||
range: [number, number];
|
||||
}) {
|
||||
const { projectsStore } = useStore();
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
const history = useHistory();
|
||||
const siteId = projectsStore.siteId;
|
||||
|
||||
const { playing, completed } = store.get();
|
||||
|
||||
const state = completed
|
||||
? PlayingState.Completed
|
||||
: playing
|
||||
? PlayingState.Playing
|
||||
: PlayingState.Paused;
|
||||
|
||||
const togglePlay = () => {
|
||||
player.togglePlay();
|
||||
};
|
||||
|
||||
const showFullSession = () => {
|
||||
const path = withSiteId(routes.session(session.sessionId), siteId);
|
||||
history.push(path + '?jumpto=' + Math.round(range[0]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-4 p-3 border-t">
|
||||
<PlayButton state={state} togglePlay={togglePlay} iconSize={30} />
|
||||
<Timeline range={range} />
|
||||
<Button size="small" type='primary' onClick={showFullSession}>
|
||||
Play Full Session
|
||||
<CirclePlay size={16}/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ClipPlayerControls);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Button, Tooltip } from '.store/antd-virtual-7db13b4af6/package';
|
||||
import Session from 'Types/session';
|
||||
import UserCard from 'Components/Session/Player/ClipPlayer/UserCard';
|
||||
import QueueControls from 'Components/Session/Player/ClipPlayer/QueueControls';
|
||||
import { App, Space } from 'antd';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { withSiteId } from '@/routes';
|
||||
import * as routes from '@/routes';
|
||||
import { useStore } from '@/mstore';
|
||||
import { LinkIcon, X } from 'lucide-react';
|
||||
import { PartialSessionBadge } from "Components/Session_/WarnBadge";
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
range: [number, number];
|
||||
onClose?: () => void;
|
||||
isHighlight?: boolean;
|
||||
}
|
||||
|
||||
function ClipPlayerHeader(props: Props) {
|
||||
const { projectsStore } = useStore();
|
||||
const { session, range, onClose, isHighlight } = props;
|
||||
const siteId = projectsStore.siteId;
|
||||
const { message } = App.useApp();
|
||||
|
||||
const copyHandler = () => {
|
||||
const path = withSiteId(routes.session(session.sessionId), siteId + '');
|
||||
copy(window.location.origin + path + '?jumpto=' + Math.round(range[0]));
|
||||
|
||||
void message.success('Session link copied to clipboard');
|
||||
};
|
||||
return (
|
||||
<div className="bg-white p-3 flex justify-between items-center border-b relative">
|
||||
{isHighlight ? <PartialSessionBadge /> : null}
|
||||
<UserCard session={props.session} />
|
||||
|
||||
<Space>
|
||||
<Tooltip title="Copy link to clipboard" placement="bottom">
|
||||
<Button
|
||||
onClick={copyHandler}
|
||||
size={'small'}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{isHighlight ? (
|
||||
<Button icon={<X size={14} strokeWidth={1} />} size={'small'} onClick={onClose} />
|
||||
) : <QueueControls />}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClipPlayerHeader;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {PlayerContext} from "Components/Session/playerContext";
|
||||
import {Loader} from "UI";
|
||||
import PlayIconLayer from "Components/Session_/Player/Overlay/PlayIconLayer";
|
||||
import ClipFeedback from "Components/Session/Player/ClipPlayer/ClipFeedback";
|
||||
import AutoplayTimer from "Components/Session/Player/ClipPlayer/AutoPlayTimer";
|
||||
|
||||
interface Props {
|
||||
autoplay: boolean;
|
||||
}
|
||||
|
||||
function Overlay({ autoplay }: Props) {
|
||||
const {player, store} = React.useContext(PlayerContext);
|
||||
const togglePlay = () => player.togglePlay();
|
||||
|
||||
const {
|
||||
messagesLoading,
|
||||
playing,
|
||||
completed,
|
||||
} = store.get();
|
||||
|
||||
return (
|
||||
<>
|
||||
{messagesLoading ? <Loader/> : null}
|
||||
{/*<div className="hidden group-hover:block">*/}
|
||||
{/* <ClipFeedback/>*/}
|
||||
{/*</div>*/}
|
||||
<PlayIconLayer playing={playing} togglePlay={togglePlay}/>
|
||||
{completed && autoplay && <AutoplayTimer/>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Overlay);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import React, {useEffect} from 'react';
|
||||
import cn from 'classnames';
|
||||
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
|
||||
import {Button, Popover} from 'antd';
|
||||
import {useStore} from 'App/mstore';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import AutoplayToggle from "Shared/AutoplayToggle";
|
||||
// import AutoplayToggle from "Components/Session/Player/ClipPlayer/AutoplayToggle";
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
|
||||
function QueueControls(props: Props) {
|
||||
const {clipStore, projectsStore, sessionStore, searchStore} = useStore();
|
||||
const previousId = clipStore.prevId;
|
||||
const nextId = clipStore.nextId;
|
||||
|
||||
const nextHandler = () => {
|
||||
clipStore.next();
|
||||
};
|
||||
|
||||
const prevHandler = () => {
|
||||
clipStore.prev();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
onClick={prevHandler}
|
||||
className={cn('p-1 group rounded-full', {
|
||||
'pointer-events-none opacity-50': !previousId,
|
||||
'cursor-pointer': !!previousId,
|
||||
})}
|
||||
>
|
||||
<Popover
|
||||
placement="bottom"
|
||||
content={
|
||||
<div className="whitespace-nowrap">Play Previous Session</div>
|
||||
}
|
||||
open={previousId ? undefined : false}
|
||||
mouseEnterDelay={1}
|
||||
>
|
||||
<Button
|
||||
size={'small'}
|
||||
shape={'circle'}
|
||||
disabled={!previousId}
|
||||
className={'flex items-center justify-center'}
|
||||
>
|
||||
<LeftOutlined/>
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<AutoplayToggle/>
|
||||
<div
|
||||
onClick={nextHandler}
|
||||
className={cn('p-1 group ml-1 rounded-full')}
|
||||
>
|
||||
<Popover
|
||||
placement="bottom"
|
||||
content={<div className="whitespace-nowrap">Play Next Session</div>}
|
||||
open={nextId ? undefined : false}
|
||||
mouseEnterDelay={1}
|
||||
>
|
||||
<Button
|
||||
size={'small'}
|
||||
shape={'circle'}
|
||||
// disabled={!nextId}
|
||||
className={'flex items-center justify-center'}
|
||||
>
|
||||
<RightOutlined/>
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(QueueControls);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import {PlayerContext} from 'App/components/Session/playerContext';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ProgressBar} from 'App/player-ui'
|
||||
|
||||
const TimeTracker = ({scale, live = false, left}) => {
|
||||
const {store} = React.useContext(PlayerContext)
|
||||
const {time, range} = store.get()
|
||||
|
||||
const adjustedTime = time - range[0]
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
scale={scale}
|
||||
live={live}
|
||||
left={left}
|
||||
time={adjustedTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TimeTracker.displayName = 'TimeTracker';
|
||||
|
||||
export default observer(TimeTracker);
|
||||
163
frontend/app/components/Session/Player/ClipPlayer/Timeline.tsx
Normal file
163
frontend/app/components/Session/Player/ClipPlayer/Timeline.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React, {useEffect, useMemo, useContext, useState, useRef} from 'react';
|
||||
import stl from '../../../Session_/Player/Controls/timeline.module.css';
|
||||
import {debounce} from 'App/utils';
|
||||
import {PlayerContext} from 'App/components/Session/playerContext';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useStore} from 'App/mstore';
|
||||
import {DateTime, Duration} from 'luxon';
|
||||
import CustomDragLayer, {OnDragCallback} from "Components/Session_/Player/Controls/components/CustomDragLayer";
|
||||
import TooltipContainer from "Components/Session_/Player/Controls/components/TooltipContainer";
|
||||
import TimelineTracker from "Components/Session/Player/ClipPlayer/TimelineTracker";
|
||||
|
||||
function Timeline({ range }: any) {
|
||||
const {player, store} = useContext(PlayerContext);
|
||||
const [wasPlaying, setWasPlaying] = useState(false);
|
||||
const [maxWidth, setMaxWidth] = useState(0);
|
||||
const {settingsStore, sessionStore} = useStore();
|
||||
const startedAt = sessionStore.current.startedAt ?? 0;
|
||||
const tooltipVisible = sessionStore.timeLineTooltip.isVisible;
|
||||
const setTimelineHoverTime = sessionStore.setTimelineTooltip;
|
||||
const timezone = sessionStore.current.timezone;
|
||||
const issues = sessionStore.current.issues ?? [];
|
||||
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
|
||||
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// const scale = 100 / endTime;
|
||||
const trimmedEndTime = range[1] - range[0];
|
||||
const scale = 100 / trimmedEndTime;
|
||||
|
||||
useEffect(() => {
|
||||
const firstIssue = issues[0];
|
||||
|
||||
if (firstIssue && skipToIssue) {
|
||||
player.jump(firstIssue.time);
|
||||
}
|
||||
if (progressRef.current) {
|
||||
setMaxWidth(progressRef.current.clientWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedJump = useMemo(() => debounce(player.jump, 500), []);
|
||||
const debouncedTooltipChange = useMemo(() => debounce(setTimelineHoverTime, 50), []);
|
||||
|
||||
const onDragEnd = () => {
|
||||
if (wasPlaying) {
|
||||
player.togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
const onDrag: OnDragCallback = (offset) => {
|
||||
// @ts-ignore react mismatch
|
||||
const p = offset.x / progressRef.current.offsetWidth;
|
||||
const time = Math.max(Math.round(p * endTime), 0);
|
||||
debouncedJump(time);
|
||||
hideTimeTooltip();
|
||||
if (playing) {
|
||||
setWasPlaying(true);
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const showTimeTooltip = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
e.target !== progressRef.current &&
|
||||
e.target !== timelineRef.current &&
|
||||
// @ts-ignore black magic
|
||||
!progressRef.current.contains(e.target)
|
||||
) {
|
||||
return tooltipVisible && hideTimeTooltip();
|
||||
}
|
||||
|
||||
const time = getTime(e);
|
||||
if (!time) return;
|
||||
const tz = settingsStore.sessionSettings.timezone.value;
|
||||
const timeStr = DateTime.fromMillis(startedAt + time)
|
||||
.setZone(tz)
|
||||
.toFormat(`hh:mm:ss a`);
|
||||
const userTimeStr = timezone
|
||||
? DateTime.fromMillis(startedAt + time)
|
||||
.setZone(timezone)
|
||||
.toFormat(`hh:mm:ss a`)
|
||||
: undefined;
|
||||
|
||||
const timeLineTooltip = {
|
||||
time: Duration.fromMillis(time).toFormat(`mm:ss`),
|
||||
localTime: timeStr,
|
||||
userTime: userTimeStr,
|
||||
offset: e.nativeEvent.pageX,
|
||||
isVisible: true,
|
||||
};
|
||||
|
||||
debouncedTooltipChange(timeLineTooltip);
|
||||
};
|
||||
|
||||
const hideTimeTooltip = () => {
|
||||
const timeLineTooltip = {isVisible: false};
|
||||
debouncedTooltipChange(timeLineTooltip);
|
||||
};
|
||||
|
||||
const seekProgress = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const time = getTime(e);
|
||||
player.jump(time);
|
||||
hideTimeTooltip();
|
||||
};
|
||||
|
||||
const jumpToTime = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLDivElement).id === 'click-ignore') {
|
||||
return;
|
||||
}
|
||||
seekProgress(e);
|
||||
};
|
||||
|
||||
const getTime = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// @ts-ignore react mismatch
|
||||
const p = e.nativeEvent.offsetX / e.target.offsetWidth;
|
||||
const time = Math.round(p * trimmedEndTime) + range[0]; // Map to the defined range
|
||||
return Math.max(range[0], Math.min(time, range[1])); // Clamp to range boundaries
|
||||
};
|
||||
|
||||
// const getTime = (e: React.MouseEvent<HTMLDivElement>, customEndTime?: number) => {
|
||||
// // @ts-ignore react mismatch
|
||||
// const p = e.nativeEvent.offsetX / e.target.offsetWidth;
|
||||
// const targetTime = customEndTime || endTime; // + rangeStart
|
||||
// return Math.max(Math.round(p * targetTime), 0);
|
||||
// };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center w-full"
|
||||
style={{
|
||||
// top: '-4px',
|
||||
// zIndex: 100,
|
||||
// maxWidth: 'calc(100% - 5rem)',
|
||||
// left: '3.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={stl.progress}
|
||||
onClick={ready ? jumpToTime : undefined}
|
||||
ref={progressRef}
|
||||
role="button"
|
||||
onMouseMoveCapture={showTimeTooltip}
|
||||
onMouseEnter={showTimeTooltip}
|
||||
onMouseLeave={hideTimeTooltip}
|
||||
>
|
||||
<TooltipContainer/>
|
||||
<TimelineTracker scale={scale} onDragEnd={onDragEnd}/>
|
||||
<CustomDragLayer
|
||||
onDrag={onDrag}
|
||||
minX={0}
|
||||
maxX={maxWidth}
|
||||
/>
|
||||
|
||||
<div className={stl.timeline} ref={timelineRef}>
|
||||
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes}/> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Timeline);
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import React, {useContext} from 'react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import DraggableCircle from 'Components/Session_/Player/Controls/components/DraggableCircle';
|
||||
import {PlayerContext} from 'Components/Session/playerContext';
|
||||
import TimeTracker from "Components/Session/Player/ClipPlayer/TimeTracker";
|
||||
|
||||
function TimelineTracker({scale, onDragEnd}: { scale: number, onDragEnd: () => void }) {
|
||||
const {store} = useContext(PlayerContext);
|
||||
const {time, range} = store.get();
|
||||
const adjustedTime = time - range[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DraggableCircle left={adjustedTime * scale} onDrop={onDragEnd}/>
|
||||
<TimeTracker scale={scale} left={(adjustedTime - range[0]) * scale}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TimelineTracker);
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react';
|
||||
import {countries} from 'App/constants';
|
||||
import {useStore} from 'App/mstore';
|
||||
import {formatTimeOrDate} from 'App/date';
|
||||
import {Avatar, TextEllipsis, Tooltip} from 'UI';
|
||||
import cn from 'classnames';
|
||||
import {capitalize} from 'App/utils';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import Session from 'Types/session';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const UserCard: React.FC<Props> = ({session, className, width, height}) => {
|
||||
const {settingsStore} = useStore();
|
||||
const {timezone} = settingsStore.sessionSettings;
|
||||
|
||||
const {
|
||||
userBrowser,
|
||||
userDevice,
|
||||
userCountry,
|
||||
userCity,
|
||||
userOs,
|
||||
startedAt,
|
||||
userNumericHash,
|
||||
userDisplayName,
|
||||
} = session;
|
||||
|
||||
const avatarBgSize = '38px';
|
||||
|
||||
return (
|
||||
<div className={cn('bg-white flex items-center w-full', className)} style={{width, height}}>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
iconSize="23"
|
||||
width={avatarBgSize}
|
||||
height={avatarBgSize}
|
||||
seed={userNumericHash}
|
||||
/>
|
||||
<div className="ml-3 overflow-hidden leading-tight">
|
||||
<TextEllipsis noHint className="font-medium">
|
||||
{userDisplayName}
|
||||
</TextEllipsis>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
<span style={{whiteSpace: 'nowrap'}}>
|
||||
<Tooltip
|
||||
title={`${formatTimeOrDate(startedAt, timezone, true)} ${timezone.label}`}
|
||||
className="w-fit !block"
|
||||
>
|
||||
{formatTimeOrDate(startedAt, timezone)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span className="mx-1 font-bold text-xl">·</span>
|
||||
{userCity && <span className="mr-1">{userCity},</span>}
|
||||
<span>{countries[userCountry]}</span>
|
||||
<span className="mx-1 font-bold text-xl">·</span>
|
||||
<span>
|
||||
{userBrowser && `${capitalize(userBrowser)}, `}
|
||||
{`${/ios/i.test(userOs) ? 'iOS' : capitalize(userOs)}, `}
|
||||
{capitalize(userDevice)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(UserCard);
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-shadow:1px 0 0 white,0 1px 0 white,-1px 0 0 white,0 -1px 0 white;
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ function PlayerBlock(props: IProps) {
|
|||
className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')}
|
||||
>
|
||||
{shouldShowSubHeader ? (
|
||||
<SubHeader sessionId={sessionId} jiraConfig={jiraConfig} />
|
||||
<SubHeader setActiveTab={setActiveTab} sessionId={sessionId} jiraConfig={jiraConfig} />
|
||||
) : null}
|
||||
<Player
|
||||
setActiveTab={setActiveTab}
|
||||
|
|
|
|||
|
|
@ -118,13 +118,27 @@ function Player(props: IProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn(stl.playerBody, 'flex-1 flex flex-col relative', fullscreen && 'pb-2')}
|
||||
className={cn(
|
||||
stl.playerBody,
|
||||
'flex-1 flex flex-col relative',
|
||||
fullscreen && 'pb-2'
|
||||
)}
|
||||
data-bottom-block={bottomBlockIsActive}
|
||||
>
|
||||
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
|
||||
<div className={cn('relative flex-1', 'overflow-hidden')}>
|
||||
<div
|
||||
className={cn('relative flex-1', 'overflow-hidden')}
|
||||
id={'player-container'}
|
||||
>
|
||||
{activeTab === 'HIGHLIGHT' ? <div style={{ background: 'rgba(0,0,0, 0.3)' }} className={'w-full h-full z-50 absolute top-0 left-0'} /> : undefined}
|
||||
<Overlay nextId={nextId} />
|
||||
<div className={cn(stl.screenWrapper, isInspMode ? stl.solidBg : stl.checkers)} ref={screenWrapper} />
|
||||
<div
|
||||
className={cn(
|
||||
stl.screenWrapper,
|
||||
isInspMode ? stl.solidBg : stl.checkers
|
||||
)}
|
||||
ref={screenWrapper}
|
||||
/>
|
||||
</div>
|
||||
{!fullscreen && !!bottomBlock && (
|
||||
<div
|
||||
|
|
@ -142,10 +156,14 @@ function Player(props: IProps) {
|
|||
/>
|
||||
{bottomBlock === OVERVIEW && <OverviewPanel />}
|
||||
{bottomBlock === CONSOLE && <ConsolePanel />}
|
||||
{bottomBlock === NETWORK && <WebNetworkPanel panelHeight={panelHeight} />}
|
||||
{bottomBlock === NETWORK && (
|
||||
<WebNetworkPanel panelHeight={panelHeight} />
|
||||
)}
|
||||
{bottomBlock === STACKEVENTS && <WebStackEventPanel />}
|
||||
{bottomBlock === STORAGE && <Storage />}
|
||||
{bottomBlock === PROFILER && <ProfilerPanel panelHeight={panelHeight} />}
|
||||
{bottomBlock === PROFILER && (
|
||||
<ProfilerPanel panelHeight={panelHeight} />
|
||||
)}
|
||||
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
|
||||
{bottomBlock === GRAPHQL && <GraphQL panelHeight={panelHeight} />}
|
||||
{bottomBlock === EXCEPTIONS && <Exceptions />}
|
||||
|
|
@ -154,7 +172,9 @@ function Player(props: IProps) {
|
|||
)}
|
||||
{!fullView ? (
|
||||
<Controls
|
||||
setActiveTab={(tab: string) => activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab)}
|
||||
setActiveTab={(tab: string) =>
|
||||
activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab)
|
||||
}
|
||||
speedDown={playerContext.player.speedDown}
|
||||
speedUp={playerContext.player.speedUp}
|
||||
jump={playerContext.player.jump}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import SummaryBlock from 'Components/Session/Player/ReplayPlayer/SummaryBlock';
|
||||
import React from 'react';
|
||||
import Session from 'Types/session/session';
|
||||
import EventsBlock from '../Session_/EventsBlock';
|
||||
import HighlightPanel from "../Session_/Highlight/HighlightPanel";
|
||||
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel';
|
||||
import TagWatch from 'Components/Session/Player/TagWatch';
|
||||
|
||||
|
|
@ -34,6 +33,12 @@ function RightBlock({
|
|||
<TagWatch />
|
||||
</div>
|
||||
);
|
||||
case 'HIGHLIGHT':
|
||||
return (
|
||||
<div className={cn('bg-white border-l', stl.panel)}>
|
||||
<HighlightPanel onClose={() => setActiveTab('')} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ function EventGroupWrapper(props) {
|
|||
if (isNote) {
|
||||
return (
|
||||
<NoteEvent
|
||||
setActiveTab={props.setActiveTab}
|
||||
note={event}
|
||||
filterOutNote={filterOutNote}
|
||||
noEdit={currentUserId !== event.userId}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ function EventsBlock(props: IProps) {
|
|||
isTabChange={isTabChange}
|
||||
isPrev={isPrev}
|
||||
filterOutNote={filterOutNote}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { useModal } from 'Components/Modal';
|
||||
import CreateNote from 'Components/Session_/Player/Controls/components/CreateNote';
|
||||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { tagProps, Note } from 'App/services/NotesService';
|
||||
import { formatTimeOrDate } from 'App/date';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
@ -13,34 +10,22 @@ import { session } from 'App/routes';
|
|||
import { confirm } from 'UI';
|
||||
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
|
||||
import { Tag } from 'antd'
|
||||
import { MessageSquareDot } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
noEdit: boolean;
|
||||
filterOutNote: (id: number) => void;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
function NoteEvent(props: Props) {
|
||||
const { settingsStore, notesStore } = useStore();
|
||||
const { timezone } = settingsStore.sessionSettings;
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
const onEdit = () => {
|
||||
showModal(
|
||||
<CreateNote
|
||||
hideModal={hideModal}
|
||||
isEdit
|
||||
time={props.note.timestamp}
|
||||
editNote={{
|
||||
timestamp: props.note.timestamp,
|
||||
tag: props.note.tag,
|
||||
isPublic: props.note.isPublic,
|
||||
message: props.note.message,
|
||||
noteId: props.note.noteId.toString(),
|
||||
}}
|
||||
/>,
|
||||
{ right: true, width: 380 }
|
||||
);
|
||||
notesStore.setEditNote(props.note);
|
||||
props.setActiveTab('HIGHLIGHT')
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
|
|
@ -78,9 +63,7 @@ function NoteEvent(props: Props) {
|
|||
return (
|
||||
<div className="flex items-start flex-col p-2 border rounded ps-4" style={{ background: '#FFFEF5' }}>
|
||||
<div className="flex items-center w-full relative">
|
||||
<div className="p-3 bg-gray-light rounded-full">
|
||||
<Icon name="quotes" color="main" />
|
||||
</div>
|
||||
<MessageSquareDot size={16} strokeWidth={1} />
|
||||
<div className="ml-2">
|
||||
<div
|
||||
className="text-base"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react'
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { MessageSquareQuote } from 'lucide-react'
|
||||
import { Icon } from 'UI';
|
||||
|
||||
function HighlightButton({ onClick }: { onClick: () => void }) {
|
||||
|
||||
const openPanel = () => {
|
||||
onClick();
|
||||
}
|
||||
return (
|
||||
<Tooltip title={'Highlight a moment'} placement={'bottom'}>
|
||||
<Button onClick={openPanel} size={'small'}>
|
||||
<Icon name="chat-square-quote" color="inherit" size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default HighlightButton
|
||||
266
frontend/app/components/Session_/Highlight/HighlightPanel.tsx
Normal file
266
frontend/app/components/Session_/Highlight/HighlightPanel.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import React from 'react';
|
||||
import { Button, Checkbox, Input, Tag } from 'antd';
|
||||
import { X } from 'lucide-react';
|
||||
import { TAGS, iTag, tagProps } from 'App/services/NotesService';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Icon } from 'UI';
|
||||
import { PlayerContext } from 'Components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { shortDurationFromMs } from 'App/date';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function maskDuration(input: string): string {
|
||||
const digits = input.replace(/\D/g, '');
|
||||
|
||||
const limitedDigits = digits.slice(0, 4);
|
||||
|
||||
if (limitedDigits.length <= 2) {
|
||||
return limitedDigits;
|
||||
}
|
||||
|
||||
return `${limitedDigits.slice(0, 2)}:${limitedDigits.slice(2)}`;
|
||||
}
|
||||
const duration = new RegExp(/(\d{2}):(\d{2})/);
|
||||
|
||||
function HighlightPanel({ onClose }: { onClose: () => void }) {
|
||||
const { uiPlayerStore, notesStore, sessionStore } = useStore();
|
||||
const editNote = notesStore.editNote;
|
||||
const [message, setMessage] = React.useState(editNote?.message ?? '');
|
||||
const [isPublic, setIsPublic] = React.useState(editNote?.isPublic ?? false);
|
||||
const { store, player } = React.useContext(PlayerContext);
|
||||
const currentTime = store.get().time;
|
||||
|
||||
const startTsStr = shortDurationFromMs(
|
||||
editNote?.startAt ?? uiPlayerStore.highlightSelection.startTs
|
||||
);
|
||||
const endTsStr = shortDurationFromMs(
|
||||
editNote?.endAt ?? uiPlayerStore.highlightSelection.endTs
|
||||
);
|
||||
const [startTs, setStartTs] = React.useState(startTsStr);
|
||||
const [endTs, setEndTs] = React.useState(endTsStr);
|
||||
const [tag, setTag] = React.useState(editNote?.tag ?? TAGS[0]);
|
||||
|
||||
const onStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newState = maskDuration(e.target.value)
|
||||
setStartTs(newState);
|
||||
if (duration.test(newState)) {
|
||||
const [_, minutes, seconds] = duration.exec(newState) ?? [];
|
||||
const newTime = (parseInt(minutes) * 60 + parseInt(seconds))*1000;
|
||||
const sessLength = store.get().endTime;
|
||||
uiPlayerStore.toggleHighlightSelection({
|
||||
enabled: true,
|
||||
range: [Math.min(newTime, sessLength), uiPlayerStore.highlightSelection.endTs],
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const onEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newState = maskDuration(e.target.value)
|
||||
setEndTs(newState);
|
||||
if (duration.test(newState)) {
|
||||
const [_, minutes, seconds] = duration.exec(newState) ?? [];
|
||||
const newTime = (parseInt(minutes) * 60 + parseInt(seconds))*1000;
|
||||
const sessLength = store.get().endTime;
|
||||
uiPlayerStore.toggleHighlightSelection({
|
||||
enabled: true,
|
||||
range: [uiPlayerStore.highlightSelection.startTs, Math.min(newTime, sessLength)],
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const playing = store.get().playing;
|
||||
|
||||
React.useEffect(() => {
|
||||
player.pause();
|
||||
const time = store.get().time;
|
||||
const endTime = store.get().endTime;
|
||||
const distance = Math.max(endTime / 40, 2500);
|
||||
uiPlayerStore.toggleHighlightSelection({
|
||||
enabled: true,
|
||||
range: [Math.max(time - distance, 0), Math.min(time + distance, endTime)],
|
||||
});
|
||||
return () => {
|
||||
uiPlayerStore.toggleHighlightSelection({
|
||||
enabled: false,
|
||||
});
|
||||
notesStore.setEditNote(null)
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const startStr = shortDurationFromMs(
|
||||
uiPlayerStore.highlightSelection.startTs
|
||||
);
|
||||
const endStr = shortDurationFromMs(uiPlayerStore.highlightSelection.endTs);
|
||||
setStartTs(startStr);
|
||||
setEndTs(endStr);
|
||||
}, [
|
||||
uiPlayerStore.highlightSelection.startTs,
|
||||
uiPlayerStore.highlightSelection.endTs,
|
||||
]);
|
||||
React.useEffect(() => {
|
||||
player.pause();
|
||||
}, [playing]);
|
||||
|
||||
const addTag = (newTag: iTag) => {
|
||||
setTag(newTag);
|
||||
};
|
||||
const tagActive = (checkedTag: iTag) => {
|
||||
return tag === checkedTag;
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
notesStore.setSaving(true)
|
||||
const playerContainer = document.querySelector('iframe')?.contentWindow?.document.body;
|
||||
let thumbnail;
|
||||
if (playerContainer) {
|
||||
thumbnail = await elementToImage(playerContainer);
|
||||
}
|
||||
const note = {
|
||||
message,
|
||||
tag: tag,
|
||||
isPublic,
|
||||
timestamp: parseInt(currentTime, 10),
|
||||
startAt: parseInt(uiPlayerStore.highlightSelection.startTs, 10),
|
||||
endAt: parseInt(uiPlayerStore.highlightSelection.endTs, 10),
|
||||
thumbnail,
|
||||
}
|
||||
if (editNoteId) {
|
||||
await notesStore.updateNote(editNoteId, note);
|
||||
toast.success('Highlight updated');
|
||||
} else {
|
||||
const sessionId = sessionStore.current.sessionId;
|
||||
await notesStore.addNote(sessionId, note);
|
||||
toast.success('Highlight saved. Find it in Home > Highlights');
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error('Failed to save highlight');
|
||||
} finally {
|
||||
notesStore.setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'w-full p-4 flex flex-col gap-4'}
|
||||
style={{ width: 270 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Icon name="chat-square-quote" color="inherit" size={16} />
|
||||
<h3 className={'text-xl font-semibold'}>
|
||||
{editNote ? 'Edit ' : ''}Highlight
|
||||
</h3>
|
||||
<div className={'cursor-pointer ml-auto'} onClick={onClose}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
Save key moments from sessions. Access them anytime on the ‘Highlights’
|
||||
page to share with your team.
|
||||
</div>
|
||||
<div>
|
||||
<Input.TextArea
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={'Enter Comments'}
|
||||
maxLength={200}
|
||||
rows={6}
|
||||
value={message}
|
||||
className="rounded-lg"
|
||||
autoFocus
|
||||
/>
|
||||
<div className={'text-disabled-text text-sm'}>
|
||||
{message.length}/200 characters remaining
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div>
|
||||
<div className={'font-semibold'}>From</div>
|
||||
<Input
|
||||
value={startTs}
|
||||
onChange={onStartChange}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={'font-semibold'}>To</div>
|
||||
<Input value={endTs} onChange={onEndChange} className="rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 flex-wrap'}>
|
||||
{TAGS.map((tag) => (
|
||||
<Tag
|
||||
onClick={() => addTag(tag)}
|
||||
key={tag}
|
||||
className="cursor-pointer rounded-lg hover:bg-indigo-50 mr-0"
|
||||
color={tagProps[tag]}
|
||||
bordered={false}
|
||||
>
|
||||
<div className={'flex items-center gap-1 text-sm'}>
|
||||
{tagActive(tag) ? (
|
||||
<Icon name="check-circle-fill" color="inherit" size={13} />
|
||||
) : null}
|
||||
{tag}
|
||||
</div>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
value={isPublic}
|
||||
className="ms-2"
|
||||
>
|
||||
Visible to team members
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
type={'primary'}
|
||||
loading={notesStore.isSaving}
|
||||
className="font-medium"
|
||||
>
|
||||
<Icon name="chat-square-quote" color="inherit" size={14} /> {editNote ? 'Update ' : 'Save '}
|
||||
Highlight
|
||||
</Button>
|
||||
<Button onClick={onClose} type="text" className="font-medium">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
window.__debugElementToImage = (el) => elementToImage(el).then(img => {
|
||||
const a = document.createElement('a');
|
||||
a.href = img;
|
||||
a.download = 'highlight.png';
|
||||
a.click();
|
||||
});
|
||||
|
||||
function elementToImage(el) {
|
||||
return import('html2canvas').then(({ default: html2canvas }) => {
|
||||
return html2canvas(
|
||||
el,
|
||||
{
|
||||
scale: 1,
|
||||
allowTaint: true,
|
||||
useCORS: false,
|
||||
logging: true,
|
||||
foreignObjectRendering: false,
|
||||
height: 900,
|
||||
width: 1200,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
).then((canvas) => {
|
||||
return canvas.toDataURL('img/png');
|
||||
}).catch(e => {
|
||||
console.log(e);
|
||||
return undefined
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default observer(HighlightPanel);
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import React from 'react';
|
||||
import stl from './issuesModal.module.css';
|
||||
import IssueForm from './IssueForm';
|
||||
import cn from 'classnames'
|
||||
|
||||
const IssuesModal = ({
|
||||
sessionId,
|
||||
closeHandler,
|
||||
}) => {
|
||||
return (
|
||||
<div className={ stl.wrapper }>
|
||||
<div className={cn(stl.wrapper, 'h-screen')}>
|
||||
<h3 className="text-xl font-semibold">
|
||||
<span>Create Issue</span>
|
||||
</h3>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import DraggableMarkers from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
|
||||
import React, { useEffect, useMemo, useContext, useState, useRef } from 'react';
|
||||
import stl from './timeline.module.css';
|
||||
import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer';
|
||||
|
|
@ -12,6 +11,7 @@ import { WebEventsList, MobEventsList } from './EventsList';
|
|||
import NotesList from './NotesList';
|
||||
import SkipIntervalsList from './SkipIntervalsList';
|
||||
import TimelineTracker from 'Components/Session_/Player/Controls/TimelineTracker';
|
||||
import { ZoomDragLayer, HighlightDragLayer } from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
|
||||
|
||||
function Timeline({ isMobile }: { isMobile: boolean }) {
|
||||
const { player, store } = useContext(PlayerContext);
|
||||
|
|
@ -24,6 +24,7 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
|
|||
const timezone = sessionStore.current.timezone;
|
||||
const issues = sessionStore.current.issues ?? [];
|
||||
const timelineZoomEnabled = uiPlayerStore.timelineZoom.enabled;
|
||||
const highlightEnabled = uiPlayerStore.highlightSelection.enabled;
|
||||
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
|
||||
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -132,7 +133,8 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
|
|||
left: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{timelineZoomEnabled ? <DraggableMarkers scale={scale} /> : null}
|
||||
{timelineZoomEnabled ? <ZoomDragLayer scale={scale} /> : null}
|
||||
{highlightEnabled ? <HighlightDragLayer scale={scale} /> : null}
|
||||
<div
|
||||
className={stl.progress}
|
||||
onClick={ready ? jumpToTime : undefined}
|
||||
|
|
@ -143,15 +145,13 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
|
|||
onMouseLeave={hideTimeTooltip}
|
||||
>
|
||||
<TooltipContainer />
|
||||
<TimelineTracker scale={scale} onDragEnd={onDragEnd} />
|
||||
<CustomDragLayer
|
||||
onDrag={onDrag}
|
||||
minX={0}
|
||||
maxX={maxWidth}
|
||||
/>
|
||||
{highlightEnabled ? null : <TimelineTracker scale={scale} onDragEnd={onDragEnd} />}
|
||||
<CustomDragLayer onDrag={onDrag} minX={0} maxX={maxWidth} />
|
||||
|
||||
<div className={stl.timeline} ref={timelineRef}>
|
||||
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
|
||||
{devtoolsLoading || domLoading || !ready ? (
|
||||
<div className={stl.stripes} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMobile ? <MobEventsList /> : <WebEventsList />}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Tag } from 'antd';
|
||||
import { Tag, Input } from 'antd';
|
||||
import { Duration } from 'luxon';
|
||||
import React from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
tagProps,
|
||||
} from 'App/services/NotesService';
|
||||
import { Button, Checkbox, Icon } from 'UI';
|
||||
import { shortDurationFromMs } from 'App/date';
|
||||
|
||||
import Select from 'Shared/Select';
|
||||
|
||||
|
|
@ -208,7 +209,7 @@ function CreateNote({
|
|||
|
||||
<div className="">
|
||||
<div className={'font-semibold'}>Note</div>
|
||||
<textarea
|
||||
<Input.TextArea
|
||||
ref={inputRef}
|
||||
name="message"
|
||||
id="message"
|
||||
|
|
@ -221,6 +222,16 @@ function CreateNote({
|
|||
}}
|
||||
className="text-area"
|
||||
/>
|
||||
<div className={'flex w-full items-center gap-1 my-2'}>
|
||||
{editNote && editNote?.startAt ? (
|
||||
<>
|
||||
<span>From: </span>
|
||||
<Input disabled readOnly value={shortDurationFromMs(editNote?.startAt)} />
|
||||
<span>To: </span>
|
||||
<Input disabled readOnly value={shortDurationFromMs(editNote?.endAt)} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1" style={{ lineHeight: '15px' }}>
|
||||
{TAGS.map((tag) => (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import {Keyboard} from 'lucide-react'
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useModal } from "../../../../Modal";
|
||||
import { useModal } from 'Components/Modal';
|
||||
|
||||
const Key = ({ label }: { label: string }) => <div style={{ minWidth: 52 }} className="whitespace-nowrap font-normal bg-indigo-50 rounded-lg px-2 py-1 text-figmaColors-text-primary text-center font-mono">{label}</div>;
|
||||
const Key = ({ label }: { label: string }) => (
|
||||
<div
|
||||
style={{ minWidth: 52 }}
|
||||
className="whitespace-nowrap font-normal bg-indigo-50 rounded-lg px-2 py-1 text-figmaColors-text-primary text-center font-mono"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
function Cell({ shortcut, text }: any) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-start rounded">
|
||||
|
|
@ -14,25 +20,24 @@ function Cell({ shortcut, text }: any) {
|
|||
);
|
||||
}
|
||||
|
||||
export const LaunchConsoleShortcut = () => <Key label={'⇧ + C'} />;
|
||||
export const LaunchNetworkShortcut = () => <Key label={'⇧ + N'} />;
|
||||
export const LaunchPerformanceShortcut = () => <Key label={'⇧ + P'} />;
|
||||
export const LaunchStateShortcut = () => <Key label={'⇧ + R'} />;
|
||||
export const LaunchEventsShortcut = () => <Key label={'⇧ + E'} />;
|
||||
export const PlaySessionInFullscreenShortcut = () => <Key label={'⇧ + F'} />;
|
||||
export const PlayPauseSessionShortcut = () => <Key label={'Space'} />;
|
||||
export const LaunchXRaShortcut = () => <Key label={'⇧ + X'} />;
|
||||
export const LaunchUserActionsShortcut = () => <Key label={'⇧ + A'} />;
|
||||
export const LaunchMoreUserInfoShortcut = () => <Key label={'⇧ + I'} />;
|
||||
export const LaunchOptionsMenuShortcut = () => <Key label={'⇧ + M'} />;
|
||||
export const PlayNextSessionShortcut = () => <Key label={'⇧ + >'} />;
|
||||
export const PlayPreviousSessionShortcut = () => <Key label={'⇧ + <'} />;
|
||||
export const SkipForwardShortcut = () => <Key label={'→'} />;
|
||||
export const SkipBackwardShortcut = () => <Key label={'←'} />;
|
||||
export const PlaybackSpeedShortcut = () => <Key label={'↑ / ↓'} />;
|
||||
|
||||
export const LaunchConsoleShortcut = () => <Key label={"⇧ + C"} />
|
||||
export const LaunchNetworkShortcut = () => <Key label={"⇧ + N"} />
|
||||
export const LaunchPerformanceShortcut = () => <Key label={"⇧ + P"} />
|
||||
export const LaunchStateShortcut = () => <Key label={"⇧ + R"} />
|
||||
export const LaunchEventsShortcut = () => <Key label={"⇧ + E"} />
|
||||
export const PlaySessionInFullscreenShortcut = () => <Key label={"⇧ + F"} />
|
||||
export const PlayPauseSessionShortcut = () => <Key label={"Space"} />
|
||||
export const LaunchXRaShortcut = () => <Key label={"⇧ + X"} />
|
||||
export const LaunchUserActionsShortcut = () => <Key label={"⇧ + A"} />
|
||||
export const LaunchMoreUserInfoShortcut = () => <Key label={"⇧ + I"} />
|
||||
export const LaunchOptionsMenuShortcut = () => <Key label={"⇧ + M"} />
|
||||
export const PlayNextSessionShortcut = () => <Key label={"⇧ + >"} />
|
||||
export const PlayPreviousSessionShortcut = () => <Key label={"⇧ + <"} />
|
||||
export const SkipForwardShortcut = () => <Key label={"→"} />
|
||||
export const SkipBackwardShortcut = () => <Key label={"←"} />
|
||||
export const PlaybackSpeedShortcut = () => <Key label={"↑ / ↓"} />
|
||||
|
||||
function ShortcutGrid() {
|
||||
export function ShortcutGrid() {
|
||||
return (
|
||||
<div className={'p-4 overflow-y-auto h-screen'}>
|
||||
<div className={'mb-4 font-semibold text-xl'}>Keyboard Shortcuts</div>
|
||||
|
|
@ -63,16 +68,16 @@ function ShortcutGrid() {
|
|||
function KeyboardHelp() {
|
||||
const { showModal } = useModal();
|
||||
return (
|
||||
<Tooltip placement='bottom' title='Keyboard Shortcuts'>
|
||||
<Button
|
||||
size={'small'}
|
||||
className={'flex items-center justify-center'}
|
||||
onClick={() => {
|
||||
showModal(<ShortcutGrid />, { right: true, width: 320 })
|
||||
}}
|
||||
>
|
||||
<Keyboard size={18}/>
|
||||
</Button>
|
||||
<Tooltip placement="bottom" title="Keyboard Shortcuts">
|
||||
<Button
|
||||
size={'small'}
|
||||
className={'flex items-center justify-center'}
|
||||
onClick={() => {
|
||||
showModal(<ShortcutGrid />, { right: true, width: 320 });
|
||||
}}
|
||||
>
|
||||
<Keyboard size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ function TimeTooltip() {
|
|||
const { time = 0, offset = 0, isVisible, localTime, userTime } = timeLineTooltip;
|
||||
return (
|
||||
<div
|
||||
className={stl.timeTooltip}
|
||||
className={`${stl.timeTooltip} p-2 rounded-lg min-w-40 max-w-64`}
|
||||
style={{
|
||||
top: 0,
|
||||
left: `calc(${offset}px - 0.5rem)`,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,119 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from "App/mstore";
|
||||
import { useStore } from 'App/mstore';
|
||||
import { getTimelinePosition } from 'Components/Session_/Player/Controls/getTimelinePosition';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { shortDurationFromMs } from 'App/date';
|
||||
import { throttle } from 'App/utils';
|
||||
|
||||
interface Props {
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const DraggableMarkers = ({
|
||||
scale,
|
||||
}: Props) => {
|
||||
export const HighlightDragLayer = observer(({ scale }: Props) => {
|
||||
const { uiPlayerStore } = useStore();
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
const sessEnd = store.get().endTime;
|
||||
const toggleHighlight = uiPlayerStore.toggleHighlightSelection;
|
||||
const timelineHighlightStartTs = uiPlayerStore.highlightSelection.startTs;
|
||||
const timelineHighlightEndTs = uiPlayerStore.highlightSelection.endTs;
|
||||
const lastStartTs = React.useRef(timelineHighlightStartTs);
|
||||
const lastEndTs = React.useRef(timelineHighlightEndTs);
|
||||
|
||||
const [throttledJump] = React.useMemo(
|
||||
() => throttle(player.jump, 25),
|
||||
[player]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (timelineHighlightStartTs !== lastStartTs.current) {
|
||||
player.pause();
|
||||
throttledJump(timelineHighlightStartTs, true);
|
||||
lastStartTs.current = timelineHighlightStartTs;
|
||||
return;
|
||||
}
|
||||
if (timelineHighlightEndTs !== lastEndTs.current) {
|
||||
player.pause();
|
||||
throttledJump(timelineHighlightEndTs, true);
|
||||
lastEndTs.current = timelineHighlightEndTs;
|
||||
return;
|
||||
}
|
||||
}, [timelineHighlightStartTs, timelineHighlightEndTs]);
|
||||
|
||||
const onDrag = (start: number, end: number) => {
|
||||
toggleHighlight({
|
||||
enabled: true,
|
||||
range: [start, end],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableMarkers
|
||||
scale={scale}
|
||||
onDragEnd={onDrag}
|
||||
defaultStartPos={timelineHighlightStartTs}
|
||||
defaultEndPos={timelineHighlightEndTs}
|
||||
sessEnd={sessEnd}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ZoomDragLayer = observer(({ scale }: Props) => {
|
||||
const { uiPlayerStore } = useStore();
|
||||
const toggleZoom = uiPlayerStore.toggleZoom;
|
||||
const timelineZoomStartTs = uiPlayerStore.timelineZoom.startTs;
|
||||
const timelineZoomEndTs = uiPlayerStore.timelineZoom.endTs;
|
||||
|
||||
const onDrag = (start: number, end: number) => {
|
||||
toggleZoom({
|
||||
enabled: true,
|
||||
range: [start, end],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableMarkers
|
||||
scale={scale}
|
||||
onDragEnd={onDrag}
|
||||
defaultStartPos={timelineZoomStartTs}
|
||||
defaultEndPos={timelineZoomEndTs}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function DraggableMarkers({
|
||||
scale,
|
||||
onDragEnd,
|
||||
defaultStartPos,
|
||||
defaultEndPos,
|
||||
sessEnd,
|
||||
}: {
|
||||
scale: Props['scale'];
|
||||
onDragEnd: (start: number, end: number) => void;
|
||||
defaultStartPos: number;
|
||||
defaultEndPos: number;
|
||||
sessEnd?: number;
|
||||
}) {
|
||||
const [startPos, setStartPos] = useState(
|
||||
getTimelinePosition(timelineZoomStartTs, scale)
|
||||
getTimelinePosition(defaultStartPos, scale)
|
||||
);
|
||||
const [endPos, setEndPos] = useState(
|
||||
getTimelinePosition(timelineZoomEndTs, scale)
|
||||
getTimelinePosition(defaultEndPos, scale)
|
||||
);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
setStartPos(getTimelinePosition(defaultStartPos, scale));
|
||||
setEndPos(getTimelinePosition(defaultEndPos, scale));
|
||||
}, [
|
||||
defaultEndPos,
|
||||
defaultStartPos,
|
||||
scale,
|
||||
dragging
|
||||
])
|
||||
|
||||
const convertToPercentage = useCallback(
|
||||
(clientX: number, element: HTMLElement) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
|
@ -50,19 +142,13 @@ const DraggableMarkers = ({
|
|||
if (endPos - newPos <= minDistance) {
|
||||
setEndPos(newPos + minDistance);
|
||||
}
|
||||
toggleZoom({
|
||||
enabled: true,
|
||||
range: [newPos / scale, endPos / scale],
|
||||
});
|
||||
onDragEnd(newPos / scale, endPos / scale);
|
||||
} else if (dragging === 'end') {
|
||||
setEndPos(newPos);
|
||||
if (newPos - startPos <= minDistance) {
|
||||
setStartPos(newPos - minDistance);
|
||||
}
|
||||
toggleZoom({
|
||||
enabled: true,
|
||||
range: [startPos / scale, newPos / scale],
|
||||
});
|
||||
onDragEnd(startPos / scale, newPos / scale);
|
||||
} else if (dragging === 'body') {
|
||||
const offset = (endPos - startPos) / 2;
|
||||
let newStartPos = newPos - offset;
|
||||
|
|
@ -76,31 +162,38 @@ const DraggableMarkers = ({
|
|||
}
|
||||
setStartPos(newStartPos);
|
||||
setEndPos(newEndPos);
|
||||
toggleZoom({
|
||||
enabled: true,
|
||||
range: [newStartPos / scale, newEndPos / scale],
|
||||
});
|
||||
setTimeout(() => {
|
||||
onDragEnd(newStartPos / scale, newEndPos / scale);
|
||||
}, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
[dragging, startPos, endPos, scale, toggleZoom]
|
||||
[dragging, startPos, endPos, scale, onDragEnd]
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragging(null);
|
||||
}, []);
|
||||
|
||||
const barSize = 104;
|
||||
const centering = -41;
|
||||
const topPadding = 41;
|
||||
const uiSize = 16;
|
||||
|
||||
const startRangeStr = shortDurationFromMs(Math.max(defaultStartPos, 0));
|
||||
const endRangeStr = shortDurationFromMs(
|
||||
Math.min(defaultEndPos, sessEnd ?? defaultEndPos)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onMouseMove={onDrag}
|
||||
onMouseLeave={endDrag}
|
||||
onMouseUp={endDrag}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '24px',
|
||||
height: barSize,
|
||||
left: 0,
|
||||
top: '-4px',
|
||||
top: centering,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
|
|
@ -110,86 +203,114 @@ const DraggableMarkers = ({
|
|||
style={{
|
||||
position: 'absolute',
|
||||
left: `${startPos}%`,
|
||||
height: '100%',
|
||||
background: '#FCC100',
|
||||
height: uiSize,
|
||||
top: topPadding,
|
||||
background: dragging && dragging !== 'start' ? '#c2970a' : '#FCC100',
|
||||
cursor: 'ew-resize',
|
||||
borderTopLeftRadius: 3,
|
||||
borderBottomLeftRadius: 3,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: 3,
|
||||
paddingLeft: 6,
|
||||
width: 18,
|
||||
paddingRight: 1,
|
||||
paddingLeft: 3,
|
||||
width: 10,
|
||||
opacity: dragging && dragging !== 'start' ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
{dragging === 'start' ? (
|
||||
<div
|
||||
className={
|
||||
'absolute bg-[#FCC100] text-black rounded-xl px-2 py-1 -top-10 select-none left-1/2 -translate-x-1/2'
|
||||
}
|
||||
>
|
||||
{startRangeStr}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={'bg-black rounded-xl'}
|
||||
className={'bg-black/20 rounded-xl'}
|
||||
style={{
|
||||
zIndex: 101,
|
||||
height: 18,
|
||||
width: 2,
|
||||
marginRight: 3,
|
||||
height: 16,
|
||||
width: 1,
|
||||
marginRight: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={'bg-black rounded-xl'}
|
||||
style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }}
|
||||
className={'bg-black/20 rounded-xl'}
|
||||
style={{ zIndex: 101, height: 16, width: 1, overflow: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="slider-body"
|
||||
onMouseDown={startDrag('body')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${startPos}% + 18px)`,
|
||||
width: `calc(${endPos - startPos}% - 18px)`,
|
||||
height: '100%',
|
||||
background: 'rgba(252, 193, 0, 0.10)',
|
||||
borderTop: '2px solid #FCC100',
|
||||
borderBottom: '2px solid #FCC100',
|
||||
cursor: 'grab',
|
||||
zIndex: 100,
|
||||
}}
|
||||
/>
|
||||
className="slider-body"
|
||||
onMouseDown={startDrag('body')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${startPos}% + 10px)`,
|
||||
width: `calc(${endPos - startPos}% - 10px)`,
|
||||
height: uiSize,
|
||||
top: topPadding,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(252, 193, 0, 0.3),
|
||||
rgba(252, 193, 0, 0.3) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
)`,
|
||||
borderTop: `1px solid ${dragging ? '#c2970a' : '#FCC100'}`,
|
||||
borderBottom: `1px solid ${dragging ? '#c2970a' : '#FCC100'}`,
|
||||
cursor: 'grab',
|
||||
zIndex: 100,
|
||||
opacity: dragging ? 0.8 : 1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="marker end"
|
||||
onMouseDown={startDrag('end')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${endPos}%`,
|
||||
height: '100%',
|
||||
background: '#FCC100',
|
||||
height: uiSize,
|
||||
top: topPadding,
|
||||
background: dragging && dragging !== 'end' ? '#c2970a' : '#FCC100',
|
||||
cursor: 'ew-resize',
|
||||
borderTopRightRadius: 3,
|
||||
borderBottomRightRadius: 3,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 3,
|
||||
paddingRight: 6,
|
||||
width: 18,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
width: 10,
|
||||
opacity: dragging && dragging !== 'end' ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
{dragging === 'end' ? (
|
||||
<div
|
||||
className={
|
||||
'absolute bg-[#FCC100] text-black rounded-xl px-2 p-1 -top-10 select-none left-1/2 -translate-x-1/2'
|
||||
}
|
||||
>
|
||||
{endRangeStr}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={'bg-black rounded-xl'}
|
||||
className={'bg-black/20 rounded-xl'}
|
||||
style={{
|
||||
zIndex: 101,
|
||||
height: 18,
|
||||
width: 2,
|
||||
marginRight: 3,
|
||||
height: 16,
|
||||
width: 1,
|
||||
marginRight: 2,
|
||||
marginLeft: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={'bg-black rounded-xl'}
|
||||
style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }}
|
||||
className={'bg-black/20 rounded-xl'}
|
||||
style={{ zIndex: 101, height: 16, width: 1, overflow: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(DraggableMarkers);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,40 @@
|
|||
import { ShareAltOutlined } from '@ant-design/icons';
|
||||
import { Button as AntButton, Switch, Tooltip } from 'antd';
|
||||
import { Button as AntButton, Switch, Tooltip, Dropdown } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import { Link2 } from 'lucide-react';
|
||||
import { useModal } from "Components/Modal";
|
||||
import IssuesModal from "Components/Session_/Issues/IssuesModal";
|
||||
import { Link2, Keyboard } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { MoreOutlined } from '@ant-design/icons'
|
||||
import { Icon } from 'UI';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { IFRAME } from 'App/constants/storageKeys';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { checkParam, truncateStringToFit } from 'App/utils';
|
||||
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
|
||||
import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp';
|
||||
import { ShortcutGrid } from 'Components/Session_/Player/Controls/components/KeyboardHelp';
|
||||
import WarnBadge from 'Components/Session_/WarnBadge';
|
||||
|
||||
import Bookmark from 'Shared/Bookmark';
|
||||
import { toast } from "react-toastify";
|
||||
import HighlightButton from './Highlight/HighlightButton';
|
||||
|
||||
import SharePopup from '../shared/SharePopup/SharePopup';
|
||||
import Issues from './Issues/Issues';
|
||||
import QueueControls from './QueueControls';
|
||||
import NotePopup from './components/NotePopup';
|
||||
import { Bookmark as BookmarkIcn, BookmarkCheck, Vault } from "lucide-react";
|
||||
|
||||
const disableDevtools = 'or_devtools_uxt_toggle';
|
||||
|
||||
function SubHeader(props) {
|
||||
const { uxtestingStore, integrationsStore, sessionStore, projectsStore } = useStore();
|
||||
const {
|
||||
uxtestingStore,
|
||||
integrationsStore,
|
||||
sessionStore,
|
||||
projectsStore,
|
||||
userStore,
|
||||
issueReportingStore,
|
||||
} = useStore();
|
||||
const favorite = sessionStore.current.favorite;
|
||||
const isEnterprise = userStore.isEnterprise;
|
||||
const currentSession = sessionStore.current
|
||||
const projectId = projectsStore.siteId;
|
||||
const integrations = integrationsStore.issues.list;
|
||||
|
|
@ -31,6 +42,9 @@ function SubHeader(props) {
|
|||
const { location: currentLocation = 'loading...' } = store.get();
|
||||
const hasIframe = localStorage.getItem(IFRAME) === 'true';
|
||||
const [hideTools, setHideTools] = React.useState(false);
|
||||
const [isFavorite, setIsFavorite] = React.useState(favorite);
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
React.useEffect(() => {
|
||||
const hideDevtools = checkParam('hideTools');
|
||||
if (hideDevtools) {
|
||||
|
|
@ -46,6 +60,27 @@ function SubHeader(props) {
|
|||
return integrations.some((i) => i.token);
|
||||
}, [integrations]);
|
||||
|
||||
const issuesIntegrationList = integrationsStore.issues.list;
|
||||
const handleOpenIssueModal = () => {
|
||||
issueReportingStore.init();
|
||||
if (!issueReportingStore.projectsFetched) {
|
||||
issueReportingStore.fetchProjects().then((projects) => {
|
||||
if (projects && projects[0]) {
|
||||
void issueReportingStore.fetchMeta(projects[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
showModal(
|
||||
<IssuesModal
|
||||
provider={reportingProvider}
|
||||
sessionId={currentSession.sessionId}
|
||||
closeHandler={hideModal}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
const reportingProvider = issuesIntegrationList[0]?.provider || '';
|
||||
|
||||
const locationTruncated = truncateStringToFit(
|
||||
currentLocation,
|
||||
window.innerWidth - 200
|
||||
|
|
@ -56,6 +91,32 @@ function SubHeader(props) {
|
|||
uxtestingStore.setHideDevtools(!enabled);
|
||||
};
|
||||
|
||||
const showKbHelp = () => {
|
||||
showModal(<ShortcutGrid />, { right: true, width: 320 });
|
||||
}
|
||||
|
||||
const vaultIcon = isEnterprise ? (
|
||||
<Vault size={16} strokeWidth={1} />
|
||||
) : isFavorite ? (
|
||||
<BookmarkCheck size={16} strokeWidth={1} />
|
||||
) : (
|
||||
<BookmarkIcn size={16} strokeWidth={1} />
|
||||
);
|
||||
const toggleFavorite = () => {
|
||||
const onToggleFavorite = sessionStore.toggleFavorite;
|
||||
const ADDED_MESSAGE = isEnterprise
|
||||
? 'Session added to vault'
|
||||
: 'Session added to your bookmarks';
|
||||
const REMOVED_MESSAGE = isEnterprise
|
||||
? 'Session removed from vault'
|
||||
: 'Session removed from your bookmarks';
|
||||
|
||||
onToggleFavorite(currentSession.sessionId).then(() => {
|
||||
toast.success(isFavorite ? REMOVED_MESSAGE : ADDED_MESSAGE);
|
||||
setIsFavorite(!isFavorite);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -84,10 +145,6 @@ function SubHeader(props) {
|
|||
)}
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
<KeyboardHelp />
|
||||
<Bookmark sessionId={currentSession.sessionId} />
|
||||
<NotePopup />
|
||||
{enabledIntegration && <Issues sessionId={currentSession.sessionId} />}
|
||||
<SharePopup
|
||||
showCopyLink={true}
|
||||
trigger={
|
||||
|
|
@ -103,6 +160,44 @@ function SubHeader(props) {
|
|||
</div>
|
||||
}
|
||||
/>
|
||||
<HighlightButton onClick={() => props.setActiveTab('HIGHLIGHT')} />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
|
||||
{
|
||||
key: '2',
|
||||
label: <div className={'flex items-center gap-2'}>
|
||||
{vaultIcon}
|
||||
<span>{isEnterprise ? 'Vault' : 'Bookmark'}</span>
|
||||
</div>,
|
||||
onClick: toggleFavorite
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
label: <div className={'flex items-center gap-2'}>
|
||||
<Icon name={`integrations/${reportingProvider || 'github'}`} />
|
||||
<span>Issues</span>
|
||||
</div>,
|
||||
disabled: !enabledIntegration,
|
||||
onClick: handleOpenIssueModal,
|
||||
},
|
||||
{
|
||||
key: '1',
|
||||
label: <div className={'flex items-center gap-2'}>
|
||||
<Keyboard size={16} strokeWidth={1} />
|
||||
<span>Keyboard Shortcuts</span>
|
||||
</div>,
|
||||
onClick: showKbHelp
|
||||
}
|
||||
]
|
||||
}}
|
||||
>
|
||||
<AntButton size={'small'}>
|
||||
<MoreOutlined />
|
||||
</AntButton>
|
||||
</Dropdown>
|
||||
|
||||
|
||||
{uxtestingStore.isUxt() ? (
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Alert } from 'antd';
|
||||
import { Icon } from 'UI';
|
||||
const localhostWarn = (project: string) => project + '_localhost_warn';
|
||||
|
||||
|
|
@ -137,4 +138,21 @@ const WarnBadge = React.memo(
|
|||
}
|
||||
);
|
||||
|
||||
export function PartialSessionBadge() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
style={{
|
||||
zIndex: 999,
|
||||
position: 'absolute',
|
||||
left: '61%',
|
||||
bottom: '1.3rem',
|
||||
}}
|
||||
>
|
||||
<Alert message="You are viewing a portion of full session" type="info" className='border-0 rounded-lg py-0.5' showIcon/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WarnBadge;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Link2 } from 'lucide-react';
|
|||
import React, { useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TextEllipsis } from "UI";
|
||||
|
||||
import { Spot } from 'App/mstore/types/spot';
|
||||
import { spot as spotUrl, withSiteId } from 'App/routes';
|
||||
|
|
@ -128,11 +129,7 @@ function SpotListItem({
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
|
|
@ -140,6 +137,85 @@ function SpotListItem({
|
|||
itemName={spot.title}
|
||||
/>
|
||||
) : null}
|
||||
<GridItem
|
||||
modifier={
|
||||
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
|
||||
<Tooltip title={tooltipText} className='capitalize'>
|
||||
<div
|
||||
className={
|
||||
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg transition-transform transform translate-y-14 group-hover:translate-y-0 '
|
||||
}
|
||||
onClick={copyToClipboard}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Link2 size={16} strokeWidth={1} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className={
|
||||
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg flex items-center cursor-normal'
|
||||
}
|
||||
>
|
||||
{spot.duration}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onSave={onSave}
|
||||
setIsEdit={setIsEdit}
|
||||
isEdit={isEdit}
|
||||
title={spot.title}
|
||||
onItemClick={onSpotClick}
|
||||
thumbnail={spot.thumbnail}
|
||||
setLoading={setLoading}
|
||||
loading={loading}
|
||||
isSelected={isSelected}
|
||||
tooltipText={tooltipText}
|
||||
copyToClipboard={copyToClipboard}
|
||||
duration={spot.duration}
|
||||
onSelect={onSelect}
|
||||
user={spot.user}
|
||||
createdAt={spot.createdAt}
|
||||
menuItems={menuItems}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GridItem({
|
||||
title,
|
||||
onItemClick,
|
||||
thumbnail,
|
||||
setLoading,
|
||||
loading,
|
||||
isSelected,
|
||||
onSelect,
|
||||
user,
|
||||
createdAt,
|
||||
menuItems,
|
||||
onMenuClick,
|
||||
modifier,
|
||||
}: {
|
||||
title: string;
|
||||
onItemClick: () => void;
|
||||
thumbnail: string;
|
||||
setLoading: (loading: boolean) => void;
|
||||
loading?: boolean;
|
||||
isSelected?: boolean;
|
||||
copyToClipboard: () => void;
|
||||
onSelect?: (selected: boolean) => void;
|
||||
user: string;
|
||||
createdAt: string;
|
||||
menuItems: any[];
|
||||
onMenuClick: (key: any) => void;
|
||||
modifier: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
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`}
|
||||
>
|
||||
<div
|
||||
className="relative group overflow-hidden"
|
||||
style={{
|
||||
|
|
@ -157,11 +233,11 @@ function SpotListItem({
|
|||
)}
|
||||
<div
|
||||
className="block w-full h-full cursor-pointer transition hover:bg-teal/70 relative"
|
||||
onClick={onSpotClick}
|
||||
onClick={onItemClick}
|
||||
>
|
||||
<img
|
||||
src={spot.thumbnail}
|
||||
alt={spot.title}
|
||||
src={thumbnail}
|
||||
alt={title}
|
||||
className={'w-full h-full object-cover opacity-80'}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => setLoading(false)}
|
||||
|
|
@ -175,49 +251,36 @@ function SpotListItem({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
|
||||
<Tooltip title={tooltipText}>
|
||||
<div
|
||||
className={
|
||||
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg transition-transform transform translate-y-14 group-hover:translate-y-0 '
|
||||
}
|
||||
onClick={copyToClipboard}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Link2 size={16} strokeWidth={1} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className={
|
||||
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg flex items-center cursor-normal'
|
||||
}
|
||||
>
|
||||
{spot.duration}
|
||||
</div>
|
||||
</div>
|
||||
{modifier}
|
||||
</div>
|
||||
<div className={'px-4 py-4 w-full border-t'}>
|
||||
<div className={'w-full border-t'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<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>
|
||||
{onSelect ? (
|
||||
<div className='px-3 pt-2'>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={({ target: { checked } }) => onSelect(checked)}
|
||||
className={`flex cursor-pointer w-full hover:text-teal ${
|
||||
isSelected ? 'text-teal' : ''
|
||||
}`}
|
||||
>
|
||||
<TextEllipsis text={title} className='w-full'/>
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : (
|
||||
<div className='bg-yellow/50 mx-2 mt-2 px-2 w-full rounded '><TextEllipsis text={title} className='capitalize' /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex items-center gap-1 leading-4 text-xs opacity-50'}>
|
||||
<div className={'flex items-center gap-1 leading-4 text-xs opacity-50 p-3'}>
|
||||
<div>
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div>{spot.user}</div>
|
||||
<div className="ms-4">
|
||||
<TextEllipsis text={user} className='capitalize' />
|
||||
<div className="ml-auto">
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div>{spot.createdAt}</div>
|
||||
<div className={'ml-auto'}>
|
||||
<div>{createdAt}</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{ items: menuItems, onClick: onMenuClick }}
|
||||
trigger={['click']}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const SpotsListHeader = observer(
|
|||
value={spotStore.filter === 'all' ? 'All Spots' : 'My Spots'}
|
||||
onChange={handleSegmentChange}
|
||||
className="mr-4 lg:hidden xl:flex"
|
||||
size={'small'}
|
||||
/>
|
||||
|
||||
<div className="w-56">
|
||||
|
|
@ -88,6 +89,7 @@ const SpotsListHeader = observer(
|
|||
onChange={handleInputChange}
|
||||
onSearch={onSearch}
|
||||
className="rounded-lg"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Link, confirm } from 'UI';
|
||||
import PlayLink from 'Shared/SessionItem/PlayLink';
|
||||
import { tagProps, Note } from 'App/services/NotesService';
|
||||
import { formatTimeOrDate } from 'App/date';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
|
|||
|
|
@ -449,6 +449,7 @@ export { default as Social_trello } from './social_trello';
|
|||
export { default as Sparkles } from './sparkles';
|
||||
export { default as Speedometer2 } from './speedometer2';
|
||||
export { default as Spinner } from './spinner';
|
||||
export { default as Square_mouse_pointer } from './square_mouse_pointer';
|
||||
export { default as Star } from './star';
|
||||
export { default as Step_forward } from './step_forward';
|
||||
export { default as Stickies } from './stickies';
|
||||
|
|
|
|||
19
frontend/app/components/ui/Icons/square_mouse_pointer.tsx
Normal file
19
frontend/app/components/ui/Icons/square_mouse_pointer.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Square_mouse_pointer(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Square_mouse_pointer;
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -12,7 +12,8 @@ export function getDateFromString(date: string, format = 'yyyy-MM-dd HH:mm:ss:SS
|
|||
/**
|
||||
* Formats a given duration.
|
||||
*
|
||||
* @param {Duration | number} inputDuration - The duration to format. Can be a Duration object or a number representing milliseconds.
|
||||
* @param {Duration | number} inputDuration - The duration to format. Can be a Duration object or a number representing
|
||||
* milliseconds.
|
||||
* @returns {string} - Formatted duration string.
|
||||
*
|
||||
* @example
|
||||
|
|
@ -163,7 +164,7 @@ export const checkForRecent = (date: DateTime, format: string): string => {
|
|||
// Formatted
|
||||
return date.toFormat(format);
|
||||
};
|
||||
export const resentOrDate = (ts) => {
|
||||
export const resentOrDate = (ts, short?: boolean) => {
|
||||
const date = DateTime.fromMillis(ts);
|
||||
const d = new Date();
|
||||
// Today
|
||||
|
|
@ -171,7 +172,7 @@ export const resentOrDate = (ts) => {
|
|||
|
||||
// Yesterday
|
||||
if (date.hasSame(d.setDate(d.getDate() - 1), 'day')) return 'Yesterday at ' + date.toFormat('hh:mm a');
|
||||
return date.toFormat('LLL dd, yyyy, hh:mm a');
|
||||
return date.toFormat(`LLL dd, yyyy${short ? '' : ', hh:mm a'}`);
|
||||
}
|
||||
|
||||
export const checkRecentTime = (date, format) => {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,8 @@ 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),
|
||||
[MENU.HIGHLIGHTS]: () => withSiteId(routes.highlights(''), siteId),
|
||||
};
|
||||
|
||||
const handleClick = (item: any) => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const enum MENU {
|
|||
VAULT = 'vault',
|
||||
BOOKMARKS = 'bookmarks',
|
||||
NOTES = 'notes',
|
||||
HIGHLIGHTS = 'highlights',
|
||||
LIVE_SESSIONS = 'live-sessions',
|
||||
DASHBOARDS = 'dashboards',
|
||||
CARDS = 'cards',
|
||||
|
|
@ -63,7 +64,8 @@ export const categories: Category[] = [
|
|||
{ label: 'Recommendations', key: MENU.RECOMMENDATIONS, icon: 'magic', hidden: true },
|
||||
{ label: 'Vault', key: MENU.VAULT, icon: 'safe', hidden: true },
|
||||
{ label: 'Bookmarks', key: MENU.BOOKMARKS, icon: 'bookmark' },
|
||||
{ label: 'Notes', key: MENU.NOTES, icon: 'stickies' }
|
||||
//{ label: 'Notes', key: MENU.NOTES, icon: 'stickies' },
|
||||
{ label: 'Highlights', key: MENU.HIGHLIGHTS, icon: 'chat-square-quote' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,17 +9,32 @@ export default class NotesStore {
|
|||
sessionNotes: Note[] = []
|
||||
loading: boolean
|
||||
page = 1
|
||||
pageSize = 10
|
||||
pageSize = 9
|
||||
activeTags: iTag[] = []
|
||||
sort = 'createdAt'
|
||||
order: 'DESC' | 'ASC' = 'DESC'
|
||||
ownOnly = false
|
||||
total = 0
|
||||
isSaving = false;
|
||||
query = ''
|
||||
editNote: Note | null = null
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
setEditNote = (note: Note | null) => {
|
||||
this.editNote = note
|
||||
}
|
||||
|
||||
setQuery = (query: string) => {
|
||||
this.query = query
|
||||
}
|
||||
|
||||
setSaving = (saving: boolean) => {
|
||||
this.isSaving = saving
|
||||
}
|
||||
|
||||
setLoading(loading: boolean) {
|
||||
this.loading = loading
|
||||
}
|
||||
|
|
@ -40,7 +55,8 @@ export default class NotesStore {
|
|||
order: this.order,
|
||||
tags: this.activeTags,
|
||||
mineOnly: this.ownOnly,
|
||||
sharedOnly: false
|
||||
sharedOnly: false,
|
||||
search: this.query,
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
|
|
@ -48,7 +64,18 @@ export default class NotesStore {
|
|||
const { notes, count } = await notesService.fetchNotes(filter);
|
||||
this.setNotes(notes);
|
||||
this.setTotal(count)
|
||||
return notes;
|
||||
return { notes, total: count };
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchNoteById = async (noteId: string)=> {
|
||||
this.setLoading(true)
|
||||
try {
|
||||
return await notesService.fetchNoteById(noteId);
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
|
|
@ -115,7 +142,7 @@ export default class NotesStore {
|
|||
}
|
||||
}
|
||||
|
||||
getNoteById(noteId: number, notes?: Note[]) {
|
||||
getNoteById(noteId: any, notes?: Note[]) {
|
||||
const notesSource = notes ? notes : this.notes
|
||||
|
||||
return notesSource.find(note => note.noteId === noteId)
|
||||
|
|
@ -128,24 +155,19 @@ export default class NotesStore {
|
|||
toggleTag(tag?: iTag) {
|
||||
if (!tag) {
|
||||
this.activeTags = []
|
||||
this.fetchNotes()
|
||||
} else {
|
||||
this.activeTags = [tag]
|
||||
this.fetchNotes()
|
||||
}
|
||||
}
|
||||
|
||||
toggleShared(ownOnly: boolean) {
|
||||
this.ownOnly = ownOnly
|
||||
this.fetchNotes()
|
||||
}
|
||||
|
||||
toggleSort(sort: string) {
|
||||
const sortOrder = sort.split('-')[1]
|
||||
// @ts-ignore
|
||||
this.order = sortOrder
|
||||
|
||||
this.fetchNotes()
|
||||
}
|
||||
|
||||
async sendSlackNotification(noteId: string, webhook: string) {
|
||||
|
|
|
|||
|
|
@ -394,21 +394,23 @@ export default class SessionStore {
|
|||
const wasInFavorite =
|
||||
this.favoriteList.findIndex(({ sessionId }) => sessionId === id) > -1;
|
||||
|
||||
if (session) {
|
||||
session.favorite = !wasInFavorite;
|
||||
this.list[sessionIdx] = session;
|
||||
}
|
||||
if (current.sessionId === id) {
|
||||
this.current.favorite = !wasInFavorite;
|
||||
}
|
||||
runInAction(() => {
|
||||
if (session) {
|
||||
session.favorite = !wasInFavorite;
|
||||
this.list[sessionIdx] = session;
|
||||
}
|
||||
if (current.sessionId === id) {
|
||||
this.current.favorite = !wasInFavorite;
|
||||
}
|
||||
|
||||
if (wasInFavorite) {
|
||||
this.favoriteList = this.favoriteList.filter(
|
||||
({ sessionId }) => sessionId !== id
|
||||
);
|
||||
} else {
|
||||
this.favoriteList.push(session);
|
||||
}
|
||||
if (wasInFavorite) {
|
||||
this.favoriteList = this.favoriteList.filter(
|
||||
({ sessionId }) => sessionId !== id
|
||||
);
|
||||
} else {
|
||||
this.favoriteList.push(session);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error(r);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class Spot {
|
|||
this.comments = data.comments ?? [];
|
||||
this.thumbnail = data.previewURL
|
||||
this.title = data.name;
|
||||
this.createdAt = resentOrDate(new Date(data.createdAt).getTime());
|
||||
this.createdAt = resentOrDate(new Date(data.createdAt).getTime(), true);
|
||||
this.user = data.userEmail;
|
||||
this.duration = shortDurationFromMs(data.duration);
|
||||
this.spotId = data.id
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ export default class UiPlayerStore {
|
|||
startTs: 0,
|
||||
endTs: 0,
|
||||
}
|
||||
highlightSelection = {
|
||||
enabled: false,
|
||||
startTs: 0,
|
||||
endTs: 0,
|
||||
}
|
||||
zoomTab: 'overview' | 'journey' | 'issues' | 'errors' = 'overview'
|
||||
dataSource: 'all' | 'current' = 'all'
|
||||
|
||||
|
|
@ -113,6 +118,12 @@ export default class UiPlayerStore {
|
|||
this.timelineZoom.endTs = payload.range?.[1] ?? 0;
|
||||
}
|
||||
|
||||
toggleHighlightSelection = (payload: ToggleZoomPayload) => {
|
||||
this.highlightSelection.enabled = payload.enabled;
|
||||
this.highlightSelection.startTs = payload.range?.[0] ?? 0;
|
||||
this.highlightSelection.endTs = payload.range?.[1] ?? 0;
|
||||
}
|
||||
|
||||
setZoomTab = (tab: 'overview' | 'journey' | 'issues' | 'errors') => {
|
||||
this.zoomTab = tab;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,3 +88,23 @@ export function createLiveWebPlayer(
|
|||
const player = new WebLivePlayer(store, session, config, agentId, projectId, uiErrorHandler)
|
||||
return [player, store]
|
||||
}
|
||||
|
||||
export function createClipPlayer(
|
||||
session: SessionFilesInfo,
|
||||
wrapStore?: (s: IWebPlayerStore) => IWebPlayerStore,
|
||||
uiErrorHandler?: { error: (msg: string) => void },
|
||||
range?: [number, number]
|
||||
): [IWebPlayer, IWebPlayerStore] {
|
||||
let store: WebPlayerStore = new SimpleStore<WebState>({
|
||||
...WebPlayer.INITIAL_STATE,
|
||||
});
|
||||
if (wrapStore) {
|
||||
store = wrapStore(store);
|
||||
}
|
||||
|
||||
const player = new WebPlayer(store, session, false, false, uiErrorHandler);
|
||||
if (range && range[0] !== range[1]) {
|
||||
player.toggleRange(range[0], range[1]);
|
||||
}
|
||||
return [player, store];
|
||||
}
|
||||
|
|
@ -195,11 +195,13 @@ export default class Animator {
|
|||
}
|
||||
|
||||
// jump by index?
|
||||
jump = (time: number) => {
|
||||
jump = (time: number, silent?: boolean) => {
|
||||
if (this.store.get().playing && this.store.get().ready) {
|
||||
cancelAnimationFrame(this.animationFrameRequestId)
|
||||
this.setTime(time)
|
||||
this.startAnimation()
|
||||
if (!silent) {
|
||||
this.startAnimation()
|
||||
}
|
||||
this.store.update({ livePlay: time === this.store.get().endTime })
|
||||
} else {
|
||||
this.setTime(time)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export default class Player extends Animator {
|
|||
autoplay: initialAutoplay,
|
||||
skip: initialSkip,
|
||||
speed: initialSpeed,
|
||||
range: [0, 0] as [number, number],
|
||||
} as const
|
||||
|
||||
constructor(private pState: Store<State & AnimatorGetState>, private manager: IMessageManager) {
|
||||
|
|
@ -105,8 +106,11 @@ export default class Player extends Animator {
|
|||
const { speed } = this.pState.get()
|
||||
this.updateSpeed(Math.max(1, speed / 2))
|
||||
}
|
||||
/* === === */
|
||||
|
||||
// toggle range (start, end)
|
||||
toggleRange(start: number, end: number) {
|
||||
this.pState.update({ range: [start, end] })
|
||||
}
|
||||
|
||||
clean() {
|
||||
this.pause()
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ export default class WebPlayer extends Player {
|
|||
this.screen.cursor.showTag(name)
|
||||
}
|
||||
|
||||
// toggle range -> from super
|
||||
toggleRange = (start: number, end: number) => {
|
||||
super.toggleRange(start, end)
|
||||
}
|
||||
|
||||
changeTab = (tab: string) => {
|
||||
const playing = this.wpState.get().playing
|
||||
this.pause()
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ 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';
|
||||
|
||||
export const highlights = (): string => '/highlights';
|
||||
|
||||
const REQUIRED_SITE_ID_ROUTES = [
|
||||
liveSession(''),
|
||||
session(''),
|
||||
|
|
@ -188,6 +190,8 @@ const REQUIRED_SITE_ID_ROUTES = [
|
|||
usabilityTestingCreate(),
|
||||
usabilityTestingEdit(''),
|
||||
usabilityTestingView(''),
|
||||
|
||||
highlights(),
|
||||
];
|
||||
const routeNeedsSiteId = (path: string): boolean => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
|
||||
const siteIdToUrl = (siteId = ':siteId'): string => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import APIClient from 'App/api_client';
|
|||
|
||||
export const tagProps = {
|
||||
'ISSUE': 'red',
|
||||
'QUERY': 'geekblue',
|
||||
'TASK': 'purple',
|
||||
'OTHER': '',
|
||||
'DESIGN': 'geekblue',
|
||||
'NOTE': 'purple',
|
||||
}
|
||||
|
||||
export type iTag = keyof typeof tagProps | "ALL"
|
||||
|
|
@ -14,11 +13,12 @@ export const TAGS = Object.keys(tagProps) as unknown as (keyof typeof tagProps)[
|
|||
|
||||
export interface WriteNote {
|
||||
message: string
|
||||
tag: iTag
|
||||
tag: string
|
||||
isPublic: boolean
|
||||
timestamp: number
|
||||
noteId?: string
|
||||
author?: string
|
||||
timestamp?: number
|
||||
startAt: number
|
||||
endAt: number
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
|
|
@ -33,6 +33,9 @@ export interface Note {
|
|||
timestamp: number
|
||||
userId: number
|
||||
userName: string
|
||||
startAt: number
|
||||
endAt: number
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface NotesFilter {
|
||||
|
|
@ -43,6 +46,7 @@ export interface NotesFilter {
|
|||
tags: iTag[]
|
||||
sharedOnly: boolean
|
||||
mineOnly: boolean
|
||||
search: string
|
||||
}
|
||||
|
||||
export default class NotesService {
|
||||
|
|
@ -62,6 +66,12 @@ export default class NotesService {
|
|||
})
|
||||
}
|
||||
|
||||
fetchNoteById(noteId: string): Promise<Note> {
|
||||
return this.client.get(`/notes/${noteId}`).then(r => {
|
||||
return r.json().then(r => r.data)
|
||||
})
|
||||
}
|
||||
|
||||
getNotesBySessionId(sessionID: string): Promise<Note[]> {
|
||||
return this.client.get(`/sessions/${sessionID}/notes`)
|
||||
.then(r => {
|
||||
|
|
|
|||
1
frontend/app/svg/icons/square-mouse-pointer.svg
Normal file
1
frontend/app/svg/icons/square-mouse-pointer.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-mouse-pointer"><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/></svg>
|
||||
|
After Width: | Height: | Size: 421 B |
|
|
@ -401,37 +401,41 @@ export function simpleThrottle(func: (...args: any[]) => void, limit: number): (
|
|||
};
|
||||
}
|
||||
|
||||
export function throttle(func, wait, options) {
|
||||
var context, args, result;
|
||||
var timeout = null;
|
||||
var previous = 0;
|
||||
if (!options) options = {};
|
||||
var later = function () {
|
||||
previous = options.leading === false ? 0 : Date.now();
|
||||
timeout = null;
|
||||
result = func.apply(context, args);
|
||||
if (!timeout) context = args = null;
|
||||
};
|
||||
return function () {
|
||||
var now = Date.now();
|
||||
if (!previous && options.leading === false) previous = now;
|
||||
var remaining = wait - (now - previous);
|
||||
context = this;
|
||||
args = arguments;
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
previous = now;
|
||||
result = func.apply(context, args);
|
||||
if (!timeout) context = args = null;
|
||||
} else if (!timeout && options.trailing !== false) {
|
||||
timeout = setTimeout(later, remaining);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
export const throttle = <R, A extends any[]>(
|
||||
fn: (...args: A) => R,
|
||||
delay: number
|
||||
): [(...args: A) => R | undefined, () => void, () => void] => {
|
||||
let wait = false;
|
||||
let timeout: undefined | number;
|
||||
let cancelled = false;
|
||||
|
||||
function resetWait() {
|
||||
wait = false;
|
||||
}
|
||||
|
||||
return [
|
||||
(...args: A) => {
|
||||
if (cancelled) return undefined;
|
||||
if (wait) return undefined;
|
||||
|
||||
const val = fn(...args);
|
||||
|
||||
wait = true;
|
||||
|
||||
timeout = window.setTimeout(resetWait, delay);
|
||||
|
||||
return val;
|
||||
},
|
||||
() => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
},
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
resetWait();
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function deleteCookie(name: string, path: string, domain: string) {
|
||||
document.cookie =
|
||||
|
|
|
|||
|
|
@ -167,3 +167,48 @@ export function tryFilterUrl(url: string) {
|
|||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeRequests(
|
||||
webTrackedRequests: SpotNetworkRequest[],
|
||||
proxyNetworkRequests: SpotNetworkRequest[],
|
||||
): SpotNetworkRequest[] {
|
||||
const map = new Map<string, SpotNetworkRequest>();
|
||||
const webReqClone = webTrackedRequests.map((r) => ({ ...r }));
|
||||
const makeKey = (r: SpotNetworkRequest) =>
|
||||
`${r.statusCode}::${r.method}::${r.url}::${Math.round(r.timestamp).toString().slice(0, -2) + "00"}`;
|
||||
|
||||
for (const proxyReq of proxyNetworkRequests) {
|
||||
map.set(makeKey(proxyReq), proxyReq);
|
||||
}
|
||||
|
||||
const merged: SpotNetworkRequest[] = [];
|
||||
for (const webReq of webReqClone) {
|
||||
if (webReq.url.includes("ingest/v1/web/i")) {
|
||||
continue;
|
||||
}
|
||||
const key = makeKey(webReq);
|
||||
const found = map.get(key);
|
||||
if (found) {
|
||||
if (
|
||||
found.responseBody &&
|
||||
found.responseBody.length > 0 &&
|
||||
found.responseBody !== "{}"
|
||||
) {
|
||||
webReq.responseBody = found.responseBody;
|
||||
webReq.responseBodySize = found.responseBodySize;
|
||||
if (webReq.encodedBodySize < found.encodedBodySize) {
|
||||
webReq.encodedBodySize = found.encodedBodySize;
|
||||
}
|
||||
}
|
||||
merged.push(webReq);
|
||||
map.delete(key);
|
||||
} else {
|
||||
webReq.responseBody = JSON.stringify({
|
||||
message: "Spot was unable to track this request's data",
|
||||
});
|
||||
merged.push(webReq);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue