Updating to support categories

This commit is contained in:
2023-03-11 12:32:41 +00:00
parent bdab151dae
commit ec7bd6dde5
27 changed files with 807 additions and 42 deletions

View File

@@ -8,5 +8,9 @@ RUN pip install -r requirements.txt
RUN apt update && apt install -y ffmpeg RUN apt update && apt install -y ffmpeg
COPY . . COPY . .
# RUN the alembic migrations
RUN alembic upgrade head
CMD ["sh", "-c", "celery -A src.routers.signvideo worker --loglevel=INFO & python main.py"] CMD ["sh", "-c", "celery -A src.routers.signvideo worker --loglevel=INFO & python main.py"]
# CMD ["python", "main.py"] # CMD ["python", "main.py"]

113
backend/alembic.ini Executable file
View File

@@ -0,0 +1,113 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
transactional_ddl = True
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

81
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,81 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.ext.asyncio.session import AsyncEngine
from alembic import context
from src.database.engine import engine
from src.models import * # necessarily to import something from file where your models are stored
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# comment line above and instead of that write
target_metadata = SQLModel.metadata
async def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
connectable = engine
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
if context.is_offline_mode():
asyncio.run(run_migrations_offline())
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
import sqlmodel
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,46 @@
"""add category table
Revision ID: 2d2d4523082b
Revises:
Create Date: 2023-03-09 20:29:44.081609
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
from src.models.category import Category
# revision identifiers, used by Alembic.
revision = '2d2d4523082b'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# create category table
Category.__table__.create(bind=op.get_bind())
op.bulk_insert(
Category.__table__,
[
{"id": 1, "name": "Alfabet"},
]
)
with op.batch_alter_table('sign', schema=None) as batch_op:
# create column category_id
batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True, default=1))
batch_op.create_foreign_key("sign_category", 'category', ['category_id'], ['id'])
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('sign', schema=None) as batch_op:
batch_op.drop_constraint("sign_category", type_='foreignkey')
batch_op.drop_column('category_id')
# drop category table
Category.__table__.drop(bind=op.get_bind())
# ### end Alembic commands ###

View File

@@ -11,4 +11,5 @@ Pillow
joblib joblib
celery celery
numpy numpy
redis redis
alembic

View File

@@ -51,11 +51,13 @@ app.add_middleware(
) )
# include the routers # include the routers
from .routers import auth_router, signs_router, signvideo_router from .routers import (auth_router, category_router, signs_router,
signvideo_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(signs_router) app.include_router(signs_router)
app.include_router(signvideo_router) app.include_router(signvideo_router)
app.include_router(category_router)
# Add the exception handlers # Add the exception handlers
app.add_exception_handler(BaseException, base_exception_handler) app.add_exception_handler(BaseException, base_exception_handler)

View File

@@ -3,6 +3,7 @@ from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel from sqlmodel import SQLModel
from src.database.crud import delete, read_all_where, read_where, update from src.database.crud import delete, read_all_where, read_where, update
@@ -34,8 +35,8 @@ class SQLModelExtended(SQLModel):
return res return res
@classmethod @classmethod
async def get_all_where(self, *args, session: AsyncSession): async def get_all_where(self, *args, select_in_load: List = [], session: AsyncSession):
res = await read_all_where(self, *args, session=session) res = await read_all_where(self, *args, select_in_load=select_in_load, session=session)
return res return res
async def delete(self, session: AsyncSession) -> None: async def delete(self, session: AsyncSession) -> None:

5
backend/src/models/__init__.py Executable file
View File

@@ -0,0 +1,5 @@
import glob
from os.path import basename, dirname, isfile, join
modules = glob.glob(join(dirname(__file__), "*.py"))
__all__ = [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]

54
backend/src/models/category.py Executable file
View File

@@ -0,0 +1,54 @@
from typing import List
import numpy as np
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship
from src.models.sign import Sign, SignOut
from src.models.SQLModelExtended import SQLModelExtended
class Category(SQLModelExtended, table=True):
id: int = Field(primary_key=True)
name: str = Field(unique=True)
enabled: bool = Field(default=True)
# list of signs that belong to this category
signs: List[Sign] = Relationship(
back_populates="category",
sa_relationship_kwargs={"lazy": "selectin"},
)
@classmethod
async def get_random_sign(self, session: AsyncSession):
# get all categories
categories = await self.get_all_where(Category.enabled==True, select_in_load=[Category.signs],session=session)
# get all signs in one list with list comprehension
signs = [s for c in categories for s in c.signs]
sign_videos = [len(s.sign_videos) for s in signs]
random_prob = [1 / (x + 1) for x in sign_videos]
random_prob = random_prob / np.sum(random_prob)
# get random sign
sign = np.random.choice(signs, p=random_prob)
return sign
class CategoryPost(BaseModel):
name: str
class CategoryPut(BaseModel):
id: int
name: str
enabled: bool = True
class CategoryOut(BaseModel):
id: int
name: str
enabled: bool

View File

@@ -3,7 +3,6 @@ from typing import List
import numpy as np import numpy as np
import requests import requests
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
from src.exceptions.base_exception import BaseException from src.exceptions.base_exception import BaseException
@@ -25,8 +24,12 @@ class Sign(SQLModelExtended, table=True):
sa_relationship_kwargs={"lazy": "selectin"}, sa_relationship_kwargs={"lazy": "selectin"},
) )
def __init__(self, url): category_id: int = Field(foreign_key="category.id")
category: "Category" = Relationship(back_populates="signs")
def __init__(self, url, category_id):
self.url = url self.url = url
self.category_id = category_id
# get name and sign id from url # get name and sign id from url
try: try:
@@ -53,23 +56,6 @@ class Sign(SQLModelExtended, table=True):
if self.video_url is None: if self.video_url is None:
raise BaseException(404, "Video url not found") raise BaseException(404, "Video url not found")
@classmethod
async def get_random(self, session: AsyncSession):
signs = await self.get_all(select_in_load=[Sign.sign_videos],session=session)
sign_videos = [len(s.sign_videos) for s in signs]
# get probability based on number of videos, lower must be more likely
# the sum must be 1
random_prob = [1 / (x + 1) for x in sign_videos]
random_prob = random_prob / np.sum(random_prob)
# get random sign
sign = np.random.choice(signs, p=random_prob)
return sign
@@ -79,6 +65,7 @@ class SignOut(BaseModel):
name: str name: str
sign_id: str sign_id: str
video_url: str video_url: str
category_id: int
sign_videos: List[SignVideo] = [] sign_videos: List[SignVideo] = []
@@ -87,4 +74,5 @@ class SignOutSimple(BaseModel):
url: str url: str
name: str name: str
sign_id: str sign_id: str
video_url: str video_url: str
category_id: int

View File

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

106
backend/src/routers/category.py Executable file
View File

@@ -0,0 +1,106 @@
from typing import List
from fastapi import APIRouter, BackgroundTasks, Depends, status
from fastapi_jwt_auth import AuthJWT
from sqlalchemy.ext.asyncio import AsyncSession
from src.database.database import get_session
from src.exceptions.base_exception import BaseException
from src.exceptions.login_exception import LoginException
from src.models.auth import User
from src.models.category import (Category, CategoryOut, CategoryPost,
CategoryPut)
from src.models.sign import Sign, SignOut
router = APIRouter(prefix="/categories")
@router.get("/", response_model=List[Category])
async def get_categories(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")
categories = await Category.get_all(session=session, select_in_load=[Category.signs])
return categories
@router.get("/{category_id}/signs", response_model=List[SignOut])
async def get_signs_by_category(category_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")
signs = await Sign.get_all_where(Sign.category_id==category_id, select_in_load=[Sign.sign_videos], session=session)
return signs
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=CategoryOut)
async def create_category(category: CategoryPost, 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")
# Category name cannot be empty or only exist of spaces
if not category.name or category.name.isspace():
raise BaseException(message="Category name cannot be empty", status_code=status.HTTP_400_BAD_REQUEST)
try:
c = Category(name=category.name)
await c.save(session=session)
except Exception as e:
raise BaseException(message="Category already exists", status_code=status.HTTP_400_BAD_REQUEST)
return c
@router.put("/", response_model=CategoryOut)
async def update_category(category: CategoryPut, 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")
# Category name cannot be empty or only exist of spaces
if not category.name or category.name.isspace():
raise BaseException(message="Category name cannot be empty", status_code=status.HTTP_400_BAD_REQUEST)
c = await Category.get_by_id(id=category.id, session=session)
if not c:
raise BaseException(message="Category not found", status_code=status.HTTP_404_NOT_FOUND)
c.name = category.name
c.enabled = category.enabled
await c.save(session=session)
return c
@router.delete("/{category_id}")
async def delete_category(category_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")
c = await Category.get_by_id(id=category_id, session=session)
print(c)
if not c:
raise BaseException(message="Category not found", status_code=status.HTTP_404_NOT_FOUND)
if (len(c.signs) > 0):
raise BaseException(message="Category cannot be deleted because it contains signs", status_code=status.HTTP_400_BAD_REQUEST)
await c.delete(session=session)
return {"message": "Category deleted successfully"}

View File

@@ -12,24 +12,25 @@ from sqlalchemy.ext.asyncio import AsyncSession
import src.settings as settings import src.settings as settings
from src.database.database import get_session from src.database.database import get_session
from src.exceptions.login_exception import LoginException from src.exceptions.login_exception import LoginException
from src.models.auth import Login, User from src.models.auth import User
from src.models.category import Category
from src.models.sign import Sign, SignOut, SignOutSimple from src.models.sign import Sign, SignOut, SignOutSimple
from src.models.signvideo import SignVideo from src.models.signvideo import SignVideo
from src.models.token import TokenExtended
router = APIRouter(prefix="/signs") router = APIRouter(prefix="/signs")
class SignUrl(BaseModel): class SignIn(BaseModel):
category: int
url: str url: str
@router.get("/random", status_code=status.HTTP_200_OK, response_model=SignOutSimple) @router.get("/random", status_code=status.HTTP_200_OK, response_model=SignOutSimple)
async def get_random_sign(session: AsyncSession = Depends(get_session)): async def get_random_sign(session: AsyncSession = Depends(get_session)):
# get a random sign where there is not much data from yet # get a random sign where there is not much data from yet
sign = await Sign.get_random(session=session) sign = await Category.get_random_sign(session=session)
return sign return sign
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignOut) @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)): async def add_sign(sign: SignIn, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
Authorize.jwt_required() Authorize.jwt_required()
user = Authorize.get_jwt_subject() user = Authorize.get_jwt_subject()
@@ -38,7 +39,7 @@ async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncS
if not user: if not user:
raise LoginException("User not found") raise LoginException("User not found")
sign = Sign(url.url) sign = Sign(url=sign.url, category_id=sign.category)
# check if the sign already exists # check if the sign already exists
signs = await Sign.get_all_where(Sign.url == sign.url, session=session) signs = await Sign.get_all_where(Sign.url == sign.url, session=session)

View File

@@ -5,6 +5,8 @@
"dependencies": { "dependencies": {
"@ffmpeg/core": "^0.11.0", "@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6", "@ffmpeg/ffmpeg": "^0.11.6",
"@heroicons/react": "^2.0.16",
"@styled-icons/heroicons-solid": "^10.47.0",
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0", "@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1", "@testing-library/user-event": "^13.2.1",
@@ -13,6 +15,7 @@
"@types/node": "^16.7.13", "@types/node": "^16.7.13",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/react-edit-text": "^5.0.1",
"@types/react-modal": "^3.13.1", "@types/react-modal": "^3.13.1",
"@types/react-transition-group": "^4.4.5", "@types/react-transition-group": "^4.4.5",
"axios": "^1.2.2", "axios": "^1.2.2",
@@ -22,6 +25,7 @@
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-edit-text": "^5.0.2",
"react-media-recorder": "^1.6.6", "react-media-recorder": "^1.6.6",
"react-modal": "^3.16.1", "react-modal": "^3.16.1",
"react-native-trimmer": "^1.1.1", "react-native-trimmer": "^1.1.1",

View File

@@ -5,15 +5,17 @@ import ProtectedRoute from './components/ProtectedRoute';
import RandomSignUpload from './components/RandomVideoUpload'; import RandomSignUpload from './components/RandomVideoUpload';
import SignDetailpage from './components/SignDetailPage'; import SignDetailpage from './components/SignDetailPage';
import SignsPage from './components/SignsPage'; import SignsPage from './components/SignsPage';
import CategoriesPage from './components/CategoryPage';
function App() { function App() {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<RandomSignUpload />} /> <Route path="/" element={<RandomSignUpload />} />
<Route path="/admin/" element={<ProtectedRoute><SignsPage /></ProtectedRoute>} /> <Route path="/admin/" element={<ProtectedRoute><CategoriesPage /></ProtectedRoute>} />
<Route path="/admin/categories/:id" element={<ProtectedRoute><SignsPage /></ProtectedRoute>} />
<Route path="/admin/login" element={<Login />} /> <Route path="/admin/login" element={<Login />} />
<Route path="/admin/signs/:id" element={<ProtectedRoute><SignDetailpage /></ProtectedRoute>} /> <Route path="/admin/categories/:category_id/signs/:id" element={<ProtectedRoute><SignDetailpage /></ProtectedRoute>} />
</Routes> </Routes>
</Router> </Router>
); );

View File

@@ -0,0 +1,98 @@
import React, { MouseEventHandler, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Category } from '../types/category';
import { Sign } from '../types/sign';
import { EditText, EditTextarea, EditTextProps } from 'react-edit-text';
import 'react-edit-text/dist/index.css';
import { deleteCategory, updateCategory } from '../services/category';
interface Props {
category: Category;
deleteCategory: () => void;
}
const SignComponent: React.FC<Props> = (props) => {
const navigate = useNavigate();
const [category, setCategory] = useState<Category>(props.category);
const [name, setName] = useState<string>(category.name);
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
navigate(`/admin/categories/${category.id}/`);
};
const handleSave = (p: any) => {
updateCategory({ ...category, "name": p.value })
.then((response) => {
setCategory({
...category,
name: p.value
});
setName(p.value);
})
.catch((error) => {
setName(category.name);
alert(error);
}
);
};
const handleEnableSwitch = (e: any) => {
e.stopPropagation();
e.preventDefault();
updateCategory({ ...category, "enabled": !category.enabled })
.then((response) => {
setCategory({
...category,
enabled: !category.enabled
});
}
)
.catch((error) => {
alert(error);
}
);
};
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
props.deleteCategory();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, setFn: { (value: React.SetStateAction<string>): void; (arg0: any): void; }) => {
setFn(e.target.value);
};
return (
<div className="relative bg-white p-6 rounded-lg shadow-md" onClick={onClick}>
<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>
<div onClick={(e) => e.stopPropagation()}>
<EditText className="text-2xl font-bold" value={name} onChange={(e) => handleChange(e, setName)} onSave={handleSave} />
</div>
<div className='mt-4 flex'>
<h2>Publically:</h2>
<label className="ml-4 inline-flex relative items-center mr-5 cursor-pointer" onClick={handleEnableSwitch}>
<input
type="checkbox"
className="sr-only peer"
checked={category.enabled}
readOnly
/>
<div
className="w-11 h-6 bg-gray-200 rounded-full peer peer-focus:ring-green-300 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-600"
></div>
</label>
</div>
</div>
);
};
export default SignComponent;

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useState } from 'react';
import { getCategories, addCategory, deleteCategory } from '../services/category';
import { Category } from '../types/category';
import { Sign } from '../types/sign';
import SignComponent from './SignComponent';
import CategorieComponent from './CategorieComponent';
const CategoriesPage: React.FC = () => {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [newCategory, setNewCategory] = useState<string>('');
const [newCategoryError, setNewCategoryError] = useState<string | null>(null);
useEffect(() => {
// get the signs from the api
getCategories().then((categories) => {
setCategories(categories);
setLoading(false);
});
}, []);
const handle_add_category = () => {
addCategory(newCategory).then((category) => {
setCategories([...categories, category]);
setNewCategory('');
}).catch((error) => {
console.log(error);
setNewCategoryError(error.message);
});
};
const handleDeleteCategory = (category: Category) => {
deleteCategory(category.id).then(() => {
setCategories(categories.filter((c) => c.id !== category.id));
}
).catch((error) => {
console.log(error);
}
);
}
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={newCategory}
onChange={(event) => setNewCategory(event.target.value)}
placeholder="Enter Category Name"
/>
</div>
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handle_add_category}>
Add Category
</button>
</div>
{newCategoryError && <p className="text-red-500">{newCategoryError}</p>}
<h1 className="text-3xl font-bold mt-5">Categories</h1>
{!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">
{
categories.map((category) => <CategorieComponent key={category.id} category={category} deleteCategory={() => handleDeleteCategory(category)} />)
}
</div>
:
<p>Loading...</p>
}
</div>
);
};
export default CategoriesPage;

View File

@@ -14,7 +14,7 @@ const SignComponent: React.FC<Props> = ({ sign, deleteSign }) => {
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault(); e.preventDefault();
navigate(`/admin/signs/${sign.id}`); navigate(`/admin/categories/${sign.category_id}/signs/${sign.id}`);
}; };
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {

View File

@@ -148,7 +148,7 @@ const SignDetailpage: React.FC<Props> = (props) => {
} }
<div> <div>
<button onClick={() => { <button onClick={() => {
window.location.href = '/admin/'; window.location.href = `/admin/categories/${sign!.category_id}`;
}} className="bg-white p-2 rounded-full text-red-600 hover:bg-red-600 hover:text-white absolute top-1 left-1"> }} 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />

View File

@@ -1,17 +1,25 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { getSigns, addSign, downloadSigns, deleteSign } from '../services/signs'; import { getSigns, addSign, downloadSigns, deleteSign } from '../services/signs';
import { Category } from '../types/category';
import { Sign } from '../types/sign'; import { Sign } from '../types/sign';
import SignComponent from './SignComponent'; import SignComponent from './SignComponent';
import { useParams } from 'react-router-dom';
type Params = {
id: string;
}
const SignsPage: React.FC = () => { const SignsPage: React.FC = () => {
const [signs, setSigns] = useState<Sign[]>([]); const [signs, setSigns] = useState<Sign[]>([]);
const [newSign, setNewSign] = useState(''); const [newSign, setNewSign] = useState('');
const [newSignError, setNewSignError] = useState<string | null>(null); const [newSignError, setNewSignError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { id } = useParams<Params>();
const handleAddSign = async () => { const handleAddSign = async () => {
addSign(newSign).then((sign) => { addSign(newSign, id!).then((sign) => {
setSigns([...signs, sign]); setSigns([...signs, sign]);
setNewSign(''); setNewSign('');
setNewSignError(null); setNewSignError(null);
@@ -44,8 +52,7 @@ const SignsPage: React.FC = () => {
useEffect(() => { useEffect(() => {
// get the signs from the api // get the signs from the api
getSigns().then((signs) => { getSigns(id!).then((signs) => {
console.log(signs)
setSigns(signs); setSigns(signs);
setLoading(false); setLoading(false);
}); });
@@ -63,6 +70,15 @@ const SignsPage: React.FC = () => {
placeholder="Enter sign url" placeholder="Enter sign url"
/> />
</div> </div>
<div>
<button onClick={() => {
window.location.href = `/admin/`;
}} 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>
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handleAddSign}> <button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handleAddSign}>
Add Sign Add Sign
</button> </button>
@@ -70,8 +86,12 @@ const SignsPage: React.FC = () => {
Download Data Download Data
</button> </button>
</div> </div>
{newSignError && <p className="text-red-500">{newSignError}</p>} {newSignError && <p className="text-red-500">{newSignError}</p>}
<h1 className="text-3xl font-bold mt-5">Signs</h1>
{!loading ? {!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"> <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">
{ {

View File

@@ -0,0 +1,85 @@
import { Category } from "../types/category";
const getCategories = 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}/categories/`, {
headers: {
Authorization: `Bearer ${token}`
}
});
// return the response
return response.json();
};
const addCategory = async (category: string) => {
// 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}/categories/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ "name": category })
});
// check if error
if (response.status !== 201) {
throw new Error('Error adding category');
}
// return the response
return response.json();
};
const updateCategory = async (category: Category) => {
// 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}/categories/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(category)
});
// check if error
if (response.status !== 200) {
throw new Error('Error updating category');
}
// return the response
return response.json();
};
const deleteCategory = async (id: number) => {
// 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}/categories/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// check if error
if (response.status !== 200) {
throw new Error('Category must be empty before deleting');
}
// return the response
return response.json();
};
export { getCategories, addCategory, updateCategory, deleteCategory };

View File

@@ -1,8 +1,8 @@
const getSigns = async () => { const getSigns = async (category: string) => {
// get access token from local storage // get access token from local storage
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
// make request to get signs // make request to get signs
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/`, { const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/${category}/signs/`, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
@@ -12,7 +12,7 @@ const getSigns = async () => {
return response.json(); return response.json();
}; };
const addSign = async (url: string) => { const addSign = async (url: string, category: string) => {
// get access token from local storage // get access token from local storage
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
// make request to add sign // make request to add sign
@@ -22,7 +22,7 @@ const addSign = async (url: string) => {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ "url": url }) body: JSON.stringify({ "url": url, "category": category })
}); });
if (!response.ok) { if (!response.ok) {

7
frontend/src/types/category.ts Executable file
View File

@@ -0,0 +1,7 @@
import { Sign } from "./sign";
export interface Category {
id: number;
name: string;
enabled: boolean;
}

View File

@@ -9,6 +9,7 @@ export interface Sign {
name: string; name: string;
sign_id: string; sign_id: string;
video_url: string; video_url: string;
category_id: number;
sign_videos: [SignVideo]; sign_videos: [SignVideo];
} }
@@ -16,6 +17,7 @@ export interface SimpleSign {
id: number; id: number;
url: string; url: string;
name: string; name: string;
category_id: number;
sign_id: string; sign_id: string;
video_url: string; video_url: string;
} }

View File

@@ -1036,6 +1036,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.11" regenerator-runtime "^0.13.11"
"@babel/runtime@^7.19.0":
version "7.21.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3": "@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3":
version "7.20.7" version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
@@ -1238,6 +1245,11 @@
regenerator-runtime "^0.13.7" regenerator-runtime "^0.13.7"
resolve-url "^0.2.1" resolve-url "^0.2.1"
"@heroicons/react@^2.0.16":
version "2.0.16"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.0.16.tgz#562883c19ba2690c83380b42a9a5cce39dcbdb4a"
integrity sha512-x89rFxH3SRdYaA+JCXwfe+RkE1SFTo9GcOkZettHer71Y3T7V+ogKmfw5CjTazgS3d0ClJ7p1NA+SP7VQLQcLw==
"@humanwhocodes/config-array@^0.11.8": "@humanwhocodes/config-array@^0.11.8":
version "0.11.8" version "0.11.8"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9"
@@ -1671,6 +1683,21 @@
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@sinonjs/commons" "^1.7.0"
"@styled-icons/heroicons-solid@^10.47.0":
version "10.47.0"
resolved "https://registry.yarnpkg.com/@styled-icons/heroicons-solid/-/heroicons-solid-10.47.0.tgz#4457463fe15c8bf8c357bf22dd16d3e579a5e163"
integrity sha512-j+tJx2NzLG2tc91IXJVwKNjsI/osxmak+wmLfnfBsB+49srpxMYjuLPMtl9ZY/xgbNsWO36O+/N5Zf5bkgiKcQ==
dependencies:
"@babel/runtime" "^7.20.7"
"@styled-icons/styled-icon" "^10.7.0"
"@styled-icons/styled-icon@^10.7.0":
version "10.7.0"
resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.7.0.tgz#d6960e719b8567c8d0d3a87c40fb6f5b4952a228"
integrity sha512-SCrhCfRyoY8DY7gUkpz+B0RqUg/n1Zaqrr2+YKmK/AyeNfCcoHuP4R9N4H0p/NA1l7PTU10ZkAWSLi68phnAjw==
dependencies:
"@babel/runtime" "^7.19.0"
"@surma/rollup-plugin-off-main-thread@^2.2.3": "@surma/rollup-plugin-off-main-thread@^2.2.3":
version "2.2.3" version "2.2.3"
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
@@ -2088,6 +2115,13 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-edit-text@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@types/react-edit-text/-/react-edit-text-5.0.1.tgz#2907c740c013c17707eb76d1b84688417dff4c27"
integrity sha512-Eb2tX+PtSsbSTZPO1mTbTufVVAGddeplMCHsL+RCXQytm3X8bkiKWXMbQ/kaoOuxDHTkarItHRX0dEeo5A2B+w==
dependencies:
"@types/react" "*"
"@types/react-modal@^3.13.1": "@types/react-modal@^3.13.1":
version "3.13.1" version "3.13.1"
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a" resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a"
@@ -3234,7 +3268,7 @@ cjs-module-lexer@^1.0.0:
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"
integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==
classnames@^2.2.1: classnames@^2.2.1, classnames@^2.2.6:
version "2.3.2" version "2.3.2"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
@@ -8035,6 +8069,14 @@ react-dom@^18.2.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
scheduler "^0.23.0" scheduler "^0.23.0"
react-edit-text@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/react-edit-text/-/react-edit-text-5.0.2.tgz#e1418a16f615cc5b833f455ccffd24d18c566ab2"
integrity sha512-V3M4KhgQDbKa1I6F5OLrhqu9w/T4XONFEyC4RijdSxDs+L5srpsCH6V5CSWGNT6pu6f4VG03n736RB3lMCTUkw==
dependencies:
classnames "^2.2.6"
prop-types "^15.8.1"
react-error-overlay@^6.0.11: react-error-overlay@^6.0.11:
version "6.0.11" version "6.0.11"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"