Added data download ability

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

7
.gitignore vendored
View File

@ -1,3 +1,8 @@
.DS_Store
node_modules/
__pycache__/
__pycache__/
.devcontainer/
.env
*.db
data/
.vscode

View File

@ -7,5 +7,4 @@ COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]

7
backend/main.py Normal file
View File

@ -0,0 +1,7 @@
import uvicorn
from src.app import app
if __name__ == "__main__":
# Run the app with reload and pass app as string
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -1 +1,10 @@
fastapi
uvicorn
sqlmodel
python-dotenv
fastapi_jwt_auth
aiosqlite
passlib
requests
python-multipart
Pillow

0
backend/src/__init__.py Normal file
View File

115
backend/src/app.py Normal file
View File

@ -0,0 +1,115 @@
import inspect
import re
from datetime import timedelta
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
from fastapi_jwt_auth import AuthJWT
from fastapi_jwt_auth.exceptions import AuthJWTException
from pydantic import BaseModel
import src.settings as settings
from src.database.database import init_db
from src.exceptions.base_exception import BaseException
from src.exceptions.exception_handlers import (auth_exception_handler,
base_exception_handler)
app = FastAPI(title="SignDataCollector", version="0.0.1")
@app.on_event("startup")
async def on_startup():
await init_db()
class AuthSettings(BaseModel):
"""AuthSettings model"""
authjwt_secret_key: str = settings.JWT_SECRET_KEY
# authjwt_denylist_enabled: bool = True
authjwt_denylist_enabled: bool = False
authjwt_denylist_token_checks: dict = {"access", "refresh"}
access_expires: timedelta = timedelta(seconds=settings.ACCESS_EXPIRES)
refresh_expires: timedelta = timedelta(seconds=settings.REFRESH_EXPIRES)
authjwt_cookie_csrf_protect: bool = True
authjwt_token_location: dict = {"headers"}
authjwt_cookie_samesite: str = "lax"
@AuthJWT.load_config
def auth_config():
return AuthSettings()
# Add middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# include the routers
from .routers import auth_router, signs_router, signvideo_router
app.include_router(auth_router)
app.include_router(signs_router)
app.include_router(signvideo_router)
# Add the exception handlers
app.add_exception_handler(BaseException, base_exception_handler)
app.add_exception_handler(AuthJWTException, auth_exception_handler)
def custom_openapi():
"""custom_openapi generate the custom swagger api documentation
:return: custom openapi_schema
:rtype: openapi_schema
"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title="My Auth API",
version="1.0",
description="An API with an Authorize Button",
routes=app.routes,
# servers=[{"url": config.api_path}],
)
openapi_schema["components"]["securitySchemes"] = {
"Bearer Auth": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "Enter: **'Bearer <JWT>'**, where JWT is the access token",
}
}
# Get all routes where jwt_optional() or jwt_required
api_router = [route for route in app.routes if isinstance(route, APIRoute)]
for route in api_router:
path = getattr(route, "path")
endpoint = getattr(route, "endpoint")
methods = [method.lower() for method in getattr(route, "methods")]
for method in methods:
# access_token
if (
re.search("RoleChecker", inspect.getsource(endpoint))
or re.search("jwt_required", inspect.getsource(endpoint))
or re.search("fresh_jwt_required", inspect.getsource(endpoint))
or re.search("jwt_optional", inspect.getsource(endpoint))
):
openapi_schema["paths"][path][method]["security"] = [
{"Bearer Auth": []}
]
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi

View File

@ -0,0 +1,129 @@
""" This module includes the create, read, update and delete functions for the models """
from typing import List, Optional, Type, TypeVar
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import SQLModel
T = TypeVar("T", SQLModel, object)
async def read_all_where(
model: T, *args, select_in_load: List = [], session: AsyncSession
) -> List[T]:
"""read_all_where this function reads all the entries from a specific model,
if a key and a value are passed this will be checked on each instance of model
example read all Users: read_all_where(User)
example read all Users with Role admin: read_all_where(User, User.role, UserRole.ADMIN)
:param model: the model to read all entries from
:type model: SQLModel
:param key: the key to check the value on, pass this argument as SQLModel.key (see the example)
:param value: the value that the key should have
:return: list with all data-entries of type model
:rtype: List[SQLModel]
"""
statement = select(model)
for arg in args:
statement = statement.where(arg)
for sil in select_in_load:
statement = statement.options(selectinload(sil))
res = await session.execute(statement)
return [value for (value,) in res.all()]
async def read_where(
model: T, *args, select_in_load: List = [], session: AsyncSession
) -> Optional[T]:
"""read_where this function reads one entry from a specific model that matches the given key and value
example read user with id user_id: read_where(User, User.id == user_id)
:param model: the model to read all entries from
:type model: SQLModel
:param key: the key to check the value on, pass this argument as SQLModel.key (see the example)
:param value: the value that the key should have
:return: a data-entry of type model that has values as values for the keys, or None is no such data-entry could be found
:rtype: Optional[SQLModel]
"""
statement = select(model)
for arg in args:
statement = statement.where(arg)
for sil in select_in_load:
statement = statement.options(selectinload(sil))
res = await session.execute(statement)
first = res.first()
if not first:
return None
return first[0]
async def count_where(model: Type[T], *args, session: AsyncSession) -> int:
"""count_where Count the objects where in mongodb
:param model: _description_
:type model: Type[SQLModel]
:return: the object count
:rtype: int
"""
statement = select(model)
for arg in args:
statement = statement.where(arg)
res = await session.execute(statement)
if res is None:
return 0
return len(res.all())
async def update(model: T, session: AsyncSession) -> Optional[T]:
"""update this function updates one entry from a model (the one with the same id, or else it adds the id)
example new_user is the updated version of the old user but the id remained: update(new_user)
:param model: an instance of the model to update / create
:type model: SQLModel
:return: the updated user upon succes
:rtype: Optional[SQLModel]
"""
session.add(model)
await session.commit()
await session.refresh(model)
return model
async def update_all(models: List[T], session: AsyncSession) -> Optional[List[T]]:
"""update this function updates all entries from models (the one with the same id, or else it adds the id)
example new_user is the updated version of the old user but the id remained: update(new_user)
:param models: a list of instances of models to update / create
:type models: List[SQLModel]
:return: the updated user upon success
:rtype: Optional[SQLModel]
"""
session.add_all(models)
await session.commit()
for model in models:
await session.refresh(model)
return models
async def delete(model: T, session: AsyncSession) -> None:
"""Deletes the given model from the database
:param model: an instance of the model to delete
:type model: SQLModel
:return: None
:rtype: None
"""
await session.delete(model)
await session.commit()

View File

@ -0,0 +1,59 @@
""" This module includes the function for the database connection """
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
import src.settings as settings
from src.models.auth import User
from src.utils.cryptography import get_password_hash
async def init_db(fill=True):
from src.database.engine import engine
async with engine.begin() as conn:
# await conn.run_sync(SQLModel.metadata.drop_all)
if settings.TEST_MODE or settings.DB_USE_SQLITE:
await conn.execute(text("PRAGMA foreign_keys=ON"))
await conn.run_sync(SQLModel.metadata.create_all)
if settings.DEFAULT_USER_ENABLED:
async_session = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
# check if there exists a user
users = await User.get_all(session=session)
if len(users) == 0:
# create a new user
user = User(
email=settings.DEFAULT_USER_EMAIL,
hashed_password=get_password_hash(settings.DEFAULT_USER_PASSWORD),
)
await user.save(session=session)
if fill:
async_session = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
# await fill_database(session=session)
pass
async def get_session() -> AsyncSession:
"""get_session return a new session
:return: a new session
:rtype: AsyncSession
:yield: a new session
:rtype: Iterator[AsyncSession]
"""
from src.database.engine import engine
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session

View File

@ -0,0 +1,47 @@
import json
from urllib.parse import quote_plus
import pydantic
from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
import src.settings as settings
engine: AsyncEngine
def _custom_json_serializer(*args, **kwargs) -> str:
"""
Encodes json in the same way that pydantic does.
"""
return json.dumps(*args, default=pydantic.json.pydantic_encoder, **kwargs)
# check if the engine must be created for the tests -> in memory sqlite
if settings.TEST_MODE:
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
json_serializer=_custom_json_serializer,
)
elif settings.DB_USE_SQLITE:
engine = create_async_engine(
URL.create(drivername="sqlite+aiosqlite", database=settings.DB_SQLITE_PATH),
connect_args={"check_same_thread": False},
json_serializer=_custom_json_serializer,
)
else:
# use the postgresql database
_encoded_password = quote_plus(settings.DB_PASSWORD)
engine = create_async_engine(
URL.create(
drivername="postgresql+asyncpg",
username=settings.DB_USER,
password=_encoded_password,
host=settings.DB_HOST,
port=settings.DB_PORT,
database=settings.DB_NAME,
json_serializer=_custom_json_serializer,
),
pool_pre_ping=True,
)

View File

@ -0,0 +1,47 @@
""" This module includes the BaseException
"""
import json
from fastapi.responses import JSONResponse
class BaseException(Exception):
"""BaseException is the base class for all exceptions
:param Exception: inherits from python Exception class
"""
def __init__(self, status_code: int, message: str):
"""__init__ init the class with the status code and message
:param status_code: the status code of the response
:type status_code: int
:param message: the message of exception
:type message: str
"""
self.status_code = status_code
self.message = message
def json(self) -> JSONResponse:
"""json return the json of the exception
:return: json reponse of exception
:rtype: JSONResponse
"""
return JSONResponse(
status_code=self.status_code, content={"message": self.message}
)
def checkResponse(self, response: BaseException) -> bool:
"""checkResponse compare the response exception with the exception
:param response: the response exception
:type response: _type_
:return: if excepiton equals the response exception
:rtype: bool
"""
return (
response.status_code == self.status_code
and json.loads(response.content)["message"] == self.message
)

View File

@ -0,0 +1,31 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi_jwt_auth.exceptions import AuthJWTException
async def base_exception_handler(request: Request, exception: BaseException):
"""my_exception_handler handler for the raised exceptions
:param request: the request
:type request: Request
:param exception: the raised exception
:type exception: BaseException
:return: the exception in json format
:rtype: JSONResponse
"""
return exception.json()
async def auth_exception_handler(request: Request, exception: AuthJWTException):
"""auth_exception_handler handler for the raised exceptions
:param request: the request
:type request: Request
:param exception: the raised exception
:type exception: AuthJWTException
:return: the exception in json format
:rtype: JSONResponse
"""
return JSONResponse(
status_code=exception.status_code, content={"message": exception.message}
)

View File

@ -0,0 +1,11 @@
from src.exceptions.base_exception import BaseException
class LoginException(BaseException):
"""LoginException exception raised when something went wrong during the login process
:param BaseException: inherits from BaseException
"""
def __init__(self, message: str = ""):
"""__init__ inits parent class with status code and message"""
super().__init__(400, message)

View File

@ -0,0 +1,53 @@
from typing import List, Optional
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
from src.database.crud import delete, read_all_where, read_where, update
class SQLModelExtended(SQLModel):
__abstract__ = True
@classmethod
async def get_all(
self, session: AsyncSession, select_in_load: List = []
) -> List[SQLModel]:
res = await read_all_where(self, select_in_load=select_in_load, session=session)
return res
async def save(self, session: AsyncSession):
await update(self, session=session)
@classmethod
async def get_by_id(
self, id: int, session: AsyncSession, select_in_load: List = []
) -> Optional[SQLModel]:
res = await read_where(
self, self.id == id, select_in_load=select_in_load, session=session
)
return res
@classmethod
async def get_where(self, *args, session: AsyncSession):
res = await read_where(self, *args, session=session)
return res
@classmethod
async def get_all_where(self, *args, session: AsyncSession):
res = await read_all_where(self, *args, session=session)
return res
async def delete(self, session: AsyncSession) -> None:
await delete(self, session=session)
async def update_from(self, data, session: AsyncSession) -> None:
data_dict = data.dict(exclude_unset=True)
for key in data_dict.keys():
setattr(self, key, getattr(data, key))
await update(self, session=session)
@classmethod
async def delete_by_id(self, id: int, session: AsyncSession) -> None:
res = await read_where(self, self.id == id, session=session)
await delete(res, session=session)

View File

@ -0,0 +1,26 @@
from typing import Optional
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship
from src.database.crud import read_where
from src.models.SQLModelExtended import SQLModelExtended
class Login(BaseModel):
email: str
password: str
class Config:
orm_mode = True
class User(SQLModelExtended, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str
hashed_password: str
@classmethod
async def get_by_email(self, email: str, session: AsyncSession) -> "User":
return await read_where(self, self.email == email, session=session)

View File

@ -0,0 +1,61 @@
from typing import List
import requests
from pydantic import BaseModel
from sqlmodel import Field, Relationship, SQLModel
from src.exceptions.base_exception import BaseException
from src.models.signvideo import SignVideo
from src.models.SQLModelExtended import SQLModelExtended
class Sign(SQLModelExtended, table=True):
id: int = Field(primary_key=True)
url: str
name: str
sign_id: str
video_url: str
sign_videos: List[SignVideo] = Relationship(
back_populates="sign",
sa_relationship_kwargs={"lazy": "selectin"},
)
def __init__(self, url):
self.url = url
# get name and sign id from url
try:
t = self.url.split("/")[-1].split("?")
self.name = t[0]
self.sign_id = t[1].split("=")[1]
except:
raise BaseException(404, "Invalid url")
self.get_video_url()
def get_video_url(self):
try:
r = requests.get(f"https://woordenboek.vlaamsegebarentaal.be/api/glosses/{self.name}")
res_json = r.json()
# find the video url
for item in res_json["variants"]:
if str(item["signId"]) == str(self.sign_id):
self.video_url = item["video"]
break
except:
raise BaseException(404, "Invalid url")
# throw exception if video url not found
if self.video_url is None:
raise BaseException(404, "Video url not found")
class SignOut(BaseModel):
id: int
url: str
name: str
sign_id: str
video_url: str
sign_videos: List[SignVideo] = []

View File

@ -0,0 +1,25 @@
from pydantic import BaseModel
from sqlmodel import Field, Relationship, SQLModel
from src.exceptions.base_exception import BaseException
from src.models.SQLModelExtended import SQLModelExtended
class SignVideo(SQLModelExtended, table=True):
id: int = Field(primary_key=True)
approved: bool = False
# foreign key to sign
sign_id: int = Field(default=None, foreign_key="sign.id")
sign: "Sign" = Relationship(
back_populates="sign_videos",
sa_relationship_kwargs={"lazy": "selectin"},
)
# path where video saved
path: str
class SignVideoOut(BaseModel):
id: int
approved: bool

View File

@ -0,0 +1,10 @@
from pydantic import BaseModel
class TokenExtended(BaseModel):
"""an output model for the tokens (accessToken + refreshToken)"""
id: str
access_token: str
access_token_expiry: int
refresh_token: str

View File

@ -0,0 +1,3 @@
from .auth import router as auth_router
from .signs import router as signs_router
from .signvideo import router as signvideo_router

View File

@ -0,0 +1,60 @@
import datetime
from fastapi import APIRouter, Depends
from fastapi_jwt_auth import AuthJWT
from sqlalchemy.ext.asyncio import AsyncSession
import src.settings as settings
from src.database.database import get_session
from src.exceptions.login_exception import LoginException
from src.models.auth import Login, User
from src.models.token import TokenExtended
from src.utils.cryptography import verify_password
router = APIRouter(prefix="/auth")
@router.post("/login")
async def login(creds: Login, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
# check if user exists
user = await User.get_by_email(creds.email, session=session)
# check if password is correct
if user is None or not verify_password(creds.password, user.hashed_password):
raise LoginException("Invalid login credentials")
access_token = Authorize.create_access_token(
subject=user.id,
expires_time=datetime.timedelta(seconds=settings.ACCESS_EXPIRES),
)
refresh_token = Authorize.create_refresh_token(
subject=user.id,
expires_time=datetime.timedelta(seconds=settings.REFRESH_EXPIRES),
)
return TokenExtended(
id=str(user.id),
access_token=access_token,
refresh_token=refresh_token,
access_token_expiry=settings.ACCESS_EXPIRES,
)
@router.get("/refresh")
async def refresh(Authorize: AuthJWT = Depends()):
Authorize.jwt_refresh_token_required()
current_user = Authorize.get_jwt_subject()
access_token = Authorize.create_access_token(
subject=current_user.id,
expires_time=datetime.timedelta(seconds=settings.ACCESS_EXPIRES),
)
refresh_token = Authorize.create_refresh_token(
subject=current_user.id,
expires_time=datetime.timedelta(seconds=settings.REFRESH_EXPIRES),
)
return TokenExtended(
id=str(current_user.id),
access_token=access_token,
refresh_token=refresh_token,
access_token_expiry=settings.ACCESS_EXPIRES,
)

View File

@ -0,0 +1,123 @@
import datetime
import os
import zipfile
from fastapi import APIRouter, Depends, status
from fastapi.responses import FileResponse
from fastapi_jwt_auth import AuthJWT
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
import src.settings as settings
from src.database.database import get_session
from src.exceptions.login_exception import LoginException
from src.models.auth import Login, User
from src.models.sign import Sign, SignOut
from src.models.signvideo import SignVideo
from src.models.token import TokenExtended
router = APIRouter(prefix="/signs")
class SignUrl(BaseModel):
url: str
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignOut)
async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
Authorize.jwt_required()
user = Authorize.get_jwt_subject()
user = await User.get_by_id(id=user, session=session)
if not user:
raise LoginException("User not found")
sign = Sign(url.url)
# check if the sign already exists
signs = await Sign.get_all_where(Sign.url == sign.url, session=session)
if len(signs) > 0:
raise LoginException("Sign already exists")
await sign.save(session=session)
return sign
@router.get("/", status_code=status.HTTP_200_OK, response_model=list[SignOut])
async def get_signs(Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
Authorize.jwt_required()
user = Authorize.get_jwt_subject()
user = await User.get_by_id(id=user, session=session)
if not user:
raise LoginException("User not found")
signs = await Sign.get_all(select_in_load=[Sign.sign_videos],session=session)
return signs
@router.get("/{id}", status_code=status.HTTP_200_OK, response_model=SignOut)
async def get_sign(id: int, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
Authorize.jwt_required()
user = Authorize.get_jwt_subject()
user = await User.get_by_id(id=user, session=session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(id=id, select_in_load=[Sign.sign_videos], session=session)
return sign
@router.delete("/{id}", status_code=status.HTTP_200_OK)
async def delete_sign(id: int, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
Authorize.jwt_required()
user = Authorize.get_jwt_subject()
user = await User.get_by_id(id=user, session=session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(id=id, select_in_load=[Sign.sign_videos], session=session)
if not sign:
raise LoginException("Sign not found")
# delete the sign videos
for sign_video in sign.sign_videos:
# get path of the sign video
path = f"{settings.DATA_PATH}/{sign_video.path}"
# delete the file
os.remove(path)
await sign_video.delete(session=session)
await sign.delete(session=session)
return {"message": "Sign deleted"}
@router.get("/download/all", status_code=status.HTTP_200_OK)
async def download_all(Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
Authorize.jwt_required()
user = Authorize.get_jwt_subject()
user = await User.get_by_id(id=user, session=session)
if not user:
raise LoginException("User not found")
# get the approved sign videos
signs = await SignVideo.get_all_where(SignVideo.approved == True, session=session)
# extract the paths from the sign videos
paths = [sign.path for sign in signs]
zip_path = f"/tmp/{datetime.datetime.now().timestamp()}.zip"
# create the zip file
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, False) as zip_file:
for path in paths:
zip_file.write(f"{settings.DATA_PATH}/{path}", os.path.basename(path))
return FileResponse(zip_path, media_type="application/zip", filename="signs.zip")

View File

@ -0,0 +1,170 @@
import base64
import datetime
import io
import os
import random
import string
import subprocess
from fastapi import APIRouter, Depends, FastAPI, File, UploadFile, status
from fastapi.responses import FileResponse
from fastapi_jwt_auth import AuthJWT
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
import src.settings as settings
from src.database.database import get_session
from src.exceptions.login_exception import LoginException
from src.models.auth import User
from src.models.sign import Sign
from src.models.signvideo import SignVideo, SignVideoOut
from src.utils.cryptography import verify_password
def extract_thumbnail(video_path):
proc = subprocess.run(["ffmpeg", "-i", video_path, "-ss", "00:00:02.000", "-vframes", "1", "-f", "image2pipe", "-"], stdout=subprocess.PIPE)
byte_data = proc.stdout
return byte_data
router = APIRouter(prefix="/signs/{sign_id}/video")
# endpoint to upload a file and save it in the data folder
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignVideoOut)
async def sign_video(
sign_id: int,
video: UploadFile,
session: AsyncSession = Depends(get_session),
Authorize: AuthJWT = Depends(),
):
Authorize.jwt_required()
current_user_id: int = Authorize.get_jwt_subject()
user = await User.get_by_id(current_user_id, session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(sign_id, session)
if not sign:
raise LoginException("Sign not found")
video.filename = f"{sign.name}!{sign_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}_{''.join(random.choices(string.ascii_uppercase + string.digits, k=5))}.mp4"
# create the data folder if not exists
if not os.path.exists(settings.DATA_PATH):
os.makedirs(settings.DATA_PATH)
sign_video = SignVideo(
sign_id=sign_id,
path=video.filename
)
with open(settings.DATA_PATH + "/" + video.filename + ".test", 'wb') as f:
f.write(video.file.read())
command = ['ffmpeg', '-y', '-i', settings.DATA_PATH + "/" + video.filename + ".test", '-c:v', 'libx264', '-c:a', 'aac', '-strict', '-2', '-b:a', '192k', '-c:a', 'aac', '-strict', '-2', '-b:a', '192k', settings.DATA_PATH + "/" + video.filename]
subprocess.run(command, check=True)
# delete the temporary file
os.remove(settings.DATA_PATH + "/" + video.filename + ".test")
await sign_video.save(session)
return sign_video
@router.get("/{video_id}/thumbnail", status_code=status.HTTP_200_OK)
async def sign_video_thumbnail(
sign_id: int,
video_id: int,
session: AsyncSession = Depends(get_session),
Authorize: AuthJWT = Depends(),
):
Authorize.jwt_required()
current_user_id: int = Authorize.get_jwt_subject()
user = await User.get_by_id(current_user_id, session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(sign_id, session)
if not sign:
raise LoginException("Sign not found")
sign_video = await SignVideo.get_by_id(video_id, session)
if not sign_video:
raise BaseException("Sign video not found")
# extract the thumbnail from the video
bytes = extract_thumbnail(f"{settings.DATA_PATH}/{sign_video.path}")
return StreamingResponse(io.BytesIO(bytes), media_type="image/png")
@router.get("/{video_id}", status_code=status.HTTP_200_OK)
async def sign_video(
sign_id: int,
video_id: int,
session: AsyncSession = Depends(get_session),
Authorize: AuthJWT = Depends(),
):
Authorize.jwt_required()
current_user_id: int = Authorize.get_jwt_subject()
user = await User.get_by_id(current_user_id, session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(sign_id, session)
if not sign:
raise LoginException("Sign not found")
sign_video = await SignVideo.get_by_id(video_id, session)
if not sign_video:
raise BaseException("Sign video not found")
# return the video
return FileResponse(f"{settings.DATA_PATH}/{sign_video.path}", media_type="video/mp4")
class SignVideoUpdate(BaseModel):
approved: bool
@router.patch("/{video_id}", status_code=status.HTTP_200_OK)
async def sign_video(
sign_id: int,
video_id: int,
update: SignVideoUpdate,
session: AsyncSession = Depends(get_session),
Authorize: AuthJWT = Depends(),
):
Authorize.jwt_required()
current_user_id: int = Authorize.get_jwt_subject()
user = await User.get_by_id(current_user_id, session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(sign_id, session)
if not sign:
raise LoginException("Sign not found")
sign_video = await SignVideo.get_by_id(video_id, session)
if not sign_video:
raise BaseException("Sign video not found")
sign_video.approved = update.approved
await sign_video.save(session)
@router.delete("/{video_id}", status_code=status.HTTP_200_OK)
async def sign_video(
sign_id: int,
video_id: int,
session: AsyncSession = Depends(get_session),
Authorize: AuthJWT = Depends(),
):
Authorize.jwt_required()
current_user_id: int = Authorize.get_jwt_subject()
user = await User.get_by_id(current_user_id, session)
if not user:
raise LoginException("User not found")
sign = await Sign.get_by_id(sign_id, session)
if not sign:
raise LoginException("Sign not found")
sign_video = await SignVideo.get_by_id(video_id, session)
if not sign_video:
raise BaseException("Sign video not found")
await sign_video.delete(session)
# delete the video
os.remove(f"{settings.DATA_PATH}/{sign_video.path}")

33
backend/src/settings.py Normal file
View File

@ -0,0 +1,33 @@
import os
from dotenv import load_dotenv
load_dotenv()
"""Testing"""
TEST_MODE: bool = os.getenv("TEST_MODE", False)
"""Database"""
DB_NAME: str = os.getenv("DB_NAME", "db")
DB_USER: str = os.getenv("DB_USER", "postgres")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "postgres")
DB_HOST: str = os.getenv("DB_HOST", "localhost")
DB_PORT: str = os.getenv("DB_PORT", "5432")
DB_USE_SQLITE: bool = os.getenv("DB_USE_SQLITE", False)
DB_SQLITE_PATH: str = os.getenv("DB_SQLITE_PATH", "./sqlite.db")
"""Storage"""
DATA_PATH: str = os.getenv("DATA_PATH", "data")
"""Authentication"""
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY")
ACCESS_EXPIRES: int = int(os.getenv("ACCESS_EXPIRES", 360000))
REFRESH_EXPIRES: int = int(os.getenv("REFRESH_EXPIRES", 864000))
"""Standard User"""
DEFAULT_USER_ENABLED: bool = os.getenv("DEFAULT_USER_ENABLED", False)
DEFAULT_USER_EMAIL: str = os.getenv("DEFAULT_USER_EMAIL", "test@test.com")
DEFAULT_USER_PASSWORD: str = os.getenv("DEFAULT_USER_PASSWORD", "test")

View File

@ -0,0 +1,30 @@
""" This module includes the functions used to hash and verify passwords
"""
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""verify_password
:param plain_password: password entered by user
:type plain_password: str
:param hashed_password: hashed password saved in database
:type hashed_password: str
:return: hashed_password and plain_password matched
:rtype: bool
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""get_password_hash get the hash of a password
:param password: the plain text password
:type password: str
:return: the hashed password from the plain text password
:rtype: str
"""
return pwd_context.hash(password)

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