aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2021-12-30 09:51:00 +0100
committerGravatar Tom van der Lee <tom@vanderlee.io>2022-01-11 00:00:22 +0100
commitbcb77d979d817e1e609adb4d007bbbcc3f61efbd (patch)
tree093c5a2914ee0f6e1ec559b3b98725190fee7285 /src/components
downloadclient-1.0.0.tar.gz
client-1.0.0.tar.bz2
client-1.0.0.zip
Prepare for githubv1.0.0
Diffstat (limited to 'src/components')
-rw-r--r--src/components/App/App.module.scss55
-rw-r--r--src/components/App/App.tsx68
-rw-r--r--src/components/Content/Content.module.scss51
-rw-r--r--src/components/Content/Content.tsx100
-rw-r--r--src/components/Details/Details.module.scss84
-rw-r--r--src/components/Details/Details.tsx148
-rw-r--r--src/components/RequestSummary/RequestSummary.module.scss24
-rw-r--r--src/components/RequestSummary/RequestSummary.tsx33
8 files changed, 563 insertions, 0 deletions
diff --git a/src/components/App/App.module.scss b/src/components/App/App.module.scss
new file mode 100644
index 0000000..2036eb9
--- /dev/null
+++ b/src/components/App/App.module.scss
@@ -0,0 +1,55 @@
1.app {
2 display: flex;
3 flex-flow: column nowrap;
4 grid-template-rows: auto 1fr;
5 height: 100vh;
6 overflow: hidden;
7}
8
9.main {
10 display: flex;
11 flex-flow: row nowrap;
12 flex-grow: 1;
13 overflow: hidden;
14}
15
16.header {
17 font-size: 1.2em;
18 display: flex;
19 flex-flow: row nowrap;
20 align-items: center;
21 justify-content: space-between;
22 background: black;
23 color: white;
24 padding: 1em;
25
26 a {
27 color: white;
28 }
29}
30
31.sidebar {
32 width: calc((6 / 16) * 100%);
33 height: 100%;
34 grid-area: sidebar;
35 border-right: 1px solid black;
36 overflow-y: auto;
37
38 li {
39 border-bottom: 1px solid gray;
40 }
41}
42
43.details {
44 width: calc((10 / 16) * 100%);
45 overflow: hidden;
46 height: 100%;
47}
48
49.noRequest, .noRequestSelected {
50 width: 100%;
51 height: 100%;
52 display: flex;
53 justify-content: center;
54 align-items: center;
55}
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
new file mode 100644
index 0000000..3a7fe9b
--- /dev/null
+++ b/src/components/App/App.tsx
@@ -0,0 +1,68 @@
1import * as React from "react";
2import useRequests, {RequestResponse} from "../../hooks/useRequests";
3import {useEffect, useMemo, useState} from "react";
4
5import styles from './App.module.scss';
6import Details from "../Details/Details";
7import RequestSummary from "../RequestSummary/RequestSummary";
8import {getHost} from "../../utils";
9
10interface Config {
11 url: string
12}
13
14export default function App() {
15 const [config, setConfig]= useState<Config | null>(null)
16 useEffect(() => {
17 fetch(`http://${getHost()}/config/`)
18 .then(response => response.json() as Promise<Config>)
19 .then(setConfig)
20 }, [])
21
22 const requests = useRequests();
23 const [selectedRequestIndex, setSelectedRequestIndex] = useState<number | null>(null);
24 const selectedRequest = useMemo<RequestResponse | null>(() => (
25 selectedRequestIndex === null
26 ? null
27 : requests[selectedRequestIndex]
28 ), [selectedRequestIndex, requests]);
29
30 return config && (
31 <div className={styles.app}>
32 <header className={styles.header}>
33 TTUN
34 <a href={config.url} target="_blank">{config.url}</a>
35 </header>
36 <main className={styles.main}>
37 <ul className={styles.sidebar}>
38 {
39 requests.length > 0
40 ? requests.slice(0).reverse().map((requestResponse, index) => (
41 <li onClick={() => setSelectedRequestIndex(requests.length - index - 1)} key={`request-${index}`}>
42 <RequestSummary requestResponse={requestResponse} />
43 </li>
44 ))
45 : (
46 <div className={styles.noRequest}>
47 <p>No requests</p>
48 </div>
49 )
50 }
51 </ul>
52
53 <div className={styles.details}>
54 {
55 selectedRequest !== null
56 ? (
57 <Details requestResponse={selectedRequest} />
58 ) : (
59 <div className={styles.noRequestSelected}>
60 <p>Select a request to inspect it</p>
61 </div>
62 )
63 }
64 </div>
65 </main>
66 </div>
67 );
68}
diff --git a/src/components/Content/Content.module.scss b/src/components/Content/Content.module.scss
new file mode 100644
index 0000000..8908516
--- /dev/null
+++ b/src/components/Content/Content.module.scss
@@ -0,0 +1,51 @@
1.content {
2 width: 100%;
3 height: 100%;
4 display: flex;
5 flex-flow: column nowrap;
6 overflow: hidden;
7}
8
9.header {
10 flex-shrink: 0;
11 flex-grow: 0;
12 width: 100%;
13 display: flex;
14 padding: 0.5em;
15 background-color: black;
16 color: white;
17}
18
19.body {
20 flex-grow: 1;
21 flex-shrink: 1;
22 overflow-y: auto;
23
24 pre {
25 width: 100%;
26 height: 100%;
27 padding: 1em;
28 font-family: monospace;
29 overflow: auto;
30 }
31
32 iframe {
33 height: 100%;
34 width: 100%;
35 }
36}
37
38.renderError {
39 width: 100%;
40 height: 100%;
41 display: flex;
42 flex-flow: column nowrap;
43 justify-content: center;
44 align-items: center;
45
46 a {
47 margin-top: 1em;
48 text-decoration: underline;
49 color: blue;
50 }
51}
diff --git a/src/components/Content/Content.tsx b/src/components/Content/Content.tsx
new file mode 100644
index 0000000..a7b5949
--- /dev/null
+++ b/src/components/Content/Content.tsx
@@ -0,0 +1,100 @@
1import styles from "~components/Details/Details.module.scss";
2import * as React from "react";
3import classNames from "classnames";
4import {RequestPayload, ResponsePayload} from "~hooks/useRequests";
5import {
6 Dispatch,
7 forwardRef, SetStateAction,
8 useEffect,
9 useImperativeHandle,
10 useMemo,
11 useRef,
12 useState
13} from "react";
14import ReactJson from 'react-json-view';
15import styles from './Content.module.scss';
16
17interface ContentProps {
18 data: RequestPayload | ResponsePayload
19 setRaw: Dispatch<SetStateAction<boolean>>
20 raw?: boolean
21}
22
23export default function Content({ raw, setRaw, ...props }: ContentProps): JSX.Element {
24 return (
25 <div className={styles.content}>
26 <div className={styles.header}>
27 <input id='raw' type='checkbox' checked={raw} onChange={() => setRaw(!raw)}/>
28 <label htmlFor='raw'>Raw</label>
29 </div>
30 <div className={styles.body}>
31 {(() => {
32 try {
33 return ContentBody({ ...props, raw })
34 } catch {
35 return (
36 <div className={styles.renderError}>
37 <p>Body could not be rendered</p>
38 <a onClick={() => setRaw(true)}>View raw</a>
39 </div>
40 )
41 }
42 })()}
43 </div>
44 </div>
45 )
46};
47
48function ContentBody({ data, raw = false }: Omit<ContentProps, 'setRaw'>) {
49 const contentType = useMemo(() => {
50 if (raw) {
51 return '';
52 }
53
54 const [_, type] = (
55 Object
56 .entries(data.headers)
57 .find(([key]) => key.toLowerCase() === 'content-type')
58 );
59
60 return type.toLowerCase().split(';')[0];
61 }, [data, raw]);
62
63 if (raw) {
64 return <pre>{atob(data.body)}</pre>
65 }
66
67 if (['application/pdf', 'text/html'].includes(contentType)) {
68 return <iframe
69 src={`data:${contentType};base64,${data.body}`}
70 srcDoc={contentType === 'text/html' ? atob(data.body) : undefined}
71 loading='lazy'
72 sandbox=''
73 />
74 }
75
76 if (contentType.startsWith('application/json')) {
77 return <ReactJson
78 src={JSON.parse(atob(data.body))}
79 style={{
80 padding: '1em',
81 width: '100%',
82 height: '100%',
83 }}
84 />
85 }
86
87 if (contentType.startsWith('audio')) {
88 return <audio src={`data:${contentType};base64,${data.body}`} />
89 }
90
91 if (contentType.startsWith('video')) {
92 return <video src={`data:${contentType};base64,${data.body}`} />
93 }
94
95 if (contentType.startsWith('image')) {
96 return <img src={`data:${contentType};base64,${data.body}`} alt=''/>
97 }
98
99 throw new Error('Not Rendered');
100};
diff --git a/src/components/Details/Details.module.scss b/src/components/Details/Details.module.scss
new file mode 100644
index 0000000..32197f0
--- /dev/null
+++ b/src/components/Details/Details.module.scss
@@ -0,0 +1,84 @@
1.details {
2 height: 100%;
3 display: flex;
4 flex-flow: column nowrap;
5 align-items: stretch;
6 overflow: hidden;
7}
8
9.header {
10 flex-shrink: 0;
11 flex-grow: 0;
12}
13
14.content {
15 flex-grow: 1;
16 flex-shrink: 1;
17 overflow-y: auto;
18}
19
20.summary {
21 font-size: 2em;
22}
23
24.tabs {
25 display: flex;
26 border-bottom: 1px solid black;
27
28 &:last-child {
29 flex-grow: 1;
30 }
31}
32
33.tab {
34 background-color: white;
35 border: none;
36 border-top: 1px solid black;
37 border-right: 1px solid black;
38 border-radius: 0;
39 color: black;
40 font-size: 1.2em;
41 padding: 0.5em;
42
43 &.selected {
44 background-color: black;
45 color: white;
46 }
47}
48
49.emptySpace {
50 flex-grow: 1;
51 display: flex;
52 justify-content: flex-end;
53 align-items: center;
54 font-size: 1.2em;
55 padding: 0.5em;
56}
57
58.resend {
59 margin-left: 1em;
60}
61
62.headers {
63 display: grid;
64 grid-template-columns: auto 1fr;
65 border: 1px solid black;
66 margin: 1em;
67 overflow-x: auto;
68
69 h2 {
70 grid-column: 1/ span 2;
71 background-color: black;
72 color: white;
73 padding: 0.5em;
74 }
75
76 > div {
77 line-break: auto;
78 padding: 0.5em;
79
80 &:nth-of-type(4n), &:nth-of-type(4n - 1) {
81 background-color: lightgray;
82 }
83 }
84}
diff --git a/src/components/Details/Details.tsx b/src/components/Details/Details.tsx
new file mode 100644
index 0000000..c4a23f4
--- /dev/null
+++ b/src/components/Details/Details.tsx
@@ -0,0 +1,148 @@
1import * as React from "react";
2import {
3 RequestPayload,
4 RequestResponse,
5 ResponsePayload
6} from "~hooks/useRequests";
7import {useCallback, useEffect, useMemo, useState} from "react";
8import styles from "./Details.module.scss";
9import RequestSummary from "../RequestSummary/RequestSummary";
10import classNames from 'classnames';
11import Content from "../Content/Content";
12import {getHost} from "../../utils";
13
14
15interface TimingProps {
16 timing: number;
17}
18
19
20function Timing({ timing }: TimingProps) {
21 const value = useMemo(() => (Math.round(timing * 1000) / 1000), [timing]);
22 const showSeconds = useMemo(() => value > 1, [value]);
23
24 return !Number.isNaN(value)
25 ? (
26 <>
27 {`${showSeconds ? value : value * 1000}${ showSeconds ? 's' : 'ms' }`}
28 </>
29 )
30 : null;
31}
32
33interface HeadersProps {
34 title: string
35 headers: {
36 [key: string]: string
37 }
38}
39
40function Headers({ title, headers }: HeadersProps) {
41 return (
42 <div className={styles.headers}>
43 <h2>{ title }</h2>
44 {
45 Object.entries(headers).map(([key, value]) => (
46 <>
47 <div>{key}</div>
48 <div>{value}</div>
49 </>
50 ))
51 }
52 </div>
53 )
54}
55
56interface DetailsProps {
57 requestResponse: RequestResponse
58}
59
60type Tab = 'headers' | 'request' | 'response'
61
62export default function Details({ requestResponse }: DetailsProps) {
63 const [tab, selectTab] = useState<Tab>('headers');
64 const [raw, setRaw] = useState(false);
65
66 const resend = useCallback(async () => fetch(
67 `http://${getHost()}/resend/`,
68 {
69 method: 'POST',
70 body: JSON.stringify({
71 ...requestResponse.request,
72 id: undefined
73 })
74 }
75 ), [requestResponse]);
76
77 return (
78 <div className={styles.details}>
79 <div className={styles.header}>
80 <RequestSummary requestResponse={requestResponse} className={styles.summary}/>
81
82 <div className={styles.tabs}>
83 <button
84 onClick={() => selectTab('headers')}
85 className={classNames(styles.tab, {
86 [styles.selected]: tab === 'headers'
87 })}
88 disabled={requestResponse.response === undefined}
89 >
90 Headers
91 </button>
92 <button
93 onClick={() => selectTab('request')}
94 className={classNames(styles.tab, {
95 [styles.selected]: tab === 'request'
96 })}
97 >
98 Request
99 </button>
100 <button
101 onClick={() => selectTab('response')}
102 className={classNames(styles.tab, {
103 [styles.selected]: tab === 'response'
104 })}
105 disabled={requestResponse.response === undefined}
106 >
107 Response
108 </button>
109
110 <div className={styles.emptySpace}>
111 <Timing timing={requestResponse.response?.timing ?? NaN} />
112 <button className={styles.resend} onClick={() => resend()}>Resend</button>
113 </div>
114 </div>
115 </div>
116 <div className={styles.content}>
117 {
118 tab === 'headers' && (
119 <>
120 <Headers
121 title="Request Headers"
122 headers={requestResponse.request.headers}
123 />
124 {
125 requestResponse.response && (
126 <Headers
127 title="Response Headers"
128 headers={requestResponse.response.headers}
129 />
130 )
131 }
132 </>
133 )
134 }
135 {
136 tab === 'request' && (
137 <Content data={requestResponse.request} raw={raw} setRaw={setRaw}/>
138 )
139 }
140 {
141 tab === 'response' && requestResponse.response !== undefined && (
142 <Content data={requestResponse.response} raw={raw} setRaw={setRaw}/>
143 )
144 }
145 </div>
146 </div>
147 )
148}
diff --git a/src/components/RequestSummary/RequestSummary.module.scss b/src/components/RequestSummary/RequestSummary.module.scss
new file mode 100644
index 0000000..e1752b3
--- /dev/null
+++ b/src/components/RequestSummary/RequestSummary.module.scss
@@ -0,0 +1,24 @@
1.requestSummary {
2 display: grid;
3 grid-template-columns: auto 1fr auto;
4 padding: 1em;
5 grid-gap: 1em;
6 align-items: center;
7}
8
9.method {
10}
11
12.statusCode {
13 padding: 0.2em;
14 border-radius: 0.4em;
15 background-color: darkgray;
16
17 &.info, &.success, &.redirect {
18 background-color: forestgreen;
19 }
20
21 &.clientError, &.serverError {
22 background-color: red;
23 }
24}
diff --git a/src/components/RequestSummary/RequestSummary.tsx b/src/components/RequestSummary/RequestSummary.tsx
new file mode 100644
index 0000000..af928c0
--- /dev/null
+++ b/src/components/RequestSummary/RequestSummary.tsx
@@ -0,0 +1,33 @@
1import {RequestResponse} from "~hooks/useRequests";
2import * as React from "react";
3import classNames from "classnames";
4
5import styles from './RequestSummary.module.scss';
6
7interface RequestSummaryProps {
8 requestResponse: RequestResponse
9 className?: string
10}
11
12function isBetween(value: number, min: number, max: number) {
13 return value >= min && value <= max
14}
15
16export default function RequestSummary({ requestResponse: { request, response }, className = ''}: RequestSummaryProps) {
17 const statusCode = response?.status ?? 0
18 return (
19 <div className={classNames(styles.requestSummary, className)}>
20 <span className={styles.method}>{ request.method }</span>
21 <p>{ request.path }</p>
22 <span className={classNames(styles.statusCode, {
23 [styles.info]: isBetween(statusCode, 100, 199),
24 [styles.success]: isBetween(statusCode, 200, 299),
25 [styles.redirect]: isBetween(statusCode, 300, 399),
26 [styles.clientError]: isBetween(statusCode, 400, 499),
27 [styles.serverError]: isBetween(statusCode, 500, 599)
28 })}>
29 { response?.status ?? 'Loading...'}
30 </span>
31 </div>
32 )
33}