diff --git a/frontend/app/PrivateRoutes.tsx b/frontend/app/PrivateRoutes.tsx index b39dd624d..7e4a85955 100644 --- a/frontend/app/PrivateRoutes.tsx +++ b/frontend/app/PrivateRoutes.tsx @@ -36,6 +36,7 @@ const components: any = { SpotsListPure: lazy(() => import('Components/Spots/SpotsList')), SpotPure: lazy(() => import('Components/Spots/SpotPlayer')), ScopeSetup: lazy(() => import('Components/ScopeForm')), + HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList')), }; const enhancedComponents: any = { @@ -58,6 +59,7 @@ const enhancedComponents: any = { SpotsList: withSiteIdUpdater(components.SpotsListPure), Spot: components.SpotPure, ScopeSetup: components.ScopeSetup, + Highlights: components.HighlightsPure, }; const withSiteId = routes.withSiteId; @@ -105,6 +107,8 @@ const SPOTS_LIST_PATH = routes.spotsList(); const SPOT_PATH = routes.spot(); const SCOPE_SETUP = routes.scopeSetup(); +const HIGHLIGHTS_PATH = routes.highlights(); + function PrivateRoutes() { const { projectsStore, userStore, integrationsStore } = useStore(); const onboarding = userStore.onboarding; @@ -236,6 +240,12 @@ function PrivateRoutes() { path={withSiteId(RECORDINGS_PATH, siteIdList)} component={enhancedComponents.Assist} /> + 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 ( + onSave(noteText, visible)} + onCancel={() => onCancel()} + > +
+ onEdit(e.target.value)} + maxLength={200} + value={noteText} + /> +
{noteText.length}/200 Characters remaining
+ setVisible(e.target.checked)} + > + Team can see and edit this Highlight. + +
+
+ ); +} + +export default EditHlModal; diff --git a/frontend/app/components/Highlights/HighlightClip.tsx b/frontend/app/components/Highlights/HighlightClip.tsx new file mode 100644 index 000000000..b8d564de0 --- /dev/null +++ b/frontend/app/components/Highlights/HighlightClip.tsx @@ -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: , + label: 'Copy Link', + }, + { + key: 'edit', + icon: , + label: 'Edit', + }, + { + key: 'visibility', + icon: , + label: 'Visibility', + }, + { + key: 'delete', + icon: , + 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 ( + null} + loading={false} + copyToClipboard={copyToClipboard} + user={user} + createdAt={resentOrDate(createdAt, true)} + menuItems={menuItems} + onMenuClick={onMenuClick} + modifier={ + tag ?
+ + {tag.toLowerCase()} + +
: null + } + /> + ); +} + +export default HighlightClip; \ No newline at end of file diff --git a/frontend/app/components/Highlights/HighlightPlayer.tsx b/frontend/app/components/Highlights/HighlightPlayer.tsx new file mode 100644 index 000000000..0e5695f29 --- /dev/null +++ b/frontend/app/components/Highlights/HighlightPlayer.tsx @@ -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({ + 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 ( +
+
+ + + +
+
+ ); +} + +export default observer(HighlightPlayer); diff --git a/frontend/app/components/Highlights/HighlightsList.tsx b/frontend/app/components/Highlights/HighlightsList.tsx new file mode 100644 index 000000000..51b472475 --- /dev/null +++ b/frontend/app/components/Highlights/HighlightsList.tsx @@ -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>({ + 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) => { + 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 ( +
+ {highlight && } + +
+ + + Highlight and note observations during session replays and share + them with your team. +
+ } + > + {notes.map((note) => ( + onEdit(note.noteId)} + onDelete={() => onDelete(note.noteId)} + onItemClick={() => onItemClick(note.noteId)} + /> + ))} + + + setEditModalOpen(false)} + text={editHl?.message} + visible={editHl?.isPublic} + onSave={onSave} + /> +
+
+
+ Showing {(page - 1) * limit + 1} + {' to '} + {(page - 1) * limit + listLength} + {' of '} + {numberWithCommas(total)} + {' highlights'}. +
+ +
+ + ); +} + +export default observer(HighlightsList); diff --git a/frontend/app/components/Highlights/HighlightsListHeader.tsx b/frontend/app/components/Highlights/HighlightsListHeader.tsx new file mode 100644 index 000000000..e67ad530e --- /dev/null +++ b/frontend/app/components/Highlights/HighlightsListHeader.tsx @@ -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) => void; +}) { + return ( +
+

Highlights

+ + All +
+ ), + }, + ...TAGS.map((tag: iTag) => ({ + value: tag, + label: ( +
+ {tag.toLowerCase()} +
+ ), + })), + ]} + onChange={(value: iTag) => + toggleTag(value === 'ALL' ? undefined : value) + } + /> +
+ { + toggleShared(key === 'own'); + }} + current={ownOnly ? 'Personal' : 'Team'} + /> +
+
+ +
+ + ) +} + +export default HighlightsListHeader \ No newline at end of file diff --git a/frontend/app/components/Session/ClipsPlayer.tsx b/frontend/app/components/Session/ClipsPlayer.tsx new file mode 100644 index 000000000..8bdd34995 --- /dev/null +++ b/frontend/app/components/Session/ClipsPlayer.tsx @@ -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(defaultContextValue); + const openedAt = React.useRef(); + const [session, setSession] = useState(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 ( + + ); + + return ( + + {contextValue.player ? ( + <> + + + + ) : ( + + )} + + ); +} + +export default observer(WebPlayer); diff --git a/frontend/app/components/Session/Player/ClipPlayer/AutoPlayTimer.tsx b/frontend/app/components/Session/Player/ClipPlayer/AutoPlayTimer.tsx new file mode 100644 index 000000000..c94398be2 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/AutoPlayTimer.tsx @@ -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 ( +
+
+
+ Autoplaying next clip in {counter} seconds +
+ +
+
+ +
+
+ +
+ +
+
+
+
+ ); +} + +export default observer(AutoplayTimer); diff --git a/frontend/app/components/Session/Player/ClipPlayer/AutoplayTimer.module.css b/frontend/app/components/Session/Player/ClipPlayer/AutoplayTimer.module.css new file mode 100644 index 000000000..1548231e3 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/AutoplayTimer.module.css @@ -0,0 +1,3 @@ +.overlayBg { + background-color: rgba(255, 255, 255, 0.8); +} diff --git a/frontend/app/components/Session/Player/ClipPlayer/AutoplayToggle.tsx b/frontend/app/components/Session/Player/ClipPlayer/AutoplayToggle.tsx new file mode 100644 index 000000000..1e7c53a63 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/AutoplayToggle.tsx @@ -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(PlayerContext); + // const { player, store } = playerContext; + const { autoplay } = playerContext.store.get(); + + const handleToggle = () => { + console.log('Toggle Autoplay'); + clipStore.toggleAutoplay(); + } + + return ( + + } + unCheckedChildren={} + /> + + ); +} + +export default observer(AutoplayToggle); diff --git a/frontend/app/components/Session/Player/ClipPlayer/ClipFeedback.tsx b/frontend/app/components/Session/Player/ClipPlayer/ClipFeedback.tsx new file mode 100644 index 000000000..f1ec529b7 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/ClipFeedback.tsx @@ -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: ( +
+ +
+ ), + target: () => ref1.current, + }, + ]; + + const interestStatus = currentClip?.interested; + const disabled = interestStatus != null + const isInterestedProps: ButtonProps = interestStatus === true ? { + color: "primary", + variant: "outlined", + icon: , + } : { + icon: , + onClick: () => submitFeedback(true) + }; + + const isNotInterestedProps: ButtonProps = interestStatus === false ? { + color: "primary", + variant: "outlined", + icon: , + } : { + icon: , + 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 ( +
+ {clipStore.tour && clipStore.toggleTour()}/>} +
+ ); +} + +export default observer(ClipFeedback); diff --git a/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerContent.tsx b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerContent.tsx new file mode 100644 index 000000000..53837bd6a --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerContent.tsx @@ -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(PlayerContext); + const screenWrapper = React.useRef(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 ( +
+
+
+
+ +
+
+
+ {props.isHighlight && props.message ? ( +
+ +
+ {props.message} +
+
+ ) : null} + +
+
+ ); +} + +export default observer(ClipPlayerContent); diff --git a/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerControls.tsx b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerControls.tsx new file mode 100644 index 000000000..8033d2794 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerControls.tsx @@ -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 ( +
+ + + +
+ ); +} + +export default observer(ClipPlayerControls); diff --git a/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerHeader.tsx b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerHeader.tsx new file mode 100644 index 000000000..fe5ee6d67 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerHeader.tsx @@ -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 ( +
+ {isHighlight ? : null} + + + + + + + + {isHighlight ? ( +
+ ); +} + +export default ClipPlayerHeader; diff --git a/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerOverlay.tsx b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerOverlay.tsx new file mode 100644 index 000000000..9162251a1 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/ClipPlayerOverlay.tsx @@ -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 ? : null} + {/*
*/} + {/* */} + {/*
*/} + + {completed && autoplay && } + + ); +} + +export default observer(Overlay); diff --git a/frontend/app/components/Session/Player/ClipPlayer/QueueControls.tsx b/frontend/app/components/Session/Player/ClipPlayer/QueueControls.tsx new file mode 100644 index 000000000..e897b83b1 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/QueueControls.tsx @@ -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 ( +
+
+ Play Previous Session
+ } + open={previousId ? undefined : false} + mouseEnterDelay={1} + > + + +
+ +
+ Play Next Session
} + open={nextId ? undefined : false} + mouseEnterDelay={1} + > + + +
+
+ ); +} + +export default observer(QueueControls); diff --git a/frontend/app/components/Session/Player/ClipPlayer/TimeTracker.tsx b/frontend/app/components/Session/Player/ClipPlayer/TimeTracker.tsx new file mode 100644 index 000000000..db47d26b2 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/TimeTracker.tsx @@ -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 ( + + ); +} + +TimeTracker.displayName = 'TimeTracker'; + +export default observer(TimeTracker); diff --git a/frontend/app/components/Session/Player/ClipPlayer/Timeline.tsx b/frontend/app/components/Session/Player/ClipPlayer/Timeline.tsx new file mode 100644 index 000000000..f4d9b9cbc --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/Timeline.tsx @@ -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(null); + const timelineRef = useRef(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) => { + 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) => { + const time = getTime(e); + player.jump(time); + hideTimeTooltip(); + }; + + const jumpToTime = (e: React.MouseEvent) => { + if ((e.target as HTMLDivElement).id === 'click-ignore') { + return; + } + seekProgress(e); + }; + + const getTime = (e: React.MouseEvent) => { + // @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, 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 ( +
+
+ + + + +
+ {devtoolsLoading || domLoading || !ready ?
: null} +
+
+
+ ); +} + +export default observer(Timeline); diff --git a/frontend/app/components/Session/Player/ClipPlayer/TimelineTracker.tsx b/frontend/app/components/Session/Player/ClipPlayer/TimelineTracker.tsx new file mode 100644 index 000000000..d6aba6337 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/TimelineTracker.tsx @@ -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 ( + <> + + + + ); +} + +export default observer(TimelineTracker); diff --git a/frontend/app/components/Session/Player/ClipPlayer/UserCard.tsx b/frontend/app/components/Session/Player/ClipPlayer/UserCard.tsx new file mode 100644 index 000000000..aa170860a --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/UserCard.tsx @@ -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 = ({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 ( +
+
+ +
+ + {userDisplayName} + +
+ + + {formatTimeOrDate(startedAt, timezone)} + + + · + {userCity && {userCity},} + {countries[userCountry]} + · + + {userBrowser && `${capitalize(userBrowser)}, `} + {`${/ios/i.test(userOs) ? 'iOS' : capitalize(userOs)}, `} + {capitalize(userDevice)} + +
+
+
+
+ ); +}; + +export default observer(UserCard); diff --git a/frontend/app/components/Session/Player/ClipPlayer/overlay.module.css b/frontend/app/components/Session/Player/ClipPlayer/overlay.module.css new file mode 100644 index 000000000..6efb79620 --- /dev/null +++ b/frontend/app/components/Session/Player/ClipPlayer/overlay.module.css @@ -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; +} diff --git a/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlock.tsx b/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlock.tsx index bb645f4fa..25f264647 100644 --- a/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlock.tsx +++ b/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlock.tsx @@ -32,7 +32,7 @@ function PlayerBlock(props: IProps) { className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')} > {shouldShowSubHeader ? ( - + ) : null} {fullscreen && } -
+
+ {activeTab === 'HIGHLIGHT' ?
: undefined} -
+
{!fullscreen && !!bottomBlock && (
{bottomBlock === OVERVIEW && } {bottomBlock === CONSOLE && } - {bottomBlock === NETWORK && } + {bottomBlock === NETWORK && ( + + )} {bottomBlock === STACKEVENTS && } {bottomBlock === STORAGE && } - {bottomBlock === PROFILER && } + {bottomBlock === PROFILER && ( + + )} {bottomBlock === PERFORMANCE && } {bottomBlock === GRAPHQL && } {bottomBlock === EXCEPTIONS && } @@ -154,7 +172,9 @@ function Player(props: IProps) { )} {!fullView ? ( activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab)} + setActiveTab={(tab: string) => + activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab) + } speedDown={playerContext.player.speedDown} speedUp={playerContext.player.speedUp} jump={playerContext.player.jump} diff --git a/frontend/app/components/Session/RightBlock.tsx b/frontend/app/components/Session/RightBlock.tsx index e0e63fddf..c355efd92 100644 --- a/frontend/app/components/Session/RightBlock.tsx +++ b/frontend/app/components/Session/RightBlock.tsx @@ -1,7 +1,6 @@ -import SummaryBlock from 'Components/Session/Player/ReplayPlayer/SummaryBlock'; import React from 'react'; -import Session from 'Types/session/session'; import EventsBlock from '../Session_/EventsBlock'; +import HighlightPanel from "../Session_/Highlight/HighlightPanel"; import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel'; import TagWatch from 'Components/Session/Player/TagWatch'; @@ -34,6 +33,12 @@ function RightBlock({
); + case 'HIGHLIGHT': + return ( +
+ setActiveTab('')} /> +
+ ) default: return null; } diff --git a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js index 9ae5f7808..f46c73d9e 100644 --- a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js +++ b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js @@ -47,6 +47,7 @@ function EventGroupWrapper(props) { if (isNote) { return ( ); }; diff --git a/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx b/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx index ef0cdb002..3979f463d 100644 --- a/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx +++ b/frontend/app/components/Session_/EventsBlock/NoteEvent.tsx @@ -1,7 +1,4 @@ -import { useModal } from 'Components/Modal'; -import CreateNote from 'Components/Session_/Player/Controls/components/CreateNote'; import React from 'react'; -import { Icon } from 'UI'; import { tagProps, Note } from 'App/services/NotesService'; import { formatTimeOrDate } from 'App/date'; import { useStore } from 'App/mstore'; @@ -13,34 +10,22 @@ import { session } from 'App/routes'; import { confirm } from 'UI'; import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes'; import { Tag } from 'antd' +import { MessageSquareDot } from 'lucide-react' interface Props { note: Note; noEdit: boolean; filterOutNote: (id: number) => void; + setActiveTab: (tab: string) => void; } function NoteEvent(props: Props) { const { settingsStore, notesStore } = useStore(); const { timezone } = settingsStore.sessionSettings; - const { showModal, hideModal } = useModal(); const onEdit = () => { - showModal( - , - { right: true, width: 380 } - ); + notesStore.setEditNote(props.note); + props.setActiveTab('HIGHLIGHT') }; const onCopy = () => { @@ -78,9 +63,7 @@ function NoteEvent(props: Props) { return (
-
- -
+
void }) { + + const openPanel = () => { + onClick(); + } + return ( + + + + ) +} + +export default HighlightButton \ No newline at end of file diff --git a/frontend/app/components/Session_/Highlight/HighlightPanel.tsx b/frontend/app/components/Session_/Highlight/HighlightPanel.tsx new file mode 100644 index 000000000..5a02a02cf --- /dev/null +++ b/frontend/app/components/Session_/Highlight/HighlightPanel.tsx @@ -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) => { + 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) => { + 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 ( +
e.stopPropagation()} + > +
+ +

+ {editNote ? 'Edit ' : ''}Highlight +

+
+ +
+
+
+ Save key moments from sessions. Access them anytime on the ‘Highlights’ + page to share with your team. +
+
+ setMessage(e.target.value)} + placeholder={'Enter Comments'} + maxLength={200} + rows={6} + value={message} + className="rounded-lg" + autoFocus + /> +
+ {message.length}/200 characters remaining +
+
+
+
+
From
+ +
+
+
To
+ +
+
+
+ {TAGS.map((tag) => ( + addTag(tag)} + key={tag} + className="cursor-pointer rounded-lg hover:bg-indigo-50 mr-0" + color={tagProps[tag]} + bordered={false} + > +
+ {tagActive(tag) ? ( + + ) : null} + {tag} +
+
+ ))} +
+
+ setIsPublic(e.target.checked)} + value={isPublic} + className="ms-2" + > + Visible to team members + +
+
+ + +
+
+ ); +} +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); diff --git a/frontend/app/components/Session_/Issues/IssuesModal.js b/frontend/app/components/Session_/Issues/IssuesModal.js index 4e298f6a7..95e224e7c 100644 --- a/frontend/app/components/Session_/Issues/IssuesModal.js +++ b/frontend/app/components/Session_/Issues/IssuesModal.js @@ -1,13 +1,14 @@ import React from 'react'; import stl from './issuesModal.module.css'; import IssueForm from './IssueForm'; +import cn from 'classnames' const IssuesModal = ({ sessionId, closeHandler, }) => { return ( -
+

Create Issue

diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.tsx b/frontend/app/components/Session_/Player/Controls/Timeline.tsx index d40fa112b..a7eb865b5 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.tsx +++ b/frontend/app/components/Session_/Player/Controls/Timeline.tsx @@ -1,4 +1,3 @@ -import DraggableMarkers from 'Components/Session_/Player/Controls/components/ZoomDragLayer'; import React, { useEffect, useMemo, useContext, useState, useRef } from 'react'; import stl from './timeline.module.css'; import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer'; @@ -12,6 +11,7 @@ import { WebEventsList, MobEventsList } from './EventsList'; import NotesList from './NotesList'; import SkipIntervalsList from './SkipIntervalsList'; import TimelineTracker from 'Components/Session_/Player/Controls/TimelineTracker'; +import { ZoomDragLayer, HighlightDragLayer } from 'Components/Session_/Player/Controls/components/ZoomDragLayer'; function Timeline({ isMobile }: { isMobile: boolean }) { const { player, store } = useContext(PlayerContext); @@ -24,6 +24,7 @@ function Timeline({ isMobile }: { isMobile: boolean }) { const timezone = sessionStore.current.timezone; const issues = sessionStore.current.issues ?? []; const timelineZoomEnabled = uiPlayerStore.timelineZoom.enabled; + const highlightEnabled = uiPlayerStore.highlightSelection.enabled; const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get(); const progressRef = useRef(null); @@ -132,7 +133,8 @@ function Timeline({ isMobile }: { isMobile: boolean }) { left: '0.5rem', }} > - {timelineZoomEnabled ? : null} + {timelineZoomEnabled ? : null} + {highlightEnabled ? : null}
- - + {highlightEnabled ? null : } +
- {devtoolsLoading || domLoading || !ready ?
: null} + {devtoolsLoading || domLoading || !ready ? ( +
+ ) : null}
{isMobile ? : } diff --git a/frontend/app/components/Session_/Player/Controls/components/CreateNote.tsx b/frontend/app/components/Session_/Player/Controls/components/CreateNote.tsx index c272126a2..e8077e5ff 100644 --- a/frontend/app/components/Session_/Player/Controls/components/CreateNote.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/CreateNote.tsx @@ -1,4 +1,4 @@ -import { Tag } from 'antd'; +import { Tag, Input } from 'antd'; import { Duration } from 'luxon'; import React from 'react'; import { toast } from 'react-toastify'; @@ -12,6 +12,7 @@ import { tagProps, } from 'App/services/NotesService'; import { Button, Checkbox, Icon } from 'UI'; +import { shortDurationFromMs } from 'App/date'; import Select from 'Shared/Select'; @@ -208,7 +209,7 @@ function CreateNote({
Note
-