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')),
|
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
|
||||||
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
|
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
|
||||||
ScopeSetup: lazy(() => import('Components/ScopeForm')),
|
ScopeSetup: lazy(() => import('Components/ScopeForm')),
|
||||||
|
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList')),
|
||||||
};
|
};
|
||||||
|
|
||||||
const enhancedComponents: any = {
|
const enhancedComponents: any = {
|
||||||
|
|
@ -58,6 +59,7 @@ const enhancedComponents: any = {
|
||||||
SpotsList: withSiteIdUpdater(components.SpotsListPure),
|
SpotsList: withSiteIdUpdater(components.SpotsListPure),
|
||||||
Spot: components.SpotPure,
|
Spot: components.SpotPure,
|
||||||
ScopeSetup: components.ScopeSetup,
|
ScopeSetup: components.ScopeSetup,
|
||||||
|
Highlights: components.HighlightsPure,
|
||||||
};
|
};
|
||||||
|
|
||||||
const withSiteId = routes.withSiteId;
|
const withSiteId = routes.withSiteId;
|
||||||
|
|
@ -105,6 +107,8 @@ const SPOTS_LIST_PATH = routes.spotsList();
|
||||||
const SPOT_PATH = routes.spot();
|
const SPOT_PATH = routes.spot();
|
||||||
const SCOPE_SETUP = routes.scopeSetup();
|
const SCOPE_SETUP = routes.scopeSetup();
|
||||||
|
|
||||||
|
const HIGHLIGHTS_PATH = routes.highlights();
|
||||||
|
|
||||||
function PrivateRoutes() {
|
function PrivateRoutes() {
|
||||||
const { projectsStore, userStore, integrationsStore } = useStore();
|
const { projectsStore, userStore, integrationsStore } = useStore();
|
||||||
const onboarding = userStore.onboarding;
|
const onboarding = userStore.onboarding;
|
||||||
|
|
@ -236,6 +240,12 @@ function PrivateRoutes() {
|
||||||
path={withSiteId(RECORDINGS_PATH, siteIdList)}
|
path={withSiteId(RECORDINGS_PATH, siteIdList)}
|
||||||
component={enhancedComponents.Assist}
|
component={enhancedComponents.Assist}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
strict
|
||||||
|
path={withSiteId(HIGHLIGHTS_PATH, siteIdList)}
|
||||||
|
component={enhancedComponents.Highlights}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
strict
|
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')}
|
className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')}
|
||||||
>
|
>
|
||||||
{shouldShowSubHeader ? (
|
{shouldShowSubHeader ? (
|
||||||
<SubHeader sessionId={sessionId} jiraConfig={jiraConfig} />
|
<SubHeader setActiveTab={setActiveTab} sessionId={sessionId} jiraConfig={jiraConfig} />
|
||||||
) : null}
|
) : null}
|
||||||
<Player
|
<Player
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
|
|
|
||||||
|
|
@ -118,13 +118,27 @@ function Player(props: IProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
data-bottom-block={bottomBlockIsActive}
|
||||||
>
|
>
|
||||||
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
|
{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} />
|
<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>
|
</div>
|
||||||
{!fullscreen && !!bottomBlock && (
|
{!fullscreen && !!bottomBlock && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -142,10 +156,14 @@ function Player(props: IProps) {
|
||||||
/>
|
/>
|
||||||
{bottomBlock === OVERVIEW && <OverviewPanel />}
|
{bottomBlock === OVERVIEW && <OverviewPanel />}
|
||||||
{bottomBlock === CONSOLE && <ConsolePanel />}
|
{bottomBlock === CONSOLE && <ConsolePanel />}
|
||||||
{bottomBlock === NETWORK && <WebNetworkPanel panelHeight={panelHeight} />}
|
{bottomBlock === NETWORK && (
|
||||||
|
<WebNetworkPanel panelHeight={panelHeight} />
|
||||||
|
)}
|
||||||
{bottomBlock === STACKEVENTS && <WebStackEventPanel />}
|
{bottomBlock === STACKEVENTS && <WebStackEventPanel />}
|
||||||
{bottomBlock === STORAGE && <Storage />}
|
{bottomBlock === STORAGE && <Storage />}
|
||||||
{bottomBlock === PROFILER && <ProfilerPanel panelHeight={panelHeight} />}
|
{bottomBlock === PROFILER && (
|
||||||
|
<ProfilerPanel panelHeight={panelHeight} />
|
||||||
|
)}
|
||||||
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
|
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
|
||||||
{bottomBlock === GRAPHQL && <GraphQL panelHeight={panelHeight} />}
|
{bottomBlock === GRAPHQL && <GraphQL panelHeight={panelHeight} />}
|
||||||
{bottomBlock === EXCEPTIONS && <Exceptions />}
|
{bottomBlock === EXCEPTIONS && <Exceptions />}
|
||||||
|
|
@ -154,7 +172,9 @@ function Player(props: IProps) {
|
||||||
)}
|
)}
|
||||||
{!fullView ? (
|
{!fullView ? (
|
||||||
<Controls
|
<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}
|
speedDown={playerContext.player.speedDown}
|
||||||
speedUp={playerContext.player.speedUp}
|
speedUp={playerContext.player.speedUp}
|
||||||
jump={playerContext.player.jump}
|
jump={playerContext.player.jump}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import SummaryBlock from 'Components/Session/Player/ReplayPlayer/SummaryBlock';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Session from 'Types/session/session';
|
|
||||||
import EventsBlock from '../Session_/EventsBlock';
|
import EventsBlock from '../Session_/EventsBlock';
|
||||||
|
import HighlightPanel from "../Session_/Highlight/HighlightPanel";
|
||||||
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel';
|
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel';
|
||||||
import TagWatch from 'Components/Session/Player/TagWatch';
|
import TagWatch from 'Components/Session/Player/TagWatch';
|
||||||
|
|
||||||
|
|
@ -34,6 +33,12 @@ function RightBlock({
|
||||||
<TagWatch />
|
<TagWatch />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'HIGHLIGHT':
|
||||||
|
return (
|
||||||
|
<div className={cn('bg-white border-l', stl.panel)}>
|
||||||
|
<HighlightPanel onClose={() => setActiveTab('')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ function EventGroupWrapper(props) {
|
||||||
if (isNote) {
|
if (isNote) {
|
||||||
return (
|
return (
|
||||||
<NoteEvent
|
<NoteEvent
|
||||||
|
setActiveTab={props.setActiveTab}
|
||||||
note={event}
|
note={event}
|
||||||
filterOutNote={filterOutNote}
|
filterOutNote={filterOutNote}
|
||||||
noEdit={currentUserId !== event.userId}
|
noEdit={currentUserId !== event.userId}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ function EventsBlock(props: IProps) {
|
||||||
isTabChange={isTabChange}
|
isTabChange={isTabChange}
|
||||||
isPrev={isPrev}
|
isPrev={isPrev}
|
||||||
filterOutNote={filterOutNote}
|
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 React from 'react';
|
||||||
import { Icon } from 'UI';
|
|
||||||
import { tagProps, Note } from 'App/services/NotesService';
|
import { tagProps, Note } from 'App/services/NotesService';
|
||||||
import { formatTimeOrDate } from 'App/date';
|
import { formatTimeOrDate } from 'App/date';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
|
|
@ -13,34 +10,22 @@ import { session } from 'App/routes';
|
||||||
import { confirm } from 'UI';
|
import { confirm } from 'UI';
|
||||||
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
|
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
|
||||||
import { Tag } from 'antd'
|
import { Tag } from 'antd'
|
||||||
|
import { MessageSquareDot } from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
note: Note;
|
note: Note;
|
||||||
noEdit: boolean;
|
noEdit: boolean;
|
||||||
filterOutNote: (id: number) => void;
|
filterOutNote: (id: number) => void;
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteEvent(props: Props) {
|
function NoteEvent(props: Props) {
|
||||||
const { settingsStore, notesStore } = useStore();
|
const { settingsStore, notesStore } = useStore();
|
||||||
const { timezone } = settingsStore.sessionSettings;
|
const { timezone } = settingsStore.sessionSettings;
|
||||||
const { showModal, hideModal } = useModal();
|
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
showModal(
|
notesStore.setEditNote(props.note);
|
||||||
<CreateNote
|
props.setActiveTab('HIGHLIGHT')
|
||||||
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 }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopy = () => {
|
const onCopy = () => {
|
||||||
|
|
@ -78,9 +63,7 @@ function NoteEvent(props: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start flex-col p-2 border rounded ps-4" style={{ background: '#FFFEF5' }}>
|
<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="flex items-center w-full relative">
|
||||||
<div className="p-3 bg-gray-light rounded-full">
|
<MessageSquareDot size={16} strokeWidth={1} />
|
||||||
<Icon name="quotes" color="main" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<div
|
<div
|
||||||
className="text-base"
|
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 React from 'react';
|
||||||
import stl from './issuesModal.module.css';
|
import stl from './issuesModal.module.css';
|
||||||
import IssueForm from './IssueForm';
|
import IssueForm from './IssueForm';
|
||||||
|
import cn from 'classnames'
|
||||||
|
|
||||||
const IssuesModal = ({
|
const IssuesModal = ({
|
||||||
sessionId,
|
sessionId,
|
||||||
closeHandler,
|
closeHandler,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={ stl.wrapper }>
|
<div className={cn(stl.wrapper, 'h-screen')}>
|
||||||
<h3 className="text-xl font-semibold">
|
<h3 className="text-xl font-semibold">
|
||||||
<span>Create Issue</span>
|
<span>Create Issue</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import DraggableMarkers from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
|
|
||||||
import React, { useEffect, useMemo, useContext, useState, useRef } from 'react';
|
import React, { useEffect, useMemo, useContext, useState, useRef } from 'react';
|
||||||
import stl from './timeline.module.css';
|
import stl from './timeline.module.css';
|
||||||
import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer';
|
import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer';
|
||||||
|
|
@ -12,6 +11,7 @@ import { WebEventsList, MobEventsList } from './EventsList';
|
||||||
import NotesList from './NotesList';
|
import NotesList from './NotesList';
|
||||||
import SkipIntervalsList from './SkipIntervalsList';
|
import SkipIntervalsList from './SkipIntervalsList';
|
||||||
import TimelineTracker from 'Components/Session_/Player/Controls/TimelineTracker';
|
import TimelineTracker from 'Components/Session_/Player/Controls/TimelineTracker';
|
||||||
|
import { ZoomDragLayer, HighlightDragLayer } from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
|
||||||
|
|
||||||
function Timeline({ isMobile }: { isMobile: boolean }) {
|
function Timeline({ isMobile }: { isMobile: boolean }) {
|
||||||
const { player, store } = useContext(PlayerContext);
|
const { player, store } = useContext(PlayerContext);
|
||||||
|
|
@ -24,6 +24,7 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
|
||||||
const timezone = sessionStore.current.timezone;
|
const timezone = sessionStore.current.timezone;
|
||||||
const issues = sessionStore.current.issues ?? [];
|
const issues = sessionStore.current.issues ?? [];
|
||||||
const timelineZoomEnabled = uiPlayerStore.timelineZoom.enabled;
|
const timelineZoomEnabled = uiPlayerStore.timelineZoom.enabled;
|
||||||
|
const highlightEnabled = uiPlayerStore.highlightSelection.enabled;
|
||||||
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
|
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
|
||||||
|
|
||||||
const progressRef = useRef<HTMLDivElement>(null);
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -132,7 +133,8 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
|
||||||
left: '0.5rem',
|
left: '0.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{timelineZoomEnabled ? <DraggableMarkers scale={scale} /> : null}
|
{timelineZoomEnabled ? <ZoomDragLayer scale={scale} /> : null}
|
||||||
|
{highlightEnabled ? <HighlightDragLayer scale={scale} /> : null}
|
||||||
<div
|
<div
|
||||||
className={stl.progress}
|
className={stl.progress}
|
||||||
onClick={ready ? jumpToTime : undefined}
|
onClick={ready ? jumpToTime : undefined}
|
||||||
|
|
@ -143,15 +145,13 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
|
||||||
onMouseLeave={hideTimeTooltip}
|
onMouseLeave={hideTimeTooltip}
|
||||||
>
|
>
|
||||||
<TooltipContainer />
|
<TooltipContainer />
|
||||||
<TimelineTracker scale={scale} onDragEnd={onDragEnd} />
|
{highlightEnabled ? null : <TimelineTracker scale={scale} onDragEnd={onDragEnd} />}
|
||||||
<CustomDragLayer
|
<CustomDragLayer onDrag={onDrag} minX={0} maxX={maxWidth} />
|
||||||
onDrag={onDrag}
|
|
||||||
minX={0}
|
|
||||||
maxX={maxWidth}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={stl.timeline} ref={timelineRef}>
|
<div className={stl.timeline} ref={timelineRef}>
|
||||||
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
|
{devtoolsLoading || domLoading || !ready ? (
|
||||||
|
<div className={stl.stripes} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile ? <MobEventsList /> : <WebEventsList />}
|
{isMobile ? <MobEventsList /> : <WebEventsList />}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Tag } from 'antd';
|
import { Tag, Input } from 'antd';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
tagProps,
|
tagProps,
|
||||||
} from 'App/services/NotesService';
|
} from 'App/services/NotesService';
|
||||||
import { Button, Checkbox, Icon } from 'UI';
|
import { Button, Checkbox, Icon } from 'UI';
|
||||||
|
import { shortDurationFromMs } from 'App/date';
|
||||||
|
|
||||||
import Select from 'Shared/Select';
|
import Select from 'Shared/Select';
|
||||||
|
|
||||||
|
|
@ -208,7 +209,7 @@ function CreateNote({
|
||||||
|
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className={'font-semibold'}>Note</div>
|
<div className={'font-semibold'}>Note</div>
|
||||||
<textarea
|
<Input.TextArea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
name="message"
|
name="message"
|
||||||
id="message"
|
id="message"
|
||||||
|
|
@ -221,6 +222,16 @@ function CreateNote({
|
||||||
}}
|
}}
|
||||||
className="text-area"
|
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' }}>
|
<div className="flex items-center gap-1" style={{ lineHeight: '15px' }}>
|
||||||
{TAGS.map((tag) => (
|
{TAGS.map((tag) => (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from 'UI';
|
import { Keyboard } from 'lucide-react';
|
||||||
import {Keyboard} from 'lucide-react'
|
|
||||||
import { Button, Tooltip } from 'antd';
|
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) {
|
function Cell({ shortcut, text }: any) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 justify-start rounded">
|
<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 function ShortcutGrid() {
|
||||||
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() {
|
|
||||||
return (
|
return (
|
||||||
<div className={'p-4 overflow-y-auto h-screen'}>
|
<div className={'p-4 overflow-y-auto h-screen'}>
|
||||||
<div className={'mb-4 font-semibold text-xl'}>Keyboard Shortcuts</div>
|
<div className={'mb-4 font-semibold text-xl'}>Keyboard Shortcuts</div>
|
||||||
|
|
@ -63,12 +68,12 @@ function ShortcutGrid() {
|
||||||
function KeyboardHelp() {
|
function KeyboardHelp() {
|
||||||
const { showModal } = useModal();
|
const { showModal } = useModal();
|
||||||
return (
|
return (
|
||||||
<Tooltip placement='bottom' title='Keyboard Shortcuts'>
|
<Tooltip placement="bottom" title="Keyboard Shortcuts">
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
className={'flex items-center justify-center'}
|
className={'flex items-center justify-center'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal(<ShortcutGrid />, { right: true, width: 320 })
|
showModal(<ShortcutGrid />, { right: true, width: 320 });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Keyboard size={18} />
|
<Keyboard size={18} />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ function TimeTooltip() {
|
||||||
const { time = 0, offset = 0, isVisible, localTime, userTime } = timeLineTooltip;
|
const { time = 0, offset = 0, isVisible, localTime, userTime } = timeLineTooltip;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={stl.timeTooltip}
|
className={`${stl.timeTooltip} p-2 rounded-lg min-w-40 max-w-64`}
|
||||||
style={{
|
style={{
|
||||||
top: 0,
|
top: 0,
|
||||||
left: `calc(${offset}px - 0.5rem)`,
|
left: `calc(${offset}px - 0.5rem)`,
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,119 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { observer } from 'mobx-react-lite';
|
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 { 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 {
|
interface Props {
|
||||||
scale: number;
|
scale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DraggableMarkers = ({
|
export const HighlightDragLayer = observer(({ scale }: Props) => {
|
||||||
scale,
|
const { uiPlayerStore } = useStore();
|
||||||
}: Props) => {
|
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 { uiPlayerStore } = useStore();
|
||||||
const toggleZoom = uiPlayerStore.toggleZoom;
|
const toggleZoom = uiPlayerStore.toggleZoom;
|
||||||
const timelineZoomStartTs = uiPlayerStore.timelineZoom.startTs;
|
const timelineZoomStartTs = uiPlayerStore.timelineZoom.startTs;
|
||||||
const timelineZoomEndTs = uiPlayerStore.timelineZoom.endTs;
|
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(
|
const [startPos, setStartPos] = useState(
|
||||||
getTimelinePosition(timelineZoomStartTs, scale)
|
getTimelinePosition(defaultStartPos, scale)
|
||||||
);
|
);
|
||||||
const [endPos, setEndPos] = useState(
|
const [endPos, setEndPos] = useState(
|
||||||
getTimelinePosition(timelineZoomEndTs, scale)
|
getTimelinePosition(defaultEndPos, scale)
|
||||||
);
|
);
|
||||||
const [dragging, setDragging] = useState<string | null>(null);
|
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(
|
const convertToPercentage = useCallback(
|
||||||
(clientX: number, element: HTMLElement) => {
|
(clientX: number, element: HTMLElement) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
@ -50,19 +142,13 @@ const DraggableMarkers = ({
|
||||||
if (endPos - newPos <= minDistance) {
|
if (endPos - newPos <= minDistance) {
|
||||||
setEndPos(newPos + minDistance);
|
setEndPos(newPos + minDistance);
|
||||||
}
|
}
|
||||||
toggleZoom({
|
onDragEnd(newPos / scale, endPos / scale);
|
||||||
enabled: true,
|
|
||||||
range: [newPos / scale, endPos / scale],
|
|
||||||
});
|
|
||||||
} else if (dragging === 'end') {
|
} else if (dragging === 'end') {
|
||||||
setEndPos(newPos);
|
setEndPos(newPos);
|
||||||
if (newPos - startPos <= minDistance) {
|
if (newPos - startPos <= minDistance) {
|
||||||
setStartPos(newPos - minDistance);
|
setStartPos(newPos - minDistance);
|
||||||
}
|
}
|
||||||
toggleZoom({
|
onDragEnd(startPos / scale, newPos / scale);
|
||||||
enabled: true,
|
|
||||||
range: [startPos / scale, newPos / scale],
|
|
||||||
});
|
|
||||||
} else if (dragging === 'body') {
|
} else if (dragging === 'body') {
|
||||||
const offset = (endPos - startPos) / 2;
|
const offset = (endPos - startPos) / 2;
|
||||||
let newStartPos = newPos - offset;
|
let newStartPos = newPos - offset;
|
||||||
|
|
@ -76,31 +162,38 @@ const DraggableMarkers = ({
|
||||||
}
|
}
|
||||||
setStartPos(newStartPos);
|
setStartPos(newStartPos);
|
||||||
setEndPos(newEndPos);
|
setEndPos(newEndPos);
|
||||||
toggleZoom({
|
setTimeout(() => {
|
||||||
enabled: true,
|
onDragEnd(newStartPos / scale, newEndPos / scale);
|
||||||
range: [newStartPos / scale, newEndPos / scale],
|
}, 1)
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dragging, startPos, endPos, scale, toggleZoom]
|
[dragging, startPos, endPos, scale, onDragEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = useCallback(() => {
|
const endDrag = useCallback(() => {
|
||||||
setDragging(null);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onMouseMove={onDrag}
|
onMouseMove={onDrag}
|
||||||
onMouseLeave={endDrag}
|
|
||||||
onMouseUp={endDrag}
|
onMouseUp={endDrag}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '24px',
|
height: barSize,
|
||||||
left: 0,
|
left: 0,
|
||||||
top: '-4px',
|
top: centering,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -110,32 +203,43 @@ const DraggableMarkers = ({
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${startPos}%`,
|
left: `${startPos}%`,
|
||||||
height: '100%',
|
height: uiSize,
|
||||||
background: '#FCC100',
|
top: topPadding,
|
||||||
|
background: dragging && dragging !== 'start' ? '#c2970a' : '#FCC100',
|
||||||
cursor: 'ew-resize',
|
cursor: 'ew-resize',
|
||||||
borderTopLeftRadius: 3,
|
borderTopLeftRadius: 3,
|
||||||
borderBottomLeftRadius: 3,
|
borderBottomLeftRadius: 3,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingRight: 3,
|
paddingRight: 1,
|
||||||
paddingLeft: 6,
|
paddingLeft: 3,
|
||||||
width: 18,
|
width: 10,
|
||||||
|
opacity: dragging && dragging !== 'start' ? 0.8 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{dragging === 'start' ? (
|
||||||
<div
|
<div
|
||||||
className={'bg-black rounded-xl'}
|
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/20 rounded-xl'}
|
||||||
style={{
|
style={{
|
||||||
zIndex: 101,
|
zIndex: 101,
|
||||||
height: 18,
|
height: 16,
|
||||||
width: 2,
|
width: 1,
|
||||||
marginRight: 3,
|
marginRight: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={'bg-black rounded-xl'}
|
className={'bg-black/20 rounded-xl'}
|
||||||
style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }}
|
style={{ zIndex: 101, height: 16, width: 1, overflow: 'hidden' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -143,14 +247,22 @@ const DraggableMarkers = ({
|
||||||
onMouseDown={startDrag('body')}
|
onMouseDown={startDrag('body')}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `calc(${startPos}% + 18px)`,
|
left: `calc(${startPos}% + 10px)`,
|
||||||
width: `calc(${endPos - startPos}% - 18px)`,
|
width: `calc(${endPos - startPos}% - 10px)`,
|
||||||
height: '100%',
|
height: uiSize,
|
||||||
background: 'rgba(252, 193, 0, 0.10)',
|
top: topPadding,
|
||||||
borderTop: '2px solid #FCC100',
|
background: `repeating-linear-gradient(
|
||||||
borderBottom: '2px solid #FCC100',
|
-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',
|
cursor: 'grab',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
|
opacity: dragging ? 0.8 : 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -159,37 +271,46 @@ const DraggableMarkers = ({
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${endPos}%`,
|
left: `${endPos}%`,
|
||||||
height: '100%',
|
height: uiSize,
|
||||||
background: '#FCC100',
|
top: topPadding,
|
||||||
|
background: dragging && dragging !== 'end' ? '#c2970a' : '#FCC100',
|
||||||
cursor: 'ew-resize',
|
cursor: 'ew-resize',
|
||||||
borderTopRightRadius: 3,
|
borderTopRightRadius: 3,
|
||||||
borderBottomRightRadius: 3,
|
borderBottomRightRadius: 3,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingLeft: 3,
|
paddingLeft: 1,
|
||||||
paddingRight: 6,
|
paddingRight: 1,
|
||||||
width: 18,
|
width: 10,
|
||||||
|
opacity: dragging && dragging !== 'end' ? 0.8 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{dragging === 'end' ? (
|
||||||
<div
|
<div
|
||||||
className={'bg-black rounded-xl'}
|
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/20 rounded-xl'}
|
||||||
style={{
|
style={{
|
||||||
zIndex: 101,
|
zIndex: 101,
|
||||||
height: 18,
|
height: 16,
|
||||||
width: 2,
|
width: 1,
|
||||||
marginRight: 3,
|
marginRight: 2,
|
||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={'bg-black rounded-xl'}
|
className={'bg-black/20 rounded-xl'}
|
||||||
style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }}
|
style={{ zIndex: 101, height: 16, width: 1, overflow: 'hidden' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default observer(DraggableMarkers);
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,40 @@
|
||||||
import { ShareAltOutlined } from '@ant-design/icons';
|
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 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 { observer } from 'mobx-react-lite';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { MoreOutlined } from '@ant-design/icons'
|
||||||
|
import { Icon } from 'UI';
|
||||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||||
import { IFRAME } from 'App/constants/storageKeys';
|
import { IFRAME } from 'App/constants/storageKeys';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { checkParam, truncateStringToFit } from 'App/utils';
|
import { checkParam, truncateStringToFit } from 'App/utils';
|
||||||
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
|
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 WarnBadge from 'Components/Session_/WarnBadge';
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import Bookmark from 'Shared/Bookmark';
|
import HighlightButton from './Highlight/HighlightButton';
|
||||||
|
|
||||||
import SharePopup from '../shared/SharePopup/SharePopup';
|
import SharePopup from '../shared/SharePopup/SharePopup';
|
||||||
import Issues from './Issues/Issues';
|
|
||||||
import QueueControls from './QueueControls';
|
import QueueControls from './QueueControls';
|
||||||
import NotePopup from './components/NotePopup';
|
import { Bookmark as BookmarkIcn, BookmarkCheck, Vault } from "lucide-react";
|
||||||
|
|
||||||
const disableDevtools = 'or_devtools_uxt_toggle';
|
const disableDevtools = 'or_devtools_uxt_toggle';
|
||||||
|
|
||||||
function SubHeader(props) {
|
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 currentSession = sessionStore.current
|
||||||
const projectId = projectsStore.siteId;
|
const projectId = projectsStore.siteId;
|
||||||
const integrations = integrationsStore.issues.list;
|
const integrations = integrationsStore.issues.list;
|
||||||
|
|
@ -31,6 +42,9 @@ function SubHeader(props) {
|
||||||
const { location: currentLocation = 'loading...' } = store.get();
|
const { location: currentLocation = 'loading...' } = store.get();
|
||||||
const hasIframe = localStorage.getItem(IFRAME) === 'true';
|
const hasIframe = localStorage.getItem(IFRAME) === 'true';
|
||||||
const [hideTools, setHideTools] = React.useState(false);
|
const [hideTools, setHideTools] = React.useState(false);
|
||||||
|
const [isFavorite, setIsFavorite] = React.useState(favorite);
|
||||||
|
const { showModal, hideModal } = useModal();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const hideDevtools = checkParam('hideTools');
|
const hideDevtools = checkParam('hideTools');
|
||||||
if (hideDevtools) {
|
if (hideDevtools) {
|
||||||
|
|
@ -46,6 +60,27 @@ function SubHeader(props) {
|
||||||
return integrations.some((i) => i.token);
|
return integrations.some((i) => i.token);
|
||||||
}, [integrations]);
|
}, [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(
|
const locationTruncated = truncateStringToFit(
|
||||||
currentLocation,
|
currentLocation,
|
||||||
window.innerWidth - 200
|
window.innerWidth - 200
|
||||||
|
|
@ -56,6 +91,32 @@ function SubHeader(props) {
|
||||||
uxtestingStore.setHideDevtools(!enabled);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -84,10 +145,6 @@ function SubHeader(props) {
|
||||||
)}
|
)}
|
||||||
style={{ width: 'max-content' }}
|
style={{ width: 'max-content' }}
|
||||||
>
|
>
|
||||||
<KeyboardHelp />
|
|
||||||
<Bookmark sessionId={currentSession.sessionId} />
|
|
||||||
<NotePopup />
|
|
||||||
{enabledIntegration && <Issues sessionId={currentSession.sessionId} />}
|
|
||||||
<SharePopup
|
<SharePopup
|
||||||
showCopyLink={true}
|
showCopyLink={true}
|
||||||
trigger={
|
trigger={
|
||||||
|
|
@ -103,6 +160,44 @@ function SubHeader(props) {
|
||||||
</div>
|
</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() ? (
|
{uxtestingStore.isUxt() ? (
|
||||||
<Switch
|
<Switch
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Alert } from 'antd';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
const localhostWarn = (project: string) => project + '_localhost_warn';
|
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;
|
export default WarnBadge;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { Link2 } from 'lucide-react';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { TextEllipsis } from "UI";
|
||||||
|
|
||||||
import { Spot } from 'App/mstore/types/spot';
|
import { Spot } from 'App/mstore/types/spot';
|
||||||
import { spot as spotUrl, withSiteId } from 'App/routes';
|
import { spot as spotUrl, withSiteId } from 'App/routes';
|
||||||
|
|
@ -128,11 +129,7 @@ function SpotListItem({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 ? (
|
{isEdit ? (
|
||||||
<EditItemModal
|
<EditItemModal
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
|
|
@ -140,43 +137,10 @@ function SpotListItem({
|
||||||
itemName={spot.title}
|
itemName={spot.title}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<GridItem
|
||||||
className="relative group overflow-hidden"
|
modifier={
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 180,
|
|
||||||
backgroundImage: `url(${backgroundUrl})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<AnimatedSVG name={ICONS.LOADER} size={32} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="block w-full h-full cursor-pointer transition hover:bg-teal/70 relative"
|
|
||||||
onClick={onSpotClick}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={spot.thumbnail}
|
|
||||||
alt={spot.title}
|
|
||||||
className={'w-full h-full object-cover opacity-80'}
|
|
||||||
onLoad={() => setLoading(false)}
|
|
||||||
onError={() => setLoading(false)}
|
|
||||||
style={{ display: loading ? 'none' : 'block' }}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 scale-75 transition-all hover:scale-100 hover:transition-all group-hover:opacity-100 transition-opacity ">
|
|
||||||
<PlayCircleOutlined
|
|
||||||
style={{ fontSize: '48px', color: 'white' }}
|
|
||||||
className="bg-teal/50 rounded-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
|
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
|
||||||
<Tooltip title={tooltipText}>
|
<Tooltip title={tooltipText} className='capitalize'>
|
||||||
<div
|
<div
|
||||||
className={
|
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 '
|
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg transition-transform transform translate-y-14 group-hover:translate-y-0 '
|
||||||
|
|
@ -195,29 +159,128 @@ function SpotListItem({
|
||||||
{spot.duration}
|
{spot.duration}
|
||||||
</div>
|
</div>
|
||||||
</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={{
|
||||||
|
width: '100%',
|
||||||
|
height: 180,
|
||||||
|
backgroundImage: `url(${backgroundUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<AnimatedSVG name={ICONS.LOADER} size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className={'px-4 py-4 w-full border-t'}>
|
)}
|
||||||
|
<div
|
||||||
|
className="block w-full h-full cursor-pointer transition hover:bg-teal/70 relative"
|
||||||
|
onClick={onItemClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={title}
|
||||||
|
className={'w-full h-full object-cover opacity-80'}
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={() => setLoading(false)}
|
||||||
|
style={{ display: loading ? 'none' : 'block' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 scale-75 transition-all hover:scale-100 hover:transition-all group-hover:opacity-100 transition-opacity ">
|
||||||
|
<PlayCircleOutlined
|
||||||
|
style={{ fontSize: '48px', color: 'white' }}
|
||||||
|
className="bg-teal/50 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modifier}
|
||||||
|
</div>
|
||||||
|
<div className={'w-full border-t'}>
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
|
{onSelect ? (
|
||||||
|
<div className='px-3 pt-2'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={({ target: { checked } }) => onSelect(checked)}
|
onChange={({ target: { checked } }) => onSelect(checked)}
|
||||||
className={`flex cursor-pointer w-full hover:text-teal ${isSelected ? 'text-teal' : ''}`}
|
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">
|
<TextEllipsis text={title} className='w-full'/>
|
||||||
{spot.title}
|
|
||||||
</span>
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center gap-1 leading-4 text-xs opacity-50'}>
|
) : (
|
||||||
|
<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 p-3'}>
|
||||||
<div>
|
<div>
|
||||||
<UserOutlined />
|
<UserOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div>{spot.user}</div>
|
<TextEllipsis text={user} className='capitalize' />
|
||||||
<div className="ms-4">
|
<div className="ml-auto">
|
||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div>{spot.createdAt}</div>
|
<div>{createdAt}</div>
|
||||||
<div className={'ml-auto'}>
|
<div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{ items: menuItems, onClick: onMenuClick }}
|
menu={{ items: menuItems, onClick: onMenuClick }}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ const SpotsListHeader = observer(
|
||||||
value={spotStore.filter === 'all' ? 'All Spots' : 'My Spots'}
|
value={spotStore.filter === 'all' ? 'All Spots' : 'My Spots'}
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
className="mr-4 lg:hidden xl:flex"
|
className="mr-4 lg:hidden xl:flex"
|
||||||
|
size={'small'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
|
|
@ -88,6 +89,7 @@ const SpotsListHeader = observer(
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onSearch={onSearch}
|
onSearch={onSearch}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, confirm } from 'UI';
|
import { Link, confirm } from 'UI';
|
||||||
import PlayLink from 'Shared/SessionItem/PlayLink';
|
|
||||||
import { tagProps, Note } from 'App/services/NotesService';
|
import { tagProps, Note } from 'App/services/NotesService';
|
||||||
import { formatTimeOrDate } from 'App/date';
|
import { formatTimeOrDate } from 'App/date';
|
||||||
import { useStore } from 'App/mstore';
|
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 Sparkles } from './sparkles';
|
||||||
export { default as Speedometer2 } from './speedometer2';
|
export { default as Speedometer2 } from './speedometer2';
|
||||||
export { default as Spinner } from './spinner';
|
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 Star } from './star';
|
||||||
export { default as Step_forward } from './step_forward';
|
export { default as Step_forward } from './step_forward';
|
||||||
export { default as Stickies } from './stickies';
|
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.
|
* 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.
|
* @returns {string} - Formatted duration string.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
|
|
@ -163,7 +164,7 @@ export const checkForRecent = (date: DateTime, format: string): string => {
|
||||||
// Formatted
|
// Formatted
|
||||||
return date.toFormat(format);
|
return date.toFormat(format);
|
||||||
};
|
};
|
||||||
export const resentOrDate = (ts) => {
|
export const resentOrDate = (ts, short?: boolean) => {
|
||||||
const date = DateTime.fromMillis(ts);
|
const date = DateTime.fromMillis(ts);
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
// Today
|
// Today
|
||||||
|
|
@ -171,7 +172,7 @@ export const resentOrDate = (ts) => {
|
||||||
|
|
||||||
// Yesterday
|
// Yesterday
|
||||||
if (date.hasSame(d.setDate(d.getDate() - 1), 'day')) return 'Yesterday at ' + date.toFormat('hh:mm a');
|
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) => {
|
export const checkRecentTime = (date, format) => {
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,8 @@ function SideMenu(props: Props) {
|
||||||
[PREFERENCES_MENU.TEAM]: () => client(CLIENT_TABS.MANAGE_USERS),
|
[PREFERENCES_MENU.TEAM]: () => client(CLIENT_TABS.MANAGE_USERS),
|
||||||
[PREFERENCES_MENU.NOTIFICATIONS]: () => client(CLIENT_TABS.NOTIFICATIONS),
|
[PREFERENCES_MENU.NOTIFICATIONS]: () => client(CLIENT_TABS.NOTIFICATIONS),
|
||||||
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
|
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
|
||||||
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES)
|
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES),
|
||||||
|
[MENU.HIGHLIGHTS]: () => withSiteId(routes.highlights(''), siteId),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (item: any) => {
|
const handleClick = (item: any) => {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export const enum MENU {
|
||||||
VAULT = 'vault',
|
VAULT = 'vault',
|
||||||
BOOKMARKS = 'bookmarks',
|
BOOKMARKS = 'bookmarks',
|
||||||
NOTES = 'notes',
|
NOTES = 'notes',
|
||||||
|
HIGHLIGHTS = 'highlights',
|
||||||
LIVE_SESSIONS = 'live-sessions',
|
LIVE_SESSIONS = 'live-sessions',
|
||||||
DASHBOARDS = 'dashboards',
|
DASHBOARDS = 'dashboards',
|
||||||
CARDS = 'cards',
|
CARDS = 'cards',
|
||||||
|
|
@ -63,7 +64,8 @@ export const categories: Category[] = [
|
||||||
{ label: 'Recommendations', key: MENU.RECOMMENDATIONS, icon: 'magic', hidden: true },
|
{ label: 'Recommendations', key: MENU.RECOMMENDATIONS, icon: 'magic', hidden: true },
|
||||||
{ label: 'Vault', key: MENU.VAULT, icon: 'safe', hidden: true },
|
{ label: 'Vault', key: MENU.VAULT, icon: 'safe', hidden: true },
|
||||||
{ label: 'Bookmarks', key: MENU.BOOKMARKS, icon: 'bookmark' },
|
{ 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[] = []
|
sessionNotes: Note[] = []
|
||||||
loading: boolean
|
loading: boolean
|
||||||
page = 1
|
page = 1
|
||||||
pageSize = 10
|
pageSize = 9
|
||||||
activeTags: iTag[] = []
|
activeTags: iTag[] = []
|
||||||
sort = 'createdAt'
|
sort = 'createdAt'
|
||||||
order: 'DESC' | 'ASC' = 'DESC'
|
order: 'DESC' | 'ASC' = 'DESC'
|
||||||
ownOnly = false
|
ownOnly = false
|
||||||
total = 0
|
total = 0
|
||||||
|
isSaving = false;
|
||||||
|
query = ''
|
||||||
|
editNote: Note | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this)
|
makeAutoObservable(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEditNote = (note: Note | null) => {
|
||||||
|
this.editNote = note
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuery = (query: string) => {
|
||||||
|
this.query = query
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving = (saving: boolean) => {
|
||||||
|
this.isSaving = saving
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(loading: boolean) {
|
setLoading(loading: boolean) {
|
||||||
this.loading = loading
|
this.loading = loading
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +55,8 @@ export default class NotesStore {
|
||||||
order: this.order,
|
order: this.order,
|
||||||
tags: this.activeTags,
|
tags: this.activeTags,
|
||||||
mineOnly: this.ownOnly,
|
mineOnly: this.ownOnly,
|
||||||
sharedOnly: false
|
sharedOnly: false,
|
||||||
|
search: this.query,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setLoading(true)
|
this.setLoading(true)
|
||||||
|
|
@ -48,7 +64,18 @@ export default class NotesStore {
|
||||||
const { notes, count } = await notesService.fetchNotes(filter);
|
const { notes, count } = await notesService.fetchNotes(filter);
|
||||||
this.setNotes(notes);
|
this.setNotes(notes);
|
||||||
this.setTotal(count)
|
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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} 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
|
const notesSource = notes ? notes : this.notes
|
||||||
|
|
||||||
return notesSource.find(note => note.noteId === noteId)
|
return notesSource.find(note => note.noteId === noteId)
|
||||||
|
|
@ -128,24 +155,19 @@ export default class NotesStore {
|
||||||
toggleTag(tag?: iTag) {
|
toggleTag(tag?: iTag) {
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
this.activeTags = []
|
this.activeTags = []
|
||||||
this.fetchNotes()
|
|
||||||
} else {
|
} else {
|
||||||
this.activeTags = [tag]
|
this.activeTags = [tag]
|
||||||
this.fetchNotes()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleShared(ownOnly: boolean) {
|
toggleShared(ownOnly: boolean) {
|
||||||
this.ownOnly = ownOnly
|
this.ownOnly = ownOnly
|
||||||
this.fetchNotes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSort(sort: string) {
|
toggleSort(sort: string) {
|
||||||
const sortOrder = sort.split('-')[1]
|
const sortOrder = sort.split('-')[1]
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this.order = sortOrder
|
this.order = sortOrder
|
||||||
|
|
||||||
this.fetchNotes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSlackNotification(noteId: string, webhook: string) {
|
async sendSlackNotification(noteId: string, webhook: string) {
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,7 @@ export default class SessionStore {
|
||||||
const wasInFavorite =
|
const wasInFavorite =
|
||||||
this.favoriteList.findIndex(({ sessionId }) => sessionId === id) > -1;
|
this.favoriteList.findIndex(({ sessionId }) => sessionId === id) > -1;
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
session.favorite = !wasInFavorite;
|
session.favorite = !wasInFavorite;
|
||||||
this.list[sessionIdx] = session;
|
this.list[sessionIdx] = session;
|
||||||
|
|
@ -409,6 +410,7 @@ export default class SessionStore {
|
||||||
} else {
|
} else {
|
||||||
this.favoriteList.push(session);
|
this.favoriteList.push(session);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.error(r);
|
console.error(r);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export class Spot {
|
||||||
this.comments = data.comments ?? [];
|
this.comments = data.comments ?? [];
|
||||||
this.thumbnail = data.previewURL
|
this.thumbnail = data.previewURL
|
||||||
this.title = data.name;
|
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.user = data.userEmail;
|
||||||
this.duration = shortDurationFromMs(data.duration);
|
this.duration = shortDurationFromMs(data.duration);
|
||||||
this.spotId = data.id
|
this.spotId = data.id
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@ export default class UiPlayerStore {
|
||||||
startTs: 0,
|
startTs: 0,
|
||||||
endTs: 0,
|
endTs: 0,
|
||||||
}
|
}
|
||||||
|
highlightSelection = {
|
||||||
|
enabled: false,
|
||||||
|
startTs: 0,
|
||||||
|
endTs: 0,
|
||||||
|
}
|
||||||
zoomTab: 'overview' | 'journey' | 'issues' | 'errors' = 'overview'
|
zoomTab: 'overview' | 'journey' | 'issues' | 'errors' = 'overview'
|
||||||
dataSource: 'all' | 'current' = 'all'
|
dataSource: 'all' | 'current' = 'all'
|
||||||
|
|
||||||
|
|
@ -113,6 +118,12 @@ export default class UiPlayerStore {
|
||||||
this.timelineZoom.endTs = payload.range?.[1] ?? 0;
|
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') => {
|
setZoomTab = (tab: 'overview' | 'journey' | 'issues' | 'errors') => {
|
||||||
this.zoomTab = tab;
|
this.zoomTab = tab;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,23 @@ export function createLiveWebPlayer(
|
||||||
const player = new WebLivePlayer(store, session, config, agentId, projectId, uiErrorHandler)
|
const player = new WebLivePlayer(store, session, config, agentId, projectId, uiErrorHandler)
|
||||||
return [player, store]
|
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 by index?
|
||||||
jump = (time: number) => {
|
jump = (time: number, silent?: boolean) => {
|
||||||
if (this.store.get().playing && this.store.get().ready) {
|
if (this.store.get().playing && this.store.get().ready) {
|
||||||
cancelAnimationFrame(this.animationFrameRequestId)
|
cancelAnimationFrame(this.animationFrameRequestId)
|
||||||
this.setTime(time)
|
this.setTime(time)
|
||||||
|
if (!silent) {
|
||||||
this.startAnimation()
|
this.startAnimation()
|
||||||
|
}
|
||||||
this.store.update({ livePlay: time === this.store.get().endTime })
|
this.store.update({ livePlay: time === this.store.get().endTime })
|
||||||
} else {
|
} else {
|
||||||
this.setTime(time)
|
this.setTime(time)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export default class Player extends Animator {
|
||||||
autoplay: initialAutoplay,
|
autoplay: initialAutoplay,
|
||||||
skip: initialSkip,
|
skip: initialSkip,
|
||||||
speed: initialSpeed,
|
speed: initialSpeed,
|
||||||
|
range: [0, 0] as [number, number],
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
constructor(private pState: Store<State & AnimatorGetState>, private manager: IMessageManager) {
|
constructor(private pState: Store<State & AnimatorGetState>, private manager: IMessageManager) {
|
||||||
|
|
@ -105,8 +106,11 @@ export default class Player extends Animator {
|
||||||
const { speed } = this.pState.get()
|
const { speed } = this.pState.get()
|
||||||
this.updateSpeed(Math.max(1, speed / 2))
|
this.updateSpeed(Math.max(1, speed / 2))
|
||||||
}
|
}
|
||||||
/* === === */
|
|
||||||
|
|
||||||
|
// toggle range (start, end)
|
||||||
|
toggleRange(start: number, end: number) {
|
||||||
|
this.pState.update({ range: [start, end] })
|
||||||
|
}
|
||||||
|
|
||||||
clean() {
|
clean() {
|
||||||
this.pause()
|
this.pause()
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,11 @@ export default class WebPlayer extends Player {
|
||||||
this.screen.cursor.showTag(name)
|
this.screen.cursor.showTag(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toggle range -> from super
|
||||||
|
toggleRange = (start: number, end: number) => {
|
||||||
|
super.toggleRange(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
changeTab = (tab: string) => {
|
changeTab = (tab: string) => {
|
||||||
const playing = this.wpState.get().playing
|
const playing = this.wpState.get().playing
|
||||||
this.pause()
|
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 spot = (id = ':spotId', hash?: string | number): string => hashed(`/view-spot/${id}`, hash);
|
||||||
export const scopeSetup = (): string => '/scope-setup';
|
export const scopeSetup = (): string => '/scope-setup';
|
||||||
|
|
||||||
|
export const highlights = (): string => '/highlights';
|
||||||
|
|
||||||
const REQUIRED_SITE_ID_ROUTES = [
|
const REQUIRED_SITE_ID_ROUTES = [
|
||||||
liveSession(''),
|
liveSession(''),
|
||||||
session(''),
|
session(''),
|
||||||
|
|
@ -188,6 +190,8 @@ const REQUIRED_SITE_ID_ROUTES = [
|
||||||
usabilityTestingCreate(),
|
usabilityTestingCreate(),
|
||||||
usabilityTestingEdit(''),
|
usabilityTestingEdit(''),
|
||||||
usabilityTestingView(''),
|
usabilityTestingView(''),
|
||||||
|
|
||||||
|
highlights(),
|
||||||
];
|
];
|
||||||
const routeNeedsSiteId = (path: string): boolean => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
|
const routeNeedsSiteId = (path: string): boolean => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
|
||||||
const siteIdToUrl = (siteId = ':siteId'): string => {
|
const siteIdToUrl = (siteId = ':siteId'): string => {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ import APIClient from 'App/api_client';
|
||||||
|
|
||||||
export const tagProps = {
|
export const tagProps = {
|
||||||
'ISSUE': 'red',
|
'ISSUE': 'red',
|
||||||
'QUERY': 'geekblue',
|
'DESIGN': 'geekblue',
|
||||||
'TASK': 'purple',
|
'NOTE': 'purple',
|
||||||
'OTHER': '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type iTag = keyof typeof tagProps | "ALL"
|
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 {
|
export interface WriteNote {
|
||||||
message: string
|
message: string
|
||||||
tag: iTag
|
tag: string
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
timestamp: number
|
timestamp?: number
|
||||||
noteId?: string
|
startAt: number
|
||||||
author?: string
|
endAt: number
|
||||||
|
thumbnail: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
|
|
@ -33,6 +33,9 @@ export interface Note {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
userId: number
|
userId: number
|
||||||
userName: string
|
userName: string
|
||||||
|
startAt: number
|
||||||
|
endAt: number
|
||||||
|
thumbnail: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotesFilter {
|
export interface NotesFilter {
|
||||||
|
|
@ -43,6 +46,7 @@ export interface NotesFilter {
|
||||||
tags: iTag[]
|
tags: iTag[]
|
||||||
sharedOnly: boolean
|
sharedOnly: boolean
|
||||||
mineOnly: boolean
|
mineOnly: boolean
|
||||||
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class NotesService {
|
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[]> {
|
getNotesBySessionId(sessionID: string): Promise<Note[]> {
|
||||||
return this.client.get(`/sessions/${sessionID}/notes`)
|
return this.client.get(`/sessions/${sessionID}/notes`)
|
||||||
.then(r => {
|
.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) {
|
export const throttle = <R, A extends any[]>(
|
||||||
var context, args, result;
|
fn: (...args: A) => R,
|
||||||
var timeout = null;
|
delay: number
|
||||||
var previous = 0;
|
): [(...args: A) => R | undefined, () => void, () => void] => {
|
||||||
if (!options) options = {};
|
let wait = false;
|
||||||
var later = function () {
|
let timeout: undefined | number;
|
||||||
previous = options.leading === false ? 0 : Date.now();
|
let cancelled = false;
|
||||||
timeout = null;
|
|
||||||
result = func.apply(context, args);
|
function resetWait() {
|
||||||
if (!timeout) context = args = null;
|
wait = false;
|
||||||
};
|
}
|
||||||
return function () {
|
|
||||||
var now = Date.now();
|
return [
|
||||||
if (!previous && options.leading === false) previous = now;
|
(...args: A) => {
|
||||||
var remaining = wait - (now - previous);
|
if (cancelled) return undefined;
|
||||||
context = this;
|
if (wait) return undefined;
|
||||||
args = arguments;
|
|
||||||
if (remaining <= 0 || remaining > wait) {
|
const val = fn(...args);
|
||||||
if (timeout) {
|
|
||||||
|
wait = true;
|
||||||
|
|
||||||
|
timeout = window.setTimeout(resetWait, delay);
|
||||||
|
|
||||||
|
return val;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
cancelled = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = null;
|
},
|
||||||
}
|
() => {
|
||||||
previous = now;
|
clearTimeout(timeout);
|
||||||
result = func.apply(context, args);
|
resetWait();
|
||||||
if (!timeout) context = args = null;
|
},
|
||||||
} else if (!timeout && options.trailing !== false) {
|
];
|
||||||
timeout = setTimeout(later, remaining);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteCookie(name: string, path: string, domain: string) {
|
export function deleteCookie(name: string, path: string, domain: string) {
|
||||||
document.cookie =
|
document.cookie =
|
||||||
|
|
|
||||||
|
|
@ -167,3 +167,48 @@ export function tryFilterUrl(url: string) {
|
||||||
return url;
|
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