page.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import {Fragment, type ReactNode} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  4. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  5. import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
  6. import UserBadge from 'sentry/components/idBadge/userBadge';
  7. import FullViewport from 'sentry/components/layouts/fullViewport';
  8. import * as Layout from 'sentry/components/layouts/thirds';
  9. import Placeholder from 'sentry/components/placeholder';
  10. import ConfigureReplayCard from 'sentry/components/replays/configureReplayCard';
  11. import DetailsPageBreadcrumbs from 'sentry/components/replays/header/detailsPageBreadcrumbs';
  12. import FeedbackButton from 'sentry/components/replays/header/feedbackButton';
  13. import ReplayMetaData from 'sentry/components/replays/header/replayMetaData';
  14. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  15. import TimeSince from 'sentry/components/timeSince';
  16. import {IconCalendar, IconDelete, IconEllipsis, IconUpload} from 'sentry/icons';
  17. import {t} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import {defined} from 'sentry/utils';
  20. import useDeleteReplay from 'sentry/utils/replays/hooks/useDeleteReplay';
  21. import useShareReplayAtTimestamp from 'sentry/utils/replays/hooks/useShareReplayAtTimestamp';
  22. import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
  23. type Props = {
  24. children: ReactNode;
  25. orgSlug: string;
  26. projectSlug: string | null;
  27. replayErrors: ReplayError[];
  28. replayRecord: undefined | ReplayRecord;
  29. isLoading?: boolean;
  30. isVideoReplay?: boolean;
  31. };
  32. export default function Page({
  33. children,
  34. orgSlug,
  35. replayRecord,
  36. projectSlug,
  37. replayErrors,
  38. isVideoReplay,
  39. isLoading,
  40. }: Props) {
  41. const title = replayRecord
  42. ? `${replayRecord.user.display_name ?? t('Anonymous User')} — Session Replay — ${orgSlug}`
  43. : `Session Replay — ${orgSlug}`;
  44. const onShareReplay = useShareReplayAtTimestamp();
  45. const onDeleteReplay = useDeleteReplay({replayId: replayRecord?.id, projectSlug});
  46. const dropdownItems: MenuItemProps[] = [
  47. {
  48. key: 'share',
  49. label: (
  50. <ItemSpacer>
  51. <IconUpload size="sm" />
  52. {t('Share')}
  53. </ItemSpacer>
  54. ),
  55. onAction: onShareReplay,
  56. },
  57. replayRecord?.id && projectSlug
  58. ? {
  59. key: 'delete',
  60. label: (
  61. <ItemSpacer>
  62. <IconDelete size="sm" />
  63. {t('Delete')}
  64. </ItemSpacer>
  65. ),
  66. onAction: onDeleteReplay,
  67. }
  68. : null,
  69. ].filter(defined);
  70. const header = replayRecord?.is_archived ? (
  71. <Header>
  72. <DetailsPageBreadcrumbs orgSlug={orgSlug} replayRecord={replayRecord} />
  73. </Header>
  74. ) : (
  75. <Header>
  76. <DetailsPageBreadcrumbs orgSlug={orgSlug} replayRecord={replayRecord} />
  77. <ButtonActionsWrapper>
  78. {isLoading ? (
  79. <Placeholder height="33px" width="203px" />
  80. ) : (
  81. <Fragment>
  82. {isVideoReplay ? <FeedbackWidgetButton /> : <FeedbackButton />}
  83. {isVideoReplay ? null : <ConfigureReplayCard />}
  84. </Fragment>
  85. )}
  86. <DropdownMenu
  87. position="bottom-end"
  88. triggerProps={{
  89. showChevron: false,
  90. icon: <IconEllipsis color="subText" />,
  91. }}
  92. size="sm"
  93. items={dropdownItems}
  94. />
  95. </ButtonActionsWrapper>
  96. {replayRecord ? (
  97. <UserBadge
  98. avatarSize={24}
  99. displayName={
  100. <DisplayHeader>
  101. <Title>{replayRecord.user.display_name || t('Anonymous User')}</Title>
  102. {replayRecord && (
  103. <TimeContainer>
  104. <IconCalendar color="gray300" size="xs" />
  105. <TimeSince
  106. date={replayRecord.started_at}
  107. isTooltipHoverable
  108. unitStyle="regular"
  109. />
  110. </TimeContainer>
  111. )}
  112. </DisplayHeader>
  113. }
  114. user={{
  115. name: replayRecord.user.display_name || '',
  116. email: replayRecord.user.email || '',
  117. username: replayRecord.user.username || '',
  118. ip_address: replayRecord.user.ip || '',
  119. id: replayRecord.user.id || '',
  120. }}
  121. hideEmail
  122. />
  123. ) : (
  124. <Placeholder width="30%" height="45px" />
  125. )}
  126. <ReplayMetaData
  127. replayRecord={replayRecord}
  128. replayErrors={replayErrors}
  129. showDeadRageClicks={!isVideoReplay}
  130. isLoading={isLoading}
  131. />
  132. </Header>
  133. );
  134. return (
  135. <SentryDocumentTitle title={title}>
  136. <FullViewport>
  137. {header}
  138. {children}
  139. </FullViewport>
  140. </SentryDocumentTitle>
  141. );
  142. }
  143. const Header = styled(Layout.Header)`
  144. gap: ${space(1)};
  145. padding-bottom: ${space(1.5)};
  146. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  147. gap: ${space(1)} ${space(3)};
  148. padding: ${space(2)} ${space(2)} ${space(1.5)} ${space(2)};
  149. }
  150. `;
  151. // TODO(replay); This could make a lot of sense to put inside HeaderActions by default
  152. const ButtonActionsWrapper = styled(Layout.HeaderActions)`
  153. flex-direction: row;
  154. justify-content: flex-end;
  155. gap: ${space(1)};
  156. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  157. margin-bottom: 0;
  158. }
  159. `;
  160. const ItemSpacer = styled('div')`
  161. display: flex;
  162. gap: ${space(1)};
  163. align-items: center;
  164. `;
  165. const Title = styled('h1')`
  166. ${p => p.theme.overflowEllipsis};
  167. ${p => p.theme.text.pageTitle};
  168. font-size: ${p => p.theme.fontSizeExtraLarge};
  169. color: ${p => p.theme.headingColor};
  170. margin: 0;
  171. line-height: 1.4;
  172. `;
  173. const TimeContainer = styled('div')`
  174. display: flex;
  175. gap: ${space(1)};
  176. align-items: center;
  177. color: ${p => p.theme.gray300};
  178. font-size: ${p => p.theme.fontSizeMedium};
  179. line-height: 1.4;
  180. `;
  181. const DisplayHeader = styled('div')`
  182. display: flex;
  183. flex-direction: column;
  184. `;