Added data download ability

This commit is contained in:
2023-02-19 21:10:00 +00:00
parent 48f080de7c
commit c850726f91
64 changed files with 12734 additions and 2 deletions

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

45
frontend/.yarnclean Normal file
View File

@@ -0,0 +1,45 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
appveyor.yml
circle.yml
codeship-services.yml
codeship-steps.yml
wercker.yml
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.travis.yml
# misc
*.md

8
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node:alpine
WORKDIR /app
COPY package.json ./
# COPY package-lock.json ./
COPY ./ ./
RUN npm i
EXPOSE 3000
CMD ["npm", "run", "dev"]

46
frontend/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://wixplosives.github.io/codux-config-schema/codux.config.schema.json",
"newComponent": {
"componentsPath": "src/components",
"templatesPath": "src/component-templates"
}
}

70
frontend/package.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react-modal": "^3.13.1",
"@types/react-transition-group": "^4.4.5",
"axios": "^1.2.2",
"buffer": "^6.0.3",
"dotenv": "^16.0.3",
"ffmpeg": "^0.0.4",
"fluent-ffmpeg": "^2.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-media-recorder": "^1.6.6",
"react-modal": "^3.16.1",
"react-native-trimmer": "^1.1.1",
"react-player": "^2.11.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.6.2",
"react-scripts": "5.0.1",
"react-transition-group": "^4.4.5",
"react-video-recorder": "^3.19.4",
"react-video-stream": "^1.0.1",
"redux": "^4.2.0",
"stream": "^0.0.2",
"styled-components": "^5.3.6",
"tailwindcss": "^3.2.4",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0",
"yarn": "^1.22.19"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@wixc3/react-board": "^2.1.3"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css"
integrity="sha512-+4zCK9k+qNFUR5X+cKL9EIR+ZOhtIloNl9GIKS57V1MyNsYpYcUrUeQc9vNfzsWfV28IaLL3i96P9sdNyeRssA=="
crossorigin="anonymous" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
frontend/src/App.css Normal file
View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

20
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Login from './components/LoginPage';
import ProtectedRoute from './components/ProtectedRoute';
import SignDetailpage from './components/SignDetailPage';
import SignsPage from './components/SignsPage';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<ProtectedRoute><SignsPage /></ProtectedRoute>} />
<Route path="/login" element={<Login />} />
<Route path="/signs/:id" element={<ProtectedRoute><SignDetailpage /></ProtectedRoute>} />
</Routes>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,34 @@
import { createBoard } from '@wixc3/react-board';
import { LoadingButton } from '../../../components/loading-button/loading-button';
import React, { useState } from "react";
import logo from "./logo.svg";
const LoadingButtonWrapper: React.FC<{}> = props => {
const [progress, setProgress] = useState(0);
const onClick = () => {
const interval = setInterval(() => {
setProgress((prevProgress) => {
if (prevProgress >= 100) {
clearInterval(interval);
return 0;
}
console.log(prevProgress);
return prevProgress + 15;
});
}, 1000);
}
return (
<LoadingButton onClick={onClick} progress={progress} />
)
}
export default createBoard({
name: 'LoadingButton',
Board: () => <LoadingButtonWrapper />
});

View File

@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { login } from '../services/login';
import { useNavigate } from "react-router-dom";
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
let navigate = useNavigate();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
try {
await login(email, password);
// redirect the user to the / page
navigate('/');
} catch (e: any) {
setError(e.message);
}
setLoading(false);
};
return (
<div className="bg-gray-200 h-screen flex items-center justify-center">
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow-md">
<h1 className="text-lg font-medium mb-4">Login</h1>
<div className="mb-4">
<label className="block text-gray-700 font-medium mb-2">Email</label>
<input
className="border border-gray-400 p-2 rounded-lg w-full"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 font-medium mb-2">Password</label>
<input
className="border border-gray-400 p-2 rounded-lg w-full"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>
<div>
{error && <p className="text-red-500">{error}</p>}
</div>
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" disabled={loading}>
{loading ? 'Loading...' : 'Login'}
</button>
</form>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,29 @@
import { Navigate } from 'react-router-dom';
const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
// check if user is logged in by checking local storage
const isLoggedIn = localStorage.getItem('accessToken');
// check if the token is expired
if (isLoggedIn) {
const expired_at = new Date(localStorage.getItem('expirationDate') || '');
if (expired_at < new Date()) {
localStorage.removeItem('accessToken');
localStorage.removeItem('expirationDate');
return <Navigate to="/login" />;
}
}
if (!isLoggedIn) {
return <Navigate to="/login" />;
}
return (
children
);
}
export default ProtectedRoute;

View File

@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Sign } from '../types/sign';
interface Props {
sign: Sign;
deleteSign: (id: number) => void;
}
const SignComponent: React.FC<Props> = ({ sign, deleteSign }) => {
const navigate = useNavigate();
const [showDeletePopup, setShowDeletePopup] = useState(false);
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
navigate(`/signs/${sign.id}`);
};
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setShowDeletePopup(true);
};
const handleConfirmDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
deleteSign(sign.id);
setShowDeletePopup(false);
};
const handleCancelDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setShowDeletePopup(false);
};
return (
<div className="relative bg-white p-6 rounded-lg shadow-md" onClick={onClick}>
{showDeletePopup && (
<div className="fixed inset-0 bg-gray-700 bg-opacity-50 flex justify-center items-center z-10">
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-lg font-medium mb-4">Are you sure you want to delete this sign?</p>
<div className="flex justify-end">
<button className="bg-gray-500 text-white px-4 py-2 rounded-md mr-2" onClick={handleCancelDeleteClick}>
Cancel
</button>
<button className="bg-red-500 text-white px-4 py-2 rounded-md" onClick={handleConfirmDeleteClick}>
Delete
</button>
</div>
</div>
</div>
)}
<button className="absolute top-2 right-2" onClick={handleDeleteClick}>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h1 className="text-2xl font-medium">{sign.name}</h1>
<p className="text-lg font-medium">Total video's: {sign.sign_videos.length}</p>
<p className="text-lg font-medium">Approved video's: {sign.sign_videos.filter((t) => t.approved).length}</p>
</div>
);
};
export default SignComponent;

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef, useEffect, ChangeEvent } from 'react';
import { Sign, SignVideo } from '../types/sign';
import { useParams } from 'react-router-dom';
import { getSign } from '../services/signs';
import ReactModal from 'react-modal';
import { acceptVideo, deleteVideo, uploadSignVideo } from '../services/signvideos';
import { LoadingButton } from './loading-button/loading-button';
import VideoRecorder from 'react-video-recorder';
import SignVideoGrid from './SignVideoGrid';
import SignVideoPlayer from './SignVideoPlayer';
interface Props {
sign?: Sign;
}
type Params = {
id: string;
}
const SignDetailpage: React.FC<Props> = (props) => {
const [sign, setSign] = useState<Sign | null>(props.sign || null);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const signVideoRef = useRef<HTMLVideoElement>(null);
const popupVideoRef = useRef<HTMLVideoElement>(null);
const [popUpShown, setPopUpShown] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [currentVideo, setCurrentVideo] = useState<number | null>(null);
useEffect(() => {
if (recordedBlob) {
setVideoUrl(URL.createObjectURL(recordedBlob));
setPopUpShown(true);
} else {
setVideoUrl(null);
}
}, [recordedBlob]);
const handleUploadProgress = (progess: number) => {
if (progess) {
setUploadProgress(progess);
}
}
const acceptSignVideo = (approved: boolean) => {
// update the sign video in the sign
if (sign != null && currentVideo != null) {
console.log('accepting video');
acceptVideo(sign.id, sign.sign_videos[currentVideo].id, approved).then((response) => {
const newSign = { ...sign };
const newSignVideo = { ...newSign.sign_videos[currentVideo] };
newSignVideo.approved = approved;
newSign.sign_videos[currentVideo] = newSignVideo;
setSign(newSign);
});
}
}
const deleteSignVideo = () => {
deleteVideo(sign!.id, sign!.sign_videos[currentVideo!].id).then((response) => {
const newSign = { ...sign! };
newSign.sign_videos.splice(currentVideo!, 1);
setSign(newSign);
setCurrentVideo(null);
});
}
const handleUpload = async () => {
setUploadProgress(0);
uploadSignVideo(sign!.id, recordedBlob!, handleUploadProgress).then((response) => {
setUploadProgress(100);
// add the new sign video to the sign
console.log(response)
const newSign = { ...sign! };
newSign.sign_videos.push(response);
setSign(newSign);
}).catch((error) => {
setUploadProgress(null);
}
);
}
// get the sign id param
const { id } = useParams<Params>();
useEffect(() => {
if (signVideoRef.current) {
signVideoRef.current.play();
}
}, []);
useEffect(() => {
// if no sign given, get the sign
if (!sign) {
getSign(parseInt(id || '-1')).then((sign) => {
setSign(sign);
});
}
}, [id]);
const dismissPopup = () => {
setPopUpShown(false);
// remove the recorded blob
setRecordedBlob(null);
};
return (
<div>
{
sign ?
<div className="flex">
<div className="w-1/2">
<video loop controls width='100%' height='100%'>
<source src={sign.video_url} type='video/mp4' />
</video>
</div>
<div className="w-1/2">
{currentVideo == null ?
<VideoRecorder
countdownTime={3000}
onRecordingComplete={(blob) => {
setRecordedBlob(blob)
}}
timeLimit={4000}
/> :
<SignVideoPlayer sign_id={sign.id} sign_video={sign.sign_videos[currentVideo]} approveSignVideo={acceptSignVideo} deleteSignVideo={deleteSignVideo} />
}
</div>
</div > : <div>Loading...</div>
}
<div>
<button onClick={() => {
window.location.href = '/';
}} className="bg-white p-2 rounded-full text-red-600 hover:bg-red-600 hover:text-white absolute top-1 left-1">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
</div>
<ReactModal
isOpen={popUpShown}
shouldCloseOnOverlayClick={false}
className="modal bg-white rounded-3xl bg-gray-300 p-7"
ariaHideApp={false}
style={{
content: {
position: "absolute",
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
}}
>
{videoUrl &&
<div>
<video key="vid" ref={popupVideoRef} src={videoUrl!} controls loop className="pb-4" />
<LoadingButton title="Upload" onClick={handleUpload} progress={uploadProgress} />
</div>
}
<button onClick={dismissPopup} className="bg-white p-2 rounded-full text-red-600 hover:bg-red-600 hover:text-white absolute top-1 right-1">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</ReactModal>
<SignVideoGrid currentVideo={currentVideo} sign={sign} setCurrentVideo={setCurrentVideo} />
</div>
);
};
export default SignDetailpage;

View File

@@ -0,0 +1,44 @@
import React, { useEffect, useState } from 'react';
import { getSigns, addSign } from '../services/signs';
import { Sign, SignVideo } from '../types/sign';
import SignComponent from './SignComponent';
import SignVideoThumbnail from './SignVideoThumbnail';
interface Props {
sign: Sign | null;
currentVideo: number | null;
setCurrentVideo: (sign_video: number | null) => void;
}
const SignVideoGrid: React.FC<Props> = ({ sign, setCurrentVideo, currentVideo }) => {
const [isHovered, setIsHovered] = React.useState(false);
const handleVideoClick = (sign_video: number) => {
setCurrentVideo(sign_video);
}
return (
<div className="grid grid-flow-col auto-cols-max gap-5 mt-5" >
{sign != null &&
<div className={`rounded-lg w-60 h-32 ${isHovered ? 'bg-gray-300' : 'bg-gray-200'} flex items-center justify-center ${isHovered ? 'text-6xl' : 'text-4xl'}`} onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}} onClick={
() => {
setCurrentVideo(null);
}
}>
<i className={`fas fa-camera ${isHovered ? 'text-6xl' : 'text-4xl'}`}></i>
</div>
}
{sign &&
sign.sign_videos.map((vid, i) => <SignVideoThumbnail selected={currentVideo == i} sign_id={sign.id} sign_video={vid} handle_play={() => handleVideoClick(i)} />)
}
</div >
);
};
export default SignVideoGrid;

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import ReactPlayer from 'react-player';
import { getSigns, addSign } from '../services/signs';
import { getVideo } from '../services/signvideos';
import { Sign, SignVideo } from '../types/sign';
import SignComponent from './SignComponent';
import SignVideoThumbnail from './SignVideoThumbnail';
import { Video } from 'react-video-stream'
interface Props {
sign_id: number;
sign_video: SignVideo;
approveSignVideo: (approve: boolean) => void;
deleteSignVideo: () => void;
}
const SignVideoPlayer: React.FC<Props> = ({ sign_id, sign_video, approveSignVideo, deleteSignVideo }) => {
const [videoBlob, setVideoBlob] = useState<string | null>(null)
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
getVideo(sign_id, sign_video.id).then((response) => {
setVideoBlob(URL.createObjectURL(response))
})
}, []);
const handleClick = () => {
approveSignVideo(!sign_video.approved)
}
const handleDeleteClick = () => {
setShowConfirm(true);
}
const handleConfirmDelete = () => {
setShowConfirm(false);
deleteSignVideo();
}
useEffect(() => {
getVideo(sign_id, sign_video.id).then((response) => {
setVideoBlob(URL.createObjectURL(response))
})
}, [sign_video]);
return (
<div className="flex flex-col items-center">
{videoBlob ?
<div>
<video src={videoBlob} controls className="w-full" />
<div className="flex justify-between">
<button
className={`relative mt-4 ${sign_video.approved ? "bg-red-500" : "bg-green-500"} w-full text-white font-bold py-2 px-4 rounded-full transition-transform duration-200 transform hover:-translate-y-1 hover:scale-105
`}
onClick={handleClick}
>
{!sign_video.approved ? "Accept video" : "Reject video"}
</button>
<button
className={`relative mt-4 text-white font-bold py-2 px-4 rounded-full transition-transform duration-200 transform hover:-translate-y-1 hover:scale-105 bg-red-500 `}
onClick={handleDeleteClick}
>
<svg viewBox="0 0 24 24" width="24" height="24" className="fill-current">
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
</div>
:
<div>Loading...</div>
}
{showConfirm &&
<div className="fixed top-0 left-0 right-0 bottom-0 z-10 flex justify-center items-center">
<div className="bg-white rounded-lg p-6">
<div className="text-center text-lg mb-4">Are you sure you want to delete this video?</div>
<div className="flex justify-between">
<button className="bg-red-500 text-white px-4 py-2 rounded-lg" onClick={handleConfirmDelete}>
Delete
</button>
<button className="bg-gray-500 text-white px-4 py-2 rounded-lg" onClick={() => setShowConfirm(false)}>
Cancel
</button>
</div>
</div>
</div>
}
</div >
);
};
export default SignVideoPlayer;

View File

@@ -0,0 +1,61 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Sign, SignVideo } from '../types/sign';
import { getThumbail } from '../services/signvideos';
interface Props {
sign_id: number;
sign_video: SignVideo;
selected: boolean;
handle_play: () => any;
}
const SignVideoThumbnail: React.FC<Props> = ({ sign_id, sign_video, handle_play, selected }) => {
const [blob, setBlob] = React.useState<Blob | null>(null);
const [isHovered, setIsHovered] = React.useState(false);
useEffect(() => {
getThumbail(sign_id, sign_video.id).then((response) => {
setBlob(response);
});
}, []);
return (
<div>
{blob &&
<div className="video-thumbnail" onClick={handle_play}
style={{ position: "relative", opacity: selected ? 0.5 : 1, cursor: "pointer" }}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
>
<img className={`rounded-lg w-60 h-32 object-cover border-4 ${sign_video.approved ? " border-emerald-700" : "border-orange-500"}`} src={URL.createObjectURL(blob)} />
<div className="play-btn" style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${isHovered ? 1.5 : 1})`,
background: "rgba(255, 255, 255, 0.7)",
borderRadius: "50%",
width: "50px",
height: "50px",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
}
</div>
);
};
export default SignVideoThumbnail;

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { getSigns, addSign, downloadSigns, deleteSign } from '../services/signs';
import { Sign } from '../types/sign';
import SignComponent from './SignComponent';
const SignsPage: React.FC = () => {
const [signs, setSigns] = useState<Sign[]>([]);
const [newSign, setNewSign] = useState('');
const [newSignError, setNewSignError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const handleAddSign = async () => {
addSign(newSign).then((sign) => {
setSigns([...signs, sign]);
setNewSign('');
setNewSignError(null);
}).catch((error) => {
setNewSignError(error.message);
});
};
const handleDownloadData = async () => {
downloadSigns().then((blob: Blob) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'data.zip');
document.body.appendChild(link);
link.click();
link.remove();
}).catch((error: Error) => {
console.log(error);
});
};
const handleDeleteSign = async (id: number) => {
deleteSign(id).then(() => {
setSigns(signs.filter((sign) => sign.id !== id));
});
};
useEffect(() => {
// get the signs from the api
getSigns().then((signs) => {
console.log(signs)
setSigns(signs);
setLoading(false);
});
}, []);
return (
<div className="flex flex-col items-left bg-gray-100 min-h-screen">
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="mb-4">
<input
className="border border-gray-400 p-2 rounded-lg w-full"
type="text"
value={newSign}
onChange={(event) => setNewSign(event.target.value)}
placeholder="Enter sign url"
/>
</div>
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handleAddSign}>
Add Sign
</button>
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600 ml-2" onClick={handleDownloadData}>
Download Data
</button>
</div>
{newSignError && <p className="text-red-500">{newSignError}</p>}
{!loading ?
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-5 mt-5">
{
signs.map((sign) => <SignComponent deleteSign={handleDeleteSign} key={sign.id} sign={sign} />)
}
</div>
:
<p>Loading...</p>
}
</div>
);
};
export default SignsPage;

View File

@@ -0,0 +1,9 @@
import React from 'react';
export interface ButtonProps {
className?: string;
}
export const Button: React.FC<ButtonProps> = ({ className = '' }) => (
<div className={className}>Button</div>
);

View File

@@ -0,0 +1,54 @@
import React, { useState, useEffect } from 'react';
export interface LoadingButtonProps {
className?: string;
progress: number | null;
onClick: () => any;
title?: string;
}
export const LoadingButton: React.FC<LoadingButtonProps> = ({ className = '', onClick, progress, title = "Click me" }) => {
const [loading, setLoading] = useState(false);
useEffect(() => {
if (progress && progress >= 100) {
setLoading(false);
}
}, [progress])
const handleClick = () => {
setLoading(true);
onClick();
};
return (
<div className="relative">
<button
className={`relative bg-blue-500 hover:bg-blue-700 w-full text-white font-bold py-2 px-4 rounded-full transition-transform duration-200 transform hover:-translate-y-1 hover:scale-105 ${loading ? "cursor-not-allowed" : ""
}`}
onClick={handleClick}
disabled={loading}
style={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden", display: 'inline-block', border: '0px solid black'
}}
>
<span className={`text-center z-10 ${loading ? "opacity-50" : ""}`}>
{loading ? `${progress}%` : `${title}`}
</span>
<div
className={`absolute h-full transition-width duration-500`}
style={{
width: `${progress}%`,
backgroundColor: "blue",
top: 0,
left: 0,
zIndex: "-1"
}}
></div>
</button>
</div>
);
};

17
frontend/src/index.css Normal file
View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<App />
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
frontend/src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,33 @@
export const login = async (email: string, password: string) => {
const response = await fetch(`${process.env.REACT_APP_API_URL}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password,
}),
});
if (!response.ok) {
throw new Error("Login failed");
}
const data = await response.json();
// save access token to local storage
localStorage.setItem("accessToken", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
// calculate expiration date
const expirationDate = new Date(
new Date().getTime() + data.access_token_expiry * 1000
);
// save expiration date to local storage
localStorage.setItem("expirationDate", expirationDate.toISOString());
return data;
};

View File

@@ -0,0 +1,91 @@
const getSigns = async () => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to get signs
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/`, {
headers: {
Authorization: `Bearer ${token}`
}
});
// return the response
return response.json();
};
const addSign = async (url: string) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to add sign
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ "url": url })
});
if (!response.ok) {
throw new Error("Invalid url or sign already exists");
}
// return the response
return response.json();
};
const getSign = async (id: number) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to get sign
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/${id}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error("Sign not found");
}
// return the response
return response.json();
};
const downloadSigns = async () => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to download signs
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/download/all`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error("Something went wrong");
}
// return the response
return response.blob();
};
const deleteSign = async (id: number) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to delete sign
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error("Sign not found");
}
// return the response
return response.json();
};
export { addSign, getSigns, getSign, downloadSigns, deleteSign };

View File

@@ -0,0 +1,94 @@
import axios from 'axios';
const uploadSignVideo = async (id: number, recordedBlob: Blob, onUploadProgress: ((arg0: number) => void)) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
let formData = new FormData();
formData.append('video', recordedBlob);
// make request to get signs
const response = await axios.post(`${process.env.REACT_APP_API_URL}/signs/${id}/video/`, formData, {
headers: {
Authorization: `Bearer ${token}`,
ContentType: 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
onUploadProgress(
Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1))
);
}
});
// return the response
return response.data;
};
const getThumbail = async (sign_id: number, video_id: number) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to get signs
const response = await axios.get(`${process.env.REACT_APP_API_URL}/signs/${sign_id}/video/${video_id}/thumbnail/`, {
headers: {
Authorization: `Bearer ${token}`,
},
// response blob
responseType: 'blob'
});
// return the response
console.log(response)
return response.data;
};
const getVideo = async (sign_id: number, video_id: number) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to get signs
const response = await axios.get(`${process.env.REACT_APP_API_URL}/signs/${sign_id}/video/${video_id}/`, {
headers: {
Authorization: `Bearer ${token}`,
},
// response blob
responseType: 'blob'
});
// return the response
return response.data;
};
const acceptVideo = async (sign_id: number, video_id: number, approved: boolean = false) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to get signs
const response = await axios.patch(`${process.env.REACT_APP_API_URL}/signs/${sign_id}/video/${video_id}/`, { "approved": approved }, {
headers: {
Authorization: `Bearer ${token}`,
"Access-Control-Allow-Methods": '*',
},
});
// return the response
return response.data;
};
const deleteVideo = async (sign_id: number, video_id: number) => {
// get access token from local storage
const token = localStorage.getItem('accessToken');
// make request to get signs
const response = await axios.delete(`${process.env.REACT_APP_API_URL}/signs/${sign_id}/video/${video_id}/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
// return the response
return response.data;
};
export { uploadSignVideo, getThumbail, getVideo, acceptVideo, deleteVideo };

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,77 @@
declare module 'react-video-recorder' {
export interface VideoActionsProps {
isVideoInputSupported: boolean;
isInlineRecordingSupported: boolean;
thereWasAnError: boolean;
isRecording: boolean;
isCameraOn: boolean;
streamIsReady: boolean;
isConnecting: boolean;
isRunningCountdown: boolean;
countdownTime: number;
timeLimit: number;
showReplayControls: boolean;
replayVideoAutoplayAndLoopOff: boolean;
isReplayingVideo: boolean;
useVideoInput: boolean;
onTurnOnCamera?: () => any;
onTurnOffCamera?: () => any;
onOpenVideoInput?: () => any;
onStartRecording?: () => any;
onStopRecording?: () => any;
onPauseRecording?: () => any;
onResumeRecording?: () => any;
onStopReplaying?: () => any;
onConfirm?: () => any;
}
export interface ReactVideoRecorderProps {
/** Whether or not to start the camera initially */
isOnInitially?: boolean;
/** Whether or not to display the video flipped (makes sense for user facing camera) */
isFlipped?: boolean;
/** Pass this if you want to force a specific mime-type for the video */
mimeType?: string;
/** How much time to wait until it starts recording (in ms) */
countdownTime?: number;
/** Use this if you want to set a time limit for the video (in ms) */
timeLimit?: number;
/** Use this if you want to show play/pause/etc. controls on the replay video */
showReplayControls?: boolean;
/** Use this to turn off autoplay and looping of the replay video. It is recommended to also showReplayControls in order to play */
replayVideoAutoplayAndLoopOff?: boolean;
/** Use this if you want to customize the constraints passed to getUserMedia() */
constraints?: {
audio: any;
video: any;
};
chunkSize?: number;
dataAvailableTimeout?: number;
useVideoInput?: boolean;
renderDisconnectedView?: (props: any) => JSX.Element;
renderLoadingView?: (props: any) => JSX.Element;
renderVideoInputView?: (props: any) => JSX.Element;
renderUnsupportedView?: (props: any) => JSX.Element;
renderErrorView?: (props: any) => JSX.Element;
renderActions?: (props: VideoActionsProps) => JSX.Element;
onCameraOn?: () => any;
onTurnOnCamera?: () => any;
onTurnOffCamera?: () => any;
onStartRecording?: () => any;
onStopRecording?: () => any;
onPauseRecording?: () => any;
onRecordingComplete?: (videoBlob: any) => void;
onResumeRecording?: () => any;
onOpenVideoInput?: () => any;
onStopReplaying?: () => any;
onError?: () => any;
}
const ReactVideoRecorder: (props: ReactVideoRecorderProps) => JSX.Element;
export default ReactVideoRecorder;
}

View File

@@ -0,0 +1,26 @@
declare module 'react-video-stream' {
export function useDash(config: {
autoPlay: boolean;
remoteUrl: string;
requestHeader?: object;
requestToken?: string;
query?: object;
}): void;
export interface VideoProps {
autoPlay: boolean;
remoteUrl: string;
className?: string;
controls?: boolean;
style?: object;
contextMenu?: boolean;
controlsList?: string;
options?: {
requestHeader?: string;
requestToken?: string;
query?: object;
}
}
export const Video: React.FC<VideoProps>;
}

View File

@@ -0,0 +1,13 @@
export interface SignVideo {
id: number;
approved: boolean;
}
export interface Sign {
id: number;
url: string;
name: string;
sign_id: string;
video_url: string;
sign_videos: [SignVideo];
}

View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

10178
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff