diff --git a/gatsby-browser.tsx b/gatsby-browser.tsx index 7a9b945f6e4c27..025487313e176f 100644 --- a/gatsby-browser.tsx +++ b/gatsby-browser.tsx @@ -1,12 +1,33 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import {GatsbyBrowser} from 'gatsby'; +import {CodeContextProvider} from 'sentry-docs/components/codeContext'; +import {FeebdackWidget} from 'sentry-docs/components/feedbackWidget'; import PageContext from 'sentry-docs/components/pageContext'; export const wrapPageElement: GatsbyBrowser['wrapPageElement'] = ({ element, props: {pageContext}, -}) => {element}; +}) => { + useEffect(() => { + const codeBlock = document.querySelector('.code-tabs-wrapper'); + if (!codeBlock) { + return; + } + codeBlock.style.position = 'relative'; + codeBlock.style.right = '-74px'; + }); + return ( + + {/* FIXME: we're duplicating CodeContextProvider, which is not nice. + Ideally, FeedbackWidget is a child of the existing CodeContextProvider. */} + + + + {element} + + ); +}; // Disable prefetching altogether so our bw is not destroyed. // If this turns out to hurt performance significantly, we can diff --git a/src/components/codeContext.tsx b/src/components/codeContext.tsx index b587835ec67a48..fee6b16ca52985 100644 --- a/src/components/codeContext.tsx +++ b/src/components/codeContext.tsx @@ -18,6 +18,7 @@ type ProjectCodeKeywords = { }; type UserCodeKeywords = { + EMAIL: string; ID: number; NAME: string; }; @@ -51,6 +52,7 @@ type ProjectApiResult = { type UserApiResult = { avatarUrl: string; + email: string; id: number; isAuthenticated: boolean; name: string; @@ -131,7 +133,7 @@ export async function fetchCodeKeywords(): Promise { const url = process.env.NODE_ENV === 'development' - ? 'http://dev.getsentry.net:8000/docs/api/user/' + ? 'http://dev.getsentry.net:8000/api/0/auth-details/' : 'https://sentry.io/docs/api/user/'; const makeDefaults = () => { @@ -185,6 +187,7 @@ export async function fetchCodeKeywords(): Promise { ? { ID: user.id, NAME: user.name, + EMAIL: user.email, } : undefined, }; diff --git a/src/components/feedbackButton.tsx b/src/components/feedbackButton.tsx new file mode 100644 index 00000000000000..b3d6bb26666329 --- /dev/null +++ b/src/components/feedbackButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const Button = styled.button` + position: fixed; + right: 0px; + bottom: 50%; + transform: translate(25%, 50%) rotate(-90deg); + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + color: #231c3d; + cursor: pointer; + font-size: 14px; + font-weight: 600; + padding: 6px 16px; + text-align: center; + text-decoration: none; + z-index: 9000; + &:hover { + background-color: #eee; + } + &:focus-visible { + outline: 1px solid #79628c; + background-color: #eee; + } +`; + +export function FeebdackButton(props) { + return ; +} diff --git a/src/components/feedbackModal.tsx b/src/components/feedbackModal.tsx new file mode 100644 index 00000000000000..f4b2e6717ef427 --- /dev/null +++ b/src/components/feedbackModal.tsx @@ -0,0 +1,440 @@ +import React, {FormEvent, useContext, useEffect} from 'react'; +import {css} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {useFocusTrap} from './hooks/useFocusTrap'; +import {useShortcut} from './hooks/useShortcut'; +import {useTakeScreenshot} from './hooks/useTakeScreenhot'; +import {CodeContext} from './codeContext'; +import {ImageEditorWrapper} from './imageEditorWrapper'; +import {Rect, ScreenshotEditor} from './screenshotEditor'; + +const Dialog = styled.dialog` + background-color: rgba(0, 0, 0, 0.05); + border: none; + position: fixed; + inset: 0; + z-index: 10000; + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.2s ease-in-out; + &:not([open]) { + opacity: 0; + pointer-events: none; + visibility: hidden; + } +`; + +const Content = styled.div` + border-radius: 4px; + background-color: #fff; + width: 500px; + max-width: 100%; + max-height: calc(100% - 64px); + display: flex; + flex-direction: column; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 16px rgba(0, 0, 0, 0.2); + transition: transform 0.2s ease-in-out; + transform: translate(0, 0) scale(1); + dialog:not([open]) & { + transform: translate(0, -16px) scale(0.98); + } +`; + +const Header = styled.h2` + font-size: 20px; + font-weight: 600; + border-bottom: 1px solid #ccc; + padding: 20px 24px; + margin: 0px; +`; + +const Form = styled.form` + display: flex; + overflow: auto; + flex-direction: column; + gap: 16px; + padding: 24px; +`; + +const Label = styled.label` + display: flex; + flex-direction: column; + gap: 4px; + margin: 0px; +`; + +const inputStyles = css` + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + padding: 6px 8px; + &:focus { + outline: 1px solid rgba(108, 95, 199, 1); + border-color: rgba(108, 95, 199, 1); + } +`; + +const Input = styled.input` + ${inputStyles} +`; + +const TextArea = styled.textarea` + ${inputStyles} + min-height: 64px; + resize: vertical; +`; + +const ModalFooter = styled.div` + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +`; + +const buttonStyles = css` + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + padding: 6px 16px; +`; + +const SubmitButton = styled.button` + ${buttonStyles} + background-color: rgba(108, 95, 199, 1); + color: #fff; + &:hover { + background-color: rgba(88, 74, 192, 1); + } + &:focus-visible { + outline: 1px solid rgba(108, 95, 199, 1); + background-color: rgba(88, 74, 192, 1); + } +`; + +const CancelButton = styled.button` + ${buttonStyles} + background-color: #fff; + color: #231c3d; + font-weight: 500; + &:hover { + background-color: #eee; + } + &:focus-visible { + outline: 1px solid rgba(108, 95, 199, 1); + background-color: #eee; + } +`; + +const ScreenshotButton = styled.button` + ${buttonStyles} + background-color: #fff; + color: #231c3d; + font-weight: 500; + &:hover { + background-color: #eee; + } + &:focus-visible { + outline: 1px solid rgba(108, 95, 199, 1); + background-color: #eee; + } +`; + +const ScreenshotWrapper = styled.div` + display: flex; + gap: 8px; + width: 100%; +`; + +const ScreenshotPreview = styled.button` + position: relative; + display: block; + flex: 1; + min-width: 0; + height: 160px; + border-radius: 4px; + border: 1px solid #ccc; + overflow: hidden; + &::after { + content: 'Edit'; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + color: #fff; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + &:hover { + &::after { + opacity: 1; + } + } +`; + +const PreviewImage = styled.img` + display: block; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + object-fit: contain; + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 5px, + rgba(0, 0, 0, 0.03) 5px, + rgba(0, 0, 0, 0.03) 10px + ); +`; + +const FlexColumns = styled.div` + display: flex; + flex-direction: row; + gap: 16px; + & > * { + flex: 1; + } +`; + +interface FeedbackModalProps { + onClose: () => void; + onSubmit: (data: { + comment: string; + email: string; + name: string; + title: string; + image?: Blob; + imageCutout?: Blob; + selection?: Rect; + }) => void; + open: boolean; +} + +function stopPropagation(e: React.MouseEvent) { + e.stopPropagation(); +} + +const retrieveStringValue = (formData: FormData, key: string) => { + const value = formData.get(key); + if (typeof value === 'string') { + return value.trim(); + } + return ''; +}; + +function blobToBase64(blob: Blob) { + return new Promise((resolve, _) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); +} + +export function FeedbackModal({open, onClose, onSubmit}: FeedbackModalProps) { + const [screenshot, setScreenshot] = React.useState(undefined); + const [screenshotCutout, setScreenshotCutout] = React.useState( + undefined + ); + const [screenshotPreview, setScreenshotPreview] = React.useState( + undefined + ); + const [screenshotCutoutPreview, setScreenshotCutoutPreview] = React.useState< + string | undefined + >(undefined); + const [isEditScreenshotOpen, setIsEditScreenshotOpen] = React.useState(false); + const [isEditCutoutOpen, setIsEditCutoutOpen] = React.useState(false); + + const selectionRef = React.useRef(undefined); + const dialogRef = React.useRef(null); + const formRef = React.useRef(null); + + useFocusTrap(dialogRef, open); + useShortcut('Escape', onClose); + const {isInProgress, takeScreenshot} = useTakeScreenshot(); + + // Reset on close + useEffect(() => { + if (!open) { + setTimeout(() => { + formRef.current.reset(); + setScreenshot(undefined); + setScreenshotCutout(undefined); + setScreenshotPreview(undefined); + }, 200); + } + }, [open]); + + const codeContext = useContext(CodeContext); + console.log('codeContext', codeContext); + let defaultUserName: string; + let defaultEmail: string; + if (codeContext && codeContext.codeKeywords) { + const userData = codeContext.codeKeywords.USER; + if (userData) { + defaultUserName = userData.NAME; + defaultEmail = userData.EMAIL; + } + } + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + onSubmit({ + comment: retrieveStringValue(formData, 'comment'), + title: retrieveStringValue(formData, 'title'), + name: retrieveStringValue(formData, 'name'), + email: retrieveStringValue(formData, 'email'), + image: screenshot, + imageCutout: screenshotCutout, + selection: selectionRef.current, + }); + }; + + const handleScreenshot = async () => { + try { + const image = await takeScreenshot(); + setScreenshotPreview(image); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + }; + + const handleEditorSubmit = async ( + newScreenshot: Blob, + cutout?: Blob, + selection?: Rect + ) => { + setScreenshot(newScreenshot); + setScreenshotCutout(cutout); + setScreenshotPreview(await blobToBase64(newScreenshot)); + setScreenshotCutoutPreview(cutout && (await blobToBase64(cutout))); + selectionRef.current = selection; + }; + + return ( + + + +
Got any Feedback?
+
+ +