From ec7bd6dde57a2277fc71527c3be40bb9e45ca98d Mon Sep 17 00:00:00 2001 From: Victor Mylle Date: Sat, 11 Mar 2023 12:32:41 +0000 Subject: [PATCH] Updating to support categories --- backend/Dockerfile | 4 + backend/alembic.ini | 113 ++++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 81 +++++++++++++ backend/alembic/script.py.mako | 25 ++++ .../2d2d4523082b_add_category_table.py | 46 +++++++ backend/requirements.txt | 3 +- backend/src/app.py | 4 +- backend/src/models/SQLModelExtended.py | 5 +- backend/src/models/__init__.py | 5 + backend/src/models/category.py | 54 +++++++++ backend/src/models/sign.py | 28 ++--- backend/src/routers/__init__.py | 1 + backend/src/routers/category.py | 106 ++++++++++++++++ backend/src/routers/signs.py | 13 +- frontend/package.json | 4 + frontend/src/App.tsx | 6 +- .../src/components/CategorieComponent.tsx | 98 +++++++++++++++ frontend/src/components/CategoryPage.tsx | 76 ++++++++++++ frontend/src/components/SignComponent.tsx | 2 +- frontend/src/components/SignDetailPage.tsx | 2 +- frontend/src/components/SignsPage.tsx | 26 +++- frontend/src/services/category.ts | 85 +++++++++++++ frontend/src/services/signs.ts | 8 +- frontend/src/types/category.ts | 7 ++ frontend/src/types/sign.ts | 2 + frontend/yarn.lock | 44 ++++++- 27 files changed, 807 insertions(+), 42 deletions(-) create mode 100755 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100755 backend/alembic/versions/2d2d4523082b_add_category_table.py create mode 100755 backend/src/models/__init__.py create mode 100755 backend/src/models/category.py create mode 100755 backend/src/routers/category.py create mode 100755 frontend/src/components/CategorieComponent.tsx create mode 100755 frontend/src/components/CategoryPage.tsx create mode 100755 frontend/src/services/category.ts create mode 100755 frontend/src/types/category.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 86acf2a..d2ba6b4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,5 +8,9 @@ RUN pip install -r requirements.txt RUN apt update && apt install -y ffmpeg COPY . . + +# RUN the alembic migrations +RUN alembic upgrade head + CMD ["sh", "-c", "celery -A src.routers.signvideo worker --loglevel=INFO & python main.py"] # CMD ["python", "main.py"] \ No newline at end of file diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..a0ef8b6 --- /dev/null +++ b/backend/alembic.ini @@ -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 + diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..a3be7b2 --- /dev/null +++ b/backend/alembic/env.py @@ -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()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..7490d8f --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/2d2d4523082b_add_category_table.py b/backend/alembic/versions/2d2d4523082b_add_category_table.py new file mode 100755 index 0000000..c19094a --- /dev/null +++ b/backend/alembic/versions/2d2d4523082b_add_category_table.py @@ -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 ### diff --git a/backend/requirements.txt b/backend/requirements.txt index a28552b..991ef58 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,5 @@ Pillow joblib celery numpy -redis \ No newline at end of file +redis +alembic \ No newline at end of file diff --git a/backend/src/app.py b/backend/src/app.py index 1970940..07f1e17 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -51,11 +51,13 @@ app.add_middleware( ) # 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(signs_router) app.include_router(signvideo_router) +app.include_router(category_router) # Add the exception handlers app.add_exception_handler(BaseException, base_exception_handler) diff --git a/backend/src/models/SQLModelExtended.py b/backend/src/models/SQLModelExtended.py index f6322d0..c5acb42 100644 --- a/backend/src/models/SQLModelExtended.py +++ b/backend/src/models/SQLModelExtended.py @@ -3,6 +3,7 @@ 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 @@ -34,8 +35,8 @@ class SQLModelExtended(SQLModel): return res @classmethod - async def get_all_where(self, *args, session: AsyncSession): - res = await read_all_where(self, *args, session=session) + async def get_all_where(self, *args, select_in_load: List = [], session: AsyncSession): + res = await read_all_where(self, *args, select_in_load=select_in_load, session=session) return res async def delete(self, session: AsyncSession) -> None: diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100755 index 0000000..40aaa60 --- /dev/null +++ b/backend/src/models/__init__.py @@ -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')] \ No newline at end of file diff --git a/backend/src/models/category.py b/backend/src/models/category.py new file mode 100755 index 0000000..30cff34 --- /dev/null +++ b/backend/src/models/category.py @@ -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 \ No newline at end of file diff --git a/backend/src/models/sign.py b/backend/src/models/sign.py index 172c250..7ad8989 100644 --- a/backend/src/models/sign.py +++ b/backend/src/models/sign.py @@ -3,7 +3,6 @@ from typing import List import numpy as np import requests from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel from src.exceptions.base_exception import BaseException @@ -25,8 +24,12 @@ class Sign(SQLModelExtended, table=True): 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.category_id = category_id # get name and sign id from url try: @@ -53,23 +56,6 @@ class Sign(SQLModelExtended, table=True): if self.video_url is None: 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 sign_id: str video_url: str + category_id: int sign_videos: List[SignVideo] = [] @@ -87,4 +74,5 @@ class SignOutSimple(BaseModel): url: str name: str sign_id: str - video_url: str \ No newline at end of file + video_url: str + category_id: int \ No newline at end of file diff --git a/backend/src/routers/__init__.py b/backend/src/routers/__init__.py index fff95b5..3ca3143 100644 --- a/backend/src/routers/__init__.py +++ b/backend/src/routers/__init__.py @@ -1,3 +1,4 @@ from .auth import router as auth_router +from .category import router as category_router from .signs import router as signs_router from .signvideo import router as signvideo_router diff --git a/backend/src/routers/category.py b/backend/src/routers/category.py new file mode 100755 index 0000000..1314809 --- /dev/null +++ b/backend/src/routers/category.py @@ -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"} diff --git a/backend/src/routers/signs.py b/backend/src/routers/signs.py index 50daaf5..f963f17 100644 --- a/backend/src/routers/signs.py +++ b/backend/src/routers/signs.py @@ -12,24 +12,25 @@ 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.auth import User +from src.models.category import Category from src.models.sign import Sign, SignOut, SignOutSimple from src.models.signvideo import SignVideo -from src.models.token import TokenExtended router = APIRouter(prefix="/signs") -class SignUrl(BaseModel): +class SignIn(BaseModel): + category: int url: str @router.get("/random", status_code=status.HTTP_200_OK, response_model=SignOutSimple) async def get_random_sign(session: AsyncSession = Depends(get_session)): # 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 @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() user = Authorize.get_jwt_subject() @@ -38,7 +39,7 @@ async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncS if not user: raise LoginException("User not found") - sign = Sign(url.url) + sign = Sign(url=sign.url, category_id=sign.category) # check if the sign already exists signs = await Sign.get_all_where(Sign.url == sign.url, session=session) diff --git a/frontend/package.json b/frontend/package.json index 342d2bc..7845679 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,8 @@ "dependencies": { "@ffmpeg/core": "^0.11.0", "@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/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", @@ -13,6 +15,7 @@ "@types/node": "^16.7.13", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", + "@types/react-edit-text": "^5.0.1", "@types/react-modal": "^3.13.1", "@types/react-transition-group": "^4.4.5", "axios": "^1.2.2", @@ -22,6 +25,7 @@ "fluent-ffmpeg": "^2.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-edit-text": "^5.0.2", "react-media-recorder": "^1.6.6", "react-modal": "^3.16.1", "react-native-trimmer": "^1.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a43f7a..7d2cd20 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,15 +5,17 @@ import ProtectedRoute from './components/ProtectedRoute'; import RandomSignUpload from './components/RandomVideoUpload'; import SignDetailpage from './components/SignDetailPage'; import SignsPage from './components/SignsPage'; +import CategoriesPage from './components/CategoryPage'; function App() { return ( } /> - } /> + } /> + } /> } /> - } /> + } /> ); diff --git a/frontend/src/components/CategorieComponent.tsx b/frontend/src/components/CategorieComponent.tsx new file mode 100755 index 0000000..5675484 --- /dev/null +++ b/frontend/src/components/CategorieComponent.tsx @@ -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) => { + const navigate = useNavigate(); + const [category, setCategory] = useState(props.category); + const [name, setName] = useState(category.name); + + const onClick = (e: React.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) => { + e.preventDefault(); + e.stopPropagation(); + props.deleteCategory(); + }; + + + const handleChange = (e: React.ChangeEvent, setFn: { (value: React.SetStateAction): void; (arg0: any): void; }) => { + setFn(e.target.value); + }; + + return ( +
+ +
e.stopPropagation()}> + handleChange(e, setName)} onSave={handleSave} /> +
+
+

Publically:

+ +
+ +
+ ); +}; + +export default SignComponent; diff --git a/frontend/src/components/CategoryPage.tsx b/frontend/src/components/CategoryPage.tsx new file mode 100755 index 0000000..3fece46 --- /dev/null +++ b/frontend/src/components/CategoryPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [newCategory, setNewCategory] = useState(''); + const [newCategoryError, setNewCategoryError] = useState(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 ( +
+
+
+ setNewCategory(event.target.value)} + placeholder="Enter Category Name" + /> +
+ +
+ {newCategoryError &&

{newCategoryError}

} + +

Categories

+ {!loading ? +
+ { + categories.map((category) => handleDeleteCategory(category)} />) + } +
+ + : +

Loading...

+ } +
+ ); +}; + +export default CategoriesPage; diff --git a/frontend/src/components/SignComponent.tsx b/frontend/src/components/SignComponent.tsx index 34942f0..be2bde7 100644 --- a/frontend/src/components/SignComponent.tsx +++ b/frontend/src/components/SignComponent.tsx @@ -14,7 +14,7 @@ const SignComponent: React.FC = ({ sign, deleteSign }) => { const onClick = (e: React.MouseEvent) => { e.preventDefault(); - navigate(`/admin/signs/${sign.id}`); + navigate(`/admin/categories/${sign.category_id}/signs/${sign.id}`); }; const handleDeleteClick = (e: React.MouseEvent) => { diff --git a/frontend/src/components/SignDetailPage.tsx b/frontend/src/components/SignDetailPage.tsx index eb6d515..b06e1f1 100644 --- a/frontend/src/components/SignDetailPage.tsx +++ b/frontend/src/components/SignDetailPage.tsx @@ -148,7 +148,7 @@ const SignDetailpage: React.FC = (props) => { }
+
+ +
@@ -70,8 +86,12 @@ const SignsPage: React.FC = () => { Download Data + {newSignError &&

{newSignError}

} +

Signs

+ + {!loading ?
{ diff --git a/frontend/src/services/category.ts b/frontend/src/services/category.ts new file mode 100755 index 0000000..357b7c9 --- /dev/null +++ b/frontend/src/services/category.ts @@ -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 }; \ No newline at end of file diff --git a/frontend/src/services/signs.ts b/frontend/src/services/signs.ts index ca4ce1c..8e6ad9a 100644 --- a/frontend/src/services/signs.ts +++ b/frontend/src/services/signs.ts @@ -1,8 +1,8 @@ -const getSigns = async () => { +const getSigns = 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}/signs/`, { + const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/${category}/signs/`, { headers: { Authorization: `Bearer ${token}` } @@ -12,7 +12,7 @@ const getSigns = async () => { return response.json(); }; -const addSign = async (url: string) => { +const addSign = async (url: string, category: string) => { // get access token from local storage const token = localStorage.getItem('accessToken'); // make request to add sign @@ -22,7 +22,7 @@ const addSign = async (url: string) => { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ "url": url }) + body: JSON.stringify({ "url": url, "category": category }) }); if (!response.ok) { diff --git a/frontend/src/types/category.ts b/frontend/src/types/category.ts new file mode 100755 index 0000000..029718e --- /dev/null +++ b/frontend/src/types/category.ts @@ -0,0 +1,7 @@ +import { Sign } from "./sign"; + +export interface Category { + id: number; + name: string; + enabled: boolean; +} \ No newline at end of file diff --git a/frontend/src/types/sign.ts b/frontend/src/types/sign.ts index 7894b3a..70af921 100644 --- a/frontend/src/types/sign.ts +++ b/frontend/src/types/sign.ts @@ -9,6 +9,7 @@ export interface Sign { name: string; sign_id: string; video_url: string; + category_id: number; sign_videos: [SignVideo]; } @@ -16,6 +17,7 @@ export interface SimpleSign { id: number; url: string; name: string; + category_id: number; sign_id: string; video_url: string; } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 56e07d5..7876c25 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1036,6 +1036,13 @@ dependencies: 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": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" @@ -1238,6 +1245,11 @@ regenerator-runtime "^0.13.7" 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": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -1671,6 +1683,21 @@ dependencies: "@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": 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" @@ -2088,6 +2115,13 @@ dependencies: "@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": version "3.13.1" 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" integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== -classnames@^2.2.1: +classnames@^2.2.1, classnames@^2.2.6: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== @@ -8035,6 +8069,14 @@ react-dom@^18.2.0: loose-envify "^1.1.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: version "6.0.11" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"