Added data download ability
This commit is contained in:
parent
48f080de7c
commit
c850726f91
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,3 +1,8 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
.devcontainer/
|
||||
.env
|
||||
*.db
|
||||
data/
|
||||
.vscode
|
||||
@ -7,5 +7,4 @@ COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
7
backend/main.py
Normal file
7
backend/main.py
Normal 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)
|
||||
@ -1 +1,10 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlmodel
|
||||
python-dotenv
|
||||
fastapi_jwt_auth
|
||||
aiosqlite
|
||||
passlib
|
||||
requests
|
||||
python-multipart
|
||||
Pillow
|
||||
0
backend/src/__init__.py
Normal file
0
backend/src/__init__.py
Normal file
115
backend/src/app.py
Normal file
115
backend/src/app.py
Normal 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
|
||||
129
backend/src/database/crud.py
Normal file
129
backend/src/database/crud.py
Normal 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()
|
||||
59
backend/src/database/database.py
Normal file
59
backend/src/database/database.py
Normal 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
|
||||
47
backend/src/database/engine.py
Normal file
47
backend/src/database/engine.py
Normal 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,
|
||||
)
|
||||
47
backend/src/exceptions/base_exception.py
Normal file
47
backend/src/exceptions/base_exception.py
Normal 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
|
||||
)
|
||||
31
backend/src/exceptions/exception_handlers.py
Normal file
31
backend/src/exceptions/exception_handlers.py
Normal 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}
|
||||
)
|
||||
11
backend/src/exceptions/login_exception.py
Normal file
11
backend/src/exceptions/login_exception.py
Normal 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)
|
||||
53
backend/src/models/SQLModelExtended.py
Normal file
53
backend/src/models/SQLModelExtended.py
Normal 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)
|
||||
26
backend/src/models/auth.py
Normal file
26
backend/src/models/auth.py
Normal 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)
|
||||
61
backend/src/models/sign.py
Normal file
61
backend/src/models/sign.py
Normal 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] = []
|
||||
25
backend/src/models/signvideo.py
Normal file
25
backend/src/models/signvideo.py
Normal 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
|
||||
10
backend/src/models/token.py
Normal file
10
backend/src/models/token.py
Normal 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
|
||||
3
backend/src/routers/__init__.py
Normal file
3
backend/src/routers/__init__.py
Normal 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
|
||||
60
backend/src/routers/auth.py
Normal file
60
backend/src/routers/auth.py
Normal 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,
|
||||
)
|
||||
123
backend/src/routers/signs.py
Normal file
123
backend/src/routers/signs.py
Normal 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")
|
||||
170
backend/src/routers/signvideo.py
Normal file
170
backend/src/routers/signvideo.py
Normal 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
33
backend/src/settings.py
Normal 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")
|
||||
30
backend/src/utils/cryptography.py
Normal file
30
backend/src/utils/cryptography.py
Normal 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
23
frontend/.gitignore
vendored
Normal 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
45
frontend/.yarnclean
Normal 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
8
frontend/Dockerfile
Normal 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
46
frontend/README.md
Normal 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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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/).
|
||||
7
frontend/codux.config.json
Normal file
7
frontend/codux.config.json
Normal 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
70
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
46
frontend/public/index.html
Normal file
46
frontend/public/index.html
Normal 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
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
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
38
frontend/src/App.css
Normal file
38
frontend/src/App.css
Normal 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);
|
||||
}
|
||||
}
|
||||
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal 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
20
frontend/src/App.tsx
Normal 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;
|
||||
@ -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 />
|
||||
});
|
||||
63
frontend/src/components/LoginPage.tsx
Normal file
63
frontend/src/components/LoginPage.tsx
Normal 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;
|
||||
29
frontend/src/components/ProtectedRoute.tsx
Normal file
29
frontend/src/components/ProtectedRoute.tsx
Normal 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;
|
||||
68
frontend/src/components/SignComponent.tsx
Normal file
68
frontend/src/components/SignComponent.tsx
Normal 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;
|
||||
187
frontend/src/components/SignDetailPage.tsx
Normal file
187
frontend/src/components/SignDetailPage.tsx
Normal 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;
|
||||
44
frontend/src/components/SignVideoGrid.tsx
Normal file
44
frontend/src/components/SignVideoGrid.tsx
Normal 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;
|
||||
97
frontend/src/components/SignVideoPlayer.tsx
Normal file
97
frontend/src/components/SignVideoPlayer.tsx
Normal 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;
|
||||
|
||||
61
frontend/src/components/SignVideoThumbnail.tsx
Normal file
61
frontend/src/components/SignVideoThumbnail.tsx
Normal 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;
|
||||
89
frontend/src/components/SignsPage.tsx
Normal file
89
frontend/src/components/SignsPage.tsx
Normal 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;
|
||||
9
frontend/src/components/button/button.tsx
Normal file
9
frontend/src/components/button/button.tsx
Normal 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>
|
||||
);
|
||||
54
frontend/src/components/loading-button/loading-button.tsx
Normal file
54
frontend/src/components/loading-button/loading-button.tsx
Normal 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
17
frontend/src/index.css
Normal 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
17
frontend/src/index.tsx
Normal 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
1
frontend/src/logo.svg
Normal 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
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal 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;
|
||||
33
frontend/src/services/login.ts
Normal file
33
frontend/src/services/login.ts
Normal 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;
|
||||
};
|
||||
91
frontend/src/services/signs.ts
Normal file
91
frontend/src/services/signs.ts
Normal 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 };
|
||||
94
frontend/src/services/signvideos.ts
Normal file
94
frontend/src/services/signvideos.ts
Normal 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 };
|
||||
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal 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';
|
||||
77
frontend/src/types/react-video-recorder.d.ts
vendored
Normal file
77
frontend/src/types/react-video-recorder.d.ts
vendored
Normal 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;
|
||||
}
|
||||
26
frontend/src/types/react-video-stream.d.ts
vendored
Normal file
26
frontend/src/types/react-video-stream.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
13
frontend/src/types/sign.ts
Normal file
13
frontend/src/types/sign.ts
Normal 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];
|
||||
}
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal 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
26
frontend/tsconfig.json
Normal 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
10178
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user