Proof of concept for input field with routing to video page
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Michael Evans 2022-01-02 01:32:04 -06:00
parent 9ef4cc5853
commit 374137256e
9 changed files with 497 additions and 407 deletions

View File

@ -14,6 +14,7 @@
"google-protobuf": "^3.19.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-scripts": "4.0.3",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"

View File

@ -1,403 +1,24 @@
import {
MediaSet,
GrpcWebImpl,
MediaSetServiceClientImpl,
GetVideoProgress,
GetPeaksProgress,
} from './generated/media_set';
import { BrowserRouter, Route, Routes } from "react-router-dom";
import HomePage from "./components/HomePage";
import VideoPage from "./components/VideoPage";
import { GrpcWebImpl } from "./generated/media_set";
import "./App.css";
import { useState, useEffect, useRef, useCallback } from 'react';
import { AudioFormat } from './generated/media_set';
import { VideoPreview } from './VideoPreview';
import { Overview, CanvasLogicalWidth } from './Overview';
import { Waveform } from './Waveform';
import { ControlBar } from './ControlBar';
import { SeekBar } from './SeekBar';
import './App.css';
import { Duration } from './generated/google/protobuf/duration';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
// ported from backend, where should they live?
const thumbnailWidth = 177;
const thumbnailHeight = 100;
const initialViewportCanvasPixels = 100;
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
// Frames represents a range of audio frames.
export interface Frames {
start: number;
end: number;
}
export interface VideoPosition {
currentTime: number;
percent: number;
}
const video = document.createElement('video');
const audio = document.createElement('audio');
const apiURL = process.env.REACT_APP_API_URL || "http://localhost:8888";
function App(): JSX.Element {
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
const [viewport, setViewport] = useState<Frames>({ start: 0, end: 0 });
const [selection, setSelection] = useState<Frames>({ start: 0, end: 0 });
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([])
);
// position stores the current playback position. positionRef makes it
// available inside a setInterval callback.
const [position, setPosition] = useState({ currentTime: 0, percent: 0 });
const positionRef = useRef(position);
positionRef.current = position;
// effects
// TODO: error handling
const videoID = new URLSearchParams(window.location.search).get('video_id');
if (videoID == null) {
return <></>;
}
// fetch mediaset on page load:
useEffect(() => {
(async function () {
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const mediaSet = await service.Get({ youtubeId: videoID });
console.log('got media set:', mediaSet);
setMediaSet(mediaSet);
})();
}, []);
const updatePlayerPositionIntevalMillis = 30;
// setup player on first page load only:
useEffect(() => {
if (mediaSet == null) {
return;
}
const intervalID = setInterval(() => {
const currTime = audio.currentTime;
if (currTime == positionRef.current.currentTime) {
return;
}
const duration = mediaSet.audioFrames / mediaSet.audioSampleRate;
const percent = (currTime / duration) * 100;
// check if the end of selection has been passed, and pause if so:
if (
currentTimeToFrame(position.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end
) {
handlePause();
}
// update the current position
setPosition({ currentTime: audio.currentTime, percent: percent });
}, updatePlayerPositionIntevalMillis);
return () => clearInterval(intervalID);
}, [mediaSet, selection]);
// bind to keypress handler.
// selection is a dependency of the handleKeyPress handler, and must be
// included here.
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]);
// load audio when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching audio...');
const service = new MediaSetServiceClientImpl(newRPC());
const audioProgressStream = service.GetPeaks({
id: mediaSet.id,
numBins: CanvasLogicalWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
setOverviewPeaks(peaks);
const pipe = audioProgressStream.pipe(
first((progress: GetPeaksProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
audio.src = progressWithURL.url;
audio.muted = false;
audio.volume = 1;
console.log('set audio src', progressWithURL.url);
})();
}, [mediaSet]);
// load video when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching video...');
const service = new MediaSetServiceClientImpl(newRPC());
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
const pipe = videoProgressStream.pipe(
first((progress: GetVideoProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
video.src = progressWithURL.url;
console.log('set video src', progressWithURL.url);
})();
}, [mediaSet]);
// set viewport when MediaSet is loaded:
useEffect(() => {
if (mediaSet == null) {
return;
}
const numFrames = Math.min(
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
initialViewportCanvasPixels,
mediaSet.audioFrames
);
setViewport({ start: 0, end: numFrames });
}, [mediaSet]);
useEffect(() => {
console.debug('viewport updated', viewport);
}, [viewport]);
// handlers
const handleKeyPress = useCallback(
(evt: KeyboardEvent) => {
if (evt.code != 'Space') {
return;
}
if (audio.paused) {
handlePlay();
} else {
handlePause();
}
},
[selection]
);
// handler called when the selection in the overview (zoom setting) is changed.
const handleOverviewSelectionChange = useCallback(
(newViewport: Frames) => {
if (mediaSet == null) {
return;
}
console.log('set new viewport', newViewport);
setViewport({ ...newViewport });
if (!audio.paused) {
return;
}
setPositionFromFrame(newViewport.start);
},
[mediaSet, audio, video, selection]
);
// handler called when the selection in the main waveform view is changed.
const handleWaveformSelectionChange = useCallback(
(newSelection: Frames) => {
setSelection(newSelection);
if (mediaSet == null) {
return;
}
// move playback position to start of selection
const ratio = newSelection.start / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video, selection]
);
const handlePlay = useCallback(() => {
audio.play();
video.play();
}, [audio, video]);
const handlePause = useCallback(() => {
video.pause();
audio.pause();
if (selection.start != selection.end) {
setPositionFromFrame(selection.start);
}
}, [audio, video, selection]);
const handleClip = useCallback(() => {
(async function () {
console.debug('clip', selection);
if (mediaSet == null) {
return;
}
// TODO: support File System Access API fallback
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
const fileStream = await h.createWritable();
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const stream = service.GetAudioSegment({
id: mediaSet.id,
format: AudioFormat.MP3,
startFrame: selection.start,
endFrame: selection.end,
});
await stream.forEach((p) => fileStream.write(p.audioData));
console.debug('finished writing stream');
await fileStream.close();
console.debug('closed stream');
})();
}, [mediaSet, selection]);
const setPositionFromFrame = useCallback(
(frame: number) => {
if (mediaSet == null) {
return;
}
const ratio = frame / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video]
);
// helpers
const currentTimeToFrame = useCallback(
(currentTime: number): number => {
if (mediaSet == null) {
return 0;
}
const dur = mediaSet.audioFrames / mediaSet.audioSampleRate;
const ratio = currentTime / dur;
return Math.round(mediaSet.audioFrames * ratio);
},
[mediaSet]
);
// render component
const containerStyles = {
border: '1px solid black',
width: '90%',
margin: '1em auto',
minHeight: '500px',
height: '700px',
display: 'flex',
flexDirection: 'column',
} as React.CSSProperties;
const offsetPixels = Math.floor(thumbnailWidth / 2);
if (mediaSet == null) {
// TODO: improve
return <></>;
}
return (
<>
<div className="App">
<div style={containerStyles}>
<ControlBar
onPlay={handlePlay}
onPause={handlePause}
onClip={handleClip}
/>
<Overview
peaks={overviewPeaks}
mediaSet={mediaSet}
offsetPixels={offsetPixels}
height={80}
viewport={viewport}
position={position}
onSelectionChange={handleOverviewSelectionChange}
/>
<Waveform
mediaSet={mediaSet}
position={position}
viewport={viewport}
offsetPixels={offsetPixels}
onSelectionChange={handleWaveformSelectionChange}
/>
<SeekBar
position={video.currentTime}
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
offsetPixels={offsetPixels}
onPositionChanged={(position: number) => {
video.currentTime = position;
audio.currentTime = position;
}}
/>
<VideoPreview
mediaSet={mediaSet}
video={video}
position={position}
duration={millisFromDuration(mediaSet.videoDuration)}
height={thumbnailHeight}
/>
</div>
<ul style={{ listStyleType: 'none' } as React.CSSProperties}>
<li>Frames: {mediaSet.audioFrames}</li>
<li>
Viewport (frames): {viewport.start} to {viewport.end}
</li>
<li>
Selection (frames): {selection.start} to {selection.end}
</li>
<li>
Position (frames):{' '}
{Math.round(mediaSet.audioFrames * (position.percent / 100))}
</li>
<li>Position (seconds): {position.currentTime}</li>
<li></li>
</ul>
</div>
</>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/video/:videoId" element={<VideoPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
function millisFromDuration(dur?: Duration): number {
if (dur == undefined) {
return 0;
}
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
}
export function newRPC(): GrpcWebImpl {
return new GrpcWebImpl(apiURL, {});
}

View File

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { MediaSet } from './generated/media_set';
import { Frames, VideoPosition } from './App';
import { WaveformCanvas } from './WaveformCanvas';
import { HudCanvas, EmptySelectionAction } from './HudCanvas';
import { Observable } from 'rxjs';

View File

@ -1,5 +1,5 @@
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
import { newRPC, VideoPosition } from './App';
import { newRPC } from './App';
import { useEffect, useRef } from 'react';
interface Props {

View File

@ -1,10 +1,10 @@
import { useEffect, useState, useCallback } from 'react';
import { Frames, VideoPosition, newRPC } from './App';
import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set';
import { WaveformCanvas } from './WaveformCanvas';
import { Selection, HudCanvas, EmptySelectionAction } from './HudCanvas';
import { from, Observable } from 'rxjs';
import { bufferCount } from 'rxjs/operators';
import { useEffect, useState, useCallback } from "react";
import { newRPC } from "./App";
import { MediaSetServiceClientImpl, MediaSet } from "./generated/media_set";
import { WaveformCanvas } from "./WaveformCanvas";
import { Selection, HudCanvas, EmptySelectionAction } from "./HudCanvas";
import { from, Observable } from "rxjs";
import { bufferCount } from "rxjs/operators";
interface Props {
mediaSet: MediaSet;
@ -45,7 +45,7 @@ export const Waveform: React.FC<Props> = ({
return;
}
console.log('fetch audio segment, frames', viewport);
console.log("fetch audio segment, frames", viewport);
const service = new MediaSetServiceClientImpl(newRPC());
const segment = await service.GetPeaksForSegment({
@ -55,7 +55,7 @@ export const Waveform: React.FC<Props> = ({
endFrame: viewport.end,
});
console.log('got segment', segment);
console.log("got segment", segment);
const peaks = from(segment.peaks).pipe(
bufferCount(mediaSet.audioChannels)
@ -119,17 +119,17 @@ export const Waveform: React.FC<Props> = ({
// render component
const containerStyles = {
background: 'black',
margin: '0 ' + offsetPixels + 'px',
background: "black",
margin: "0 " + offsetPixels + "px",
flexGrow: 1,
position: 'relative',
position: "relative",
} as React.CSSProperties;
const hudStyles = {
borderLineWidth: 0,
borderStrokeStyle: 'transparent',
borderStrokeStyle: "transparent",
positionLineWidth: 6,
positionStrokeStyle: 'red',
positionStrokeStyle: "red",
};
return (

View File

@ -0,0 +1,40 @@
import { ChangeEventHandler, MouseEventHandler, useState } from "react";
import { useNavigate } from "react-router-dom";
const extractVideoIDFromURL = (input: string): string | null => {
const { searchParams } = new URL(input);
return searchParams.get("v");
};
function HomePage(): JSX.Element {
const [input, setInput] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setInput(event.target.value);
};
const handleSubmit: MouseEventHandler<HTMLButtonElement> = () => {
try {
const videoId = extractVideoIDFromURL(input);
if (videoId === null) {
setError("URL not valid, please enter a valid YouTube URL");
} else {
navigate(`/video/${videoId}`);
}
} catch (err) {
setError("URL not valid, please enter a valid YouTube URL");
}
};
return (
<div>
<input value={input} onChange={handleChange} />
<button onClick={handleSubmit}>Submit</button>
{Boolean(error) && <div>{error}</div>}
</div>
);
}
export default HomePage;

View File

@ -0,0 +1,391 @@
import {
MediaSet,
MediaSetServiceClientImpl,
GetVideoProgress,
GetPeaksProgress,
} from '../generated/media_set';
import { useState, useEffect, useRef, useCallback } from 'react';
import { AudioFormat } from '../generated/media_set';
import { VideoPreview } from '../VideoPreview';
import { Overview, CanvasLogicalWidth } from '../Overview';
import { Waveform } from '../Waveform';
import { ControlBar } from '../ControlBar';
import { SeekBar } from '../SeekBar';
import { Duration } from '../generated/google/protobuf/duration';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { newRPC } from '../App';
import { useParams } from 'react-router-dom';
// ported from backend, where should they live?
const thumbnailWidth = 177;
const thumbnailHeight = 100;
const initialViewportCanvasPixels = 100;
// Frames represents a range of audio frames.
const video = document.createElement('video');
const audio = document.createElement('audio');
type VideoPageParams = {
videoId: string;
}
function VideoPage(): JSX.Element {
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
const [viewport, setViewport] = useState<Frames>({ start: 0, end: 0 });
const [selection, setSelection] = useState<Frames>({ start: 0, end: 0 });
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([])
);
const { videoId } = useParams<VideoPageParams>();
// position stores the current playback position. positionRef makes it
// available inside a setInterval callback.
const [position, setPosition] = useState({ currentTime: 0, percent: 0 });
const positionRef = useRef(position);
positionRef.current = position;
// effects
// TODO: error handling
if (videoId == null) {
return <></>;
}
// fetch mediaset on page load:
useEffect(() => {
(async function () {
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const mediaSet = await service.Get({ youtubeId: videoId });
console.log('got media set:', mediaSet);
setMediaSet(mediaSet);
})();
}, []);
const updatePlayerPositionIntevalMillis = 30;
// setup player on first page load only:
useEffect(() => {
if (mediaSet == null) {
return;
}
const intervalID = setInterval(() => {
const currTime = audio.currentTime;
if (currTime == positionRef.current.currentTime) {
return;
}
const duration = mediaSet.audioFrames / mediaSet.audioSampleRate;
const percent = (currTime / duration) * 100;
// check if the end of selection has been passed, and pause if so:
if (
currentTimeToFrame(position.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end
) {
handlePause();
}
// update the current position
setPosition({ currentTime: audio.currentTime, percent: percent });
}, updatePlayerPositionIntevalMillis);
return () => clearInterval(intervalID);
}, [mediaSet, selection]);
// bind to keypress handler.
// selection is a dependency of the handleKeyPress handler, and must be
// included here.
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]);
// load audio when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching audio...');
const service = new MediaSetServiceClientImpl(newRPC());
const audioProgressStream = service.GetPeaks({
id: mediaSet.id,
numBins: CanvasLogicalWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
setOverviewPeaks(peaks);
const pipe = audioProgressStream.pipe(
first((progress: GetPeaksProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
audio.src = progressWithURL.url;
audio.muted = false;
audio.volume = 1;
console.log('set audio src', progressWithURL.url);
})();
}, [mediaSet]);
// load video when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching video...');
const service = new MediaSetServiceClientImpl(newRPC());
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
const pipe = videoProgressStream.pipe(
first((progress: GetVideoProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
video.src = progressWithURL.url;
console.log('set video src', progressWithURL.url);
})();
}, [mediaSet]);
// set viewport when MediaSet is loaded:
useEffect(() => {
if (mediaSet == null) {
return;
}
const numFrames = Math.min(
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
initialViewportCanvasPixels,
mediaSet.audioFrames
);
setViewport({ start: 0, end: numFrames });
}, [mediaSet]);
useEffect(() => {
console.debug('viewport updated', viewport);
}, [viewport]);
// handlers
const handleKeyPress = useCallback(
(evt: KeyboardEvent) => {
if (evt.code != 'Space') {
return;
}
if (audio.paused) {
handlePlay();
} else {
handlePause();
}
},
[selection]
);
// handler called when the selection in the overview (zoom setting) is changed.
const handleOverviewSelectionChange = useCallback(
(newViewport: Frames) => {
if (mediaSet == null) {
return;
}
console.log('set new viewport', newViewport);
setViewport({ ...newViewport });
if (!audio.paused) {
return;
}
setPositionFromFrame(newViewport.start);
},
[mediaSet, audio, video, selection]
);
// handler called when the selection in the main waveform view is changed.
const handleWaveformSelectionChange = useCallback(
(newSelection: Frames) => {
setSelection(newSelection);
if (mediaSet == null) {
return;
}
// move playback position to start of selection
const ratio = newSelection.start / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video, selection]
);
const handlePlay = useCallback(() => {
audio.play();
video.play();
}, [audio, video]);
const handlePause = useCallback(() => {
video.pause();
audio.pause();
if (selection.start != selection.end) {
setPositionFromFrame(selection.start);
}
}, [audio, video, selection]);
const handleClip = useCallback(() => {
(async function () {
console.debug('clip', selection);
if (mediaSet == null) {
return;
}
// TODO: support File System Access API fallback
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
const fileStream = await h.createWritable();
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const stream = service.GetAudioSegment({
id: mediaSet.id,
format: AudioFormat.MP3,
startFrame: selection.start,
endFrame: selection.end,
});
await stream.forEach((p) => fileStream.write(p.audioData));
console.debug('finished writing stream');
await fileStream.close();
console.debug('closed stream');
})();
}, [mediaSet, selection]);
const setPositionFromFrame = useCallback(
(frame: number) => {
if (mediaSet == null) {
return;
}
const ratio = frame / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video]
);
// helpers
const currentTimeToFrame = useCallback(
(currentTime: number): number => {
if (mediaSet == null) {
return 0;
}
const dur = mediaSet.audioFrames / mediaSet.audioSampleRate;
const ratio = currentTime / dur;
return Math.round(mediaSet.audioFrames * ratio);
},
[mediaSet]
);
// render component
const containerStyles = {
border: '1px solid black',
width: '90%',
margin: '1em auto',
minHeight: '500px',
height: '700px',
display: 'flex',
flexDirection: 'column',
} as React.CSSProperties;
const offsetPixels = Math.floor(thumbnailWidth / 2);
if (mediaSet == null) {
// TODO: improve
return <></>;
}
return (
<div className="App">
<div style={containerStyles}>
<ControlBar
onPlay={handlePlay}
onPause={handlePause}
onClip={handleClip}
/>
<Overview
peaks={overviewPeaks}
mediaSet={mediaSet}
offsetPixels={offsetPixels}
height={80}
viewport={viewport}
position={position}
onSelectionChange={handleOverviewSelectionChange}
/>
<Waveform
mediaSet={mediaSet}
position={position}
viewport={viewport}
offsetPixels={offsetPixels}
onSelectionChange={handleWaveformSelectionChange}
/>
<SeekBar
position={video.currentTime}
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
offsetPixels={offsetPixels}
onPositionChanged={(position: number) => {
video.currentTime = position;
audio.currentTime = position;
}}
/>
<VideoPreview
mediaSet={mediaSet}
video={video}
position={position}
duration={millisFromDuration(mediaSet.videoDuration)}
height={thumbnailHeight}
/>
</div>
<ul style={{ listStyleType: 'none' } as React.CSSProperties}>
<li>Frames: {mediaSet.audioFrames}</li>
<li>
Viewport (frames): {viewport.start} to {viewport.end}
</li>
<li>
Selection (frames): {selection.start} to {selection.end}
</li>
<li>
Position (frames):{' '}
{Math.round(mediaSet.audioFrames * (position.percent / 100))}
</li>
<li>Position (seconds): {position.currentTime}</li>
<li></li>
</ul>
</div>
);
}
export default VideoPage;
function millisFromDuration(dur?: Duration): number {
if (dur == undefined) {
return 0;
}
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
}

9
frontend/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
interface Frames {
start: number;
end: number;
}
interface VideoPosition {
currentTime: number;
percent: number;
}

View File

@ -1172,6 +1172,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.7.6":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa"
integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4", "@babel/template@^7.16.0", "@babel/template@^7.3.3":
version "7.16.0"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6"
@ -5648,6 +5655,13 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
history@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.2.0.tgz#7cdd31cf9bac3c5d31f09c231c9928fad0007b7c"
integrity sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==
dependencies:
"@babel/runtime" "^7.7.6"
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -9324,6 +9338,21 @@ react-refresh@^0.8.3:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
react-router-dom@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.2.1.tgz#32ec81829152fbb8a7b045bf593a22eadf019bec"
integrity sha512-I6Zax+/TH/cZMDpj3/4Fl2eaNdcvoxxHoH1tYOREsQ22OKDYofGebrNm6CTPUcvLvZm63NL/vzCYdjf9CUhqmA==
dependencies:
history "^5.2.0"
react-router "6.2.1"
react-router@6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.2.1.tgz#be2a97a6006ce1d9123c28934e604faef51448a3"
integrity sha512-2fG0udBtxou9lXtK97eJeET2ki5//UWfQSl1rlJ7quwe6jrktK9FCCc8dQb5QY6jAv3jua8bBQRhhDOM/kVRsg==
dependencies:
history "^5.2.0"
react-scripts@4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-4.0.3.tgz#b1cafed7c3fa603e7628ba0f187787964cb5d345"