Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2d65b622 | |||
| 44ebf0a934 | |||
| 78dbd0944a | |||
| c31b205154 | |||
| 7024328629 | |||
| e51140583b | |||
| e9fa4f2ead | |||
| 480d2fe141 | |||
| 72ec735e9d | |||
| 2951a559bc | |||
| 3fb38cac3a | |||
| 175d2f9cd4 | |||
| 0ebfd11851 | |||
| 2a696f96c1 | |||
| 227f1c5782 | |||
| f80431aba0 | |||
| 03da5914fa | |||
| ed0ecf9f51 | |||
| 3033d1f34b | |||
| d4e5d42f81 | |||
| b751983838 | |||
| 31b1c977ed | |||
| aecce43357 | |||
| d2dd2977df | |||
| ffe45a821d | |||
| af608f5b26 |
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime, types
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
@ -7,6 +7,21 @@ import datetime
|
||||
from .database import Base
|
||||
|
||||
|
||||
class ChoiceType(types.TypeDecorator):
|
||||
|
||||
impl = types.String
|
||||
|
||||
def __init__(self, choices, **kw):
|
||||
self.choices = dict(choices)
|
||||
super(ChoiceType, self).__init__(**kw)
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
return [k for k, v in self.choices.items() if v == value][0]
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return self.choices[value]
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@ -47,8 +62,21 @@ class Queue(Base):
|
||||
description = Column(String, index=True)
|
||||
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
start_time = Column(DateTime, nullable=True)
|
||||
status = Column(
|
||||
ChoiceType(
|
||||
{
|
||||
"created": "created",
|
||||
"waiting": "waiting",
|
||||
"active": "active",
|
||||
"finished": "finished",
|
||||
}
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
users = relationship("QueueUser", backref="queue", lazy="dynamic")
|
||||
logs = relationship("QueueLog", backref="queue", lazy="dynamic")
|
||||
groups = relationship("QueueGroup", backref="queue", lazy="dynamic")
|
||||
|
||||
|
||||
class QueueUser(Base):
|
||||
@ -59,6 +87,7 @@ class QueueUser(Base):
|
||||
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"))
|
||||
position = Column(Integer)
|
||||
passed = Column(Boolean, default=False)
|
||||
group_id = Column(UUID(as_uuid=True), ForeignKey("queuegroup.id"), nullable=True)
|
||||
|
||||
|
||||
class QueueLog(Base):
|
||||
@ -66,4 +95,24 @@ class QueueLog(Base):
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
action = Column(String)
|
||||
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"))
|
||||
created = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class QueueGroup(Base):
|
||||
__tablename__ = "queuegroup"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String)
|
||||
priority = Column(Integer)
|
||||
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"))
|
||||
|
||||
users = relationship("QueueUser", backref="group", lazy="dynamic")
|
||||
|
||||
|
||||
class Captcha(Base):
|
||||
__tablename__ = "captcha"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
prompt = Column(String(length=6))
|
||||
used = Column(Boolean, default=False)
|
||||
|
||||
10
backend/app/db/redis.py
Normal file
10
backend/app/db/redis.py
Normal file
@ -0,0 +1,10 @@
|
||||
import redis
|
||||
import os
|
||||
|
||||
REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
|
||||
|
||||
async def create_redis() -> redis.Redis:
|
||||
redis_connection = await redis.asyncio.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
|
||||
return redis_connection
|
||||
@ -1,4 +1,9 @@
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
import redis
|
||||
|
||||
from .db.database import SessionLocal
|
||||
from .db.redis import create_redis
|
||||
|
||||
|
||||
def get_db():
|
||||
@ -7,3 +12,19 @@ def get_db():
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_redis():
|
||||
r = await create_redis()
|
||||
try:
|
||||
yield r
|
||||
finally:
|
||||
r.close()
|
||||
|
||||
|
||||
async def get_pubsub(r: Annotated[redis.Redis, Depends(get_redis)]):
|
||||
ps = r.pubsub()
|
||||
try:
|
||||
yield ps
|
||||
finally:
|
||||
ps.close()
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
from typing import Union
|
||||
from typing import Union, Annotated
|
||||
from fastapi import FastAPI, Depends
|
||||
import redis
|
||||
|
||||
from .db import models
|
||||
from .db.database import SessionLocal, engine
|
||||
from .db.redis import create_redis
|
||||
from .dependencies import get_db
|
||||
|
||||
from .views.auth.api import router as auth_router
|
||||
@ -17,6 +19,12 @@ app.include_router(auth_router)
|
||||
app.include_router(news_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
r = await create_redis()
|
||||
await r.flushall()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return {"message": "OK"}
|
||||
|
||||
@ -2,8 +2,9 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Union
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -46,6 +47,9 @@ async def register(
|
||||
user_data: schemas.UserRegister,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> schemas.User:
|
||||
if services.check_captcha(
|
||||
id=user_data.captcha.id, prompt=user_data.captcha.prompt, db=db
|
||||
):
|
||||
user = services.get_user_by_username(db, user_data.username)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
@ -61,10 +65,42 @@ async def register(
|
||||
)
|
||||
user = services.create_user(db=db, user_data=user_data)
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid captcha"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=schemas.User)
|
||||
@router.get("/me")
|
||||
async def read_users_me(
|
||||
current_user: Annotated[schemas.User, Depends(services.get_current_active_user)],
|
||||
):
|
||||
current_user: Annotated[
|
||||
schemas.UserInDB, Depends(services.get_current_active_user)
|
||||
],
|
||||
) -> schemas.UserInDB:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/anon")
|
||||
async def get_anon_user(
|
||||
anon_user: Annotated[schemas.AnonUser, Depends(services.get_anon_user)]
|
||||
) -> schemas.AnonUser:
|
||||
return anon_user
|
||||
|
||||
|
||||
@router.patch("/anon")
|
||||
async def get_anon_user(
|
||||
anon_user: Annotated[schemas.AnonUser, Depends(services.patch_anon_name)]
|
||||
) -> schemas.AnonUser:
|
||||
return anon_user
|
||||
|
||||
|
||||
@router.get(
|
||||
"/captcha/{captcha_id}",
|
||||
responses={200: {"content": {"image/png": {}}}},
|
||||
response_class=Response,
|
||||
)
|
||||
async def generate_captcha(
|
||||
captcha: Annotated[BytesIO, Depends(services.get_captcha)]
|
||||
) -> Response:
|
||||
captcha.seek(0)
|
||||
captcha_bytes = captcha.read()
|
||||
return Response(content=captcha_bytes, media_type="image/png")
|
||||
|
||||
@ -15,9 +15,22 @@ class UserInDB(User):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Captcha(BaseModel):
|
||||
id: UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CaptchaCheck(BaseModel):
|
||||
id: UUID
|
||||
prompt: str
|
||||
|
||||
|
||||
class UserRegister(User):
|
||||
password: str
|
||||
password2: str
|
||||
captcha: CaptchaCheck
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
@ -27,3 +40,15 @@ class Token(BaseModel):
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Union[str, None] = None
|
||||
|
||||
|
||||
class AnonUser(BaseModel):
|
||||
id: UUID
|
||||
name: Union[str, None] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AnonUserPatch(BaseModel):
|
||||
name: str
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from fastapi import status, HTTPException, Depends
|
||||
from fastapi import status, HTTPException, Depends, Header
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
@ -6,12 +6,17 @@ from typing import Annotated, Union
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from passlib.context import CryptContext
|
||||
import uuid
|
||||
import random
|
||||
from io import BytesIO
|
||||
from captcha.image import ImageCaptcha
|
||||
|
||||
from ...db import models
|
||||
from . import schemas
|
||||
from ...dependencies import get_db
|
||||
from ...config import jwt_config
|
||||
|
||||
CAPTCHA_SYMBOLS = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
@ -65,7 +70,7 @@ def create_user(db: Session, user_data: schemas.UserRegister) -> schemas.UserInD
|
||||
return schemas.UserInDB.model_validate(user)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> schemas.UserInDB:
|
||||
@ -90,11 +95,13 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_or_none(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
def get_current_user_or_none(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
authorization: Annotated[Union[str, None], Header()] = None,
|
||||
) -> Union[schemas.UserInDB, None]:
|
||||
try:
|
||||
if authorization:
|
||||
token = authorization.split()[1]
|
||||
payload = jwt.decode(
|
||||
token, jwt_config.SECRET_KEY, algorithms=[jwt_config.ALGORITHM]
|
||||
)
|
||||
@ -102,15 +109,101 @@ async def get_current_user_or_none(
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = schemas.TokenData(username=username)
|
||||
else:
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
user = get_user_by_username(db, username=token_data.username)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
def get_current_active_user(
|
||||
current_user: Annotated[schemas.User, Depends(get_current_user)],
|
||||
):
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
|
||||
def create_anon_user(db: Annotated[Session, Depends(get_db)]) -> schemas.AnonUser:
|
||||
u = models.AnonymousUser()
|
||||
db.add(u)
|
||||
db.commit()
|
||||
# return schemas.AnonUser.model_validate(u)
|
||||
return u
|
||||
|
||||
|
||||
def get_anon_user(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_client_id: Annotated[Union[str, None], Header()] = None,
|
||||
) -> schemas.AnonUser:
|
||||
if x_client_id:
|
||||
anon = (
|
||||
db.query(models.AnonymousUser)
|
||||
.filter(models.AnonymousUser.id == x_client_id)
|
||||
.first()
|
||||
)
|
||||
if anon:
|
||||
return anon
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_418_IM_A_TEAPOT,
|
||||
)
|
||||
return create_anon_user(db)
|
||||
|
||||
|
||||
def patch_anon_name(
|
||||
data: schemas.AnonUserPatch,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_client_id: Annotated[Union[str, None], Header()] = None,
|
||||
) -> schemas.AnonUser:
|
||||
if x_client_id:
|
||||
anon = (
|
||||
db.query(models.AnonymousUser)
|
||||
.filter(models.AnonymousUser.id == x_client_id)
|
||||
.first()
|
||||
)
|
||||
if anon:
|
||||
setattr(anon, "name", data.name)
|
||||
db.commit()
|
||||
return anon
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_418_IM_A_TEAPOT,
|
||||
)
|
||||
return create_anon_user(db)
|
||||
|
||||
|
||||
def get_captcha(
|
||||
captcha_id: uuid.UUID, db: Annotated[Session, Depends(get_db)]
|
||||
) -> BytesIO:
|
||||
prompt = "".join(random.choice(CAPTCHA_SYMBOLS) for i in range(4))
|
||||
c = models.Captcha(id=captcha_id, prompt=prompt)
|
||||
try:
|
||||
db.add(c)
|
||||
db.commit()
|
||||
except:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_418_IM_A_TEAPOT,
|
||||
)
|
||||
captcha = ImageCaptcha()
|
||||
data = captcha.generate(prompt)
|
||||
return data
|
||||
|
||||
|
||||
def check_captcha(
|
||||
id: uuid.UUID, prompt: str, db: Annotated[Session, Depends(get_db)]
|
||||
) -> bool:
|
||||
c = (
|
||||
db.query(models.Captcha)
|
||||
.filter(
|
||||
models.Captcha.id == id,
|
||||
models.Captcha.prompt == prompt,
|
||||
models.Captcha.used == False,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if c:
|
||||
setattr(c, "used", True)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Union
|
||||
from sqlalchemy.orm import Session
|
||||
import redis
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
@ -9,7 +10,8 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...config import jwt_config
|
||||
from ...dependencies import get_db
|
||||
from ...dependencies import get_db, get_redis
|
||||
from ...db import models
|
||||
from . import schemas
|
||||
from . import services
|
||||
|
||||
@ -25,8 +27,15 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/owned")
|
||||
async def owned_queues_list(
|
||||
queues: Annotated[schemas.Queue, Depends(services.get_owned_queues)],
|
||||
) -> list[schemas.QueueInDb]:
|
||||
return queues
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def user_queues_list(
|
||||
async def anonuser_queues_list(
|
||||
queues: Annotated[schemas.Queue, Depends(services.get_user_queues)],
|
||||
) -> list[schemas.QueueInDb]:
|
||||
return queues
|
||||
@ -46,3 +55,24 @@ async def create_queue(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> schemas.QueueInDb:
|
||||
return services.create_queue(new_queue=new_queue, current_user=current_user, db=db)
|
||||
|
||||
|
||||
@router.post("/{queue_id}/join")
|
||||
async def join_queue(
|
||||
queue_user: Annotated[schemas.QueueUser, Depends(services.join_queue)]
|
||||
) -> schemas.QueueUser:
|
||||
return queue_user
|
||||
|
||||
|
||||
@router.post("/{queue_id}/listen")
|
||||
async def listen_queue(
|
||||
updated_queue: Annotated[schemas.QueueDetail, Depends(services.set_queue_listener)]
|
||||
) -> schemas.QueueDetail:
|
||||
return updated_queue
|
||||
|
||||
|
||||
@router.post("/{queue_id}/action/{action}")
|
||||
async def perform_queue_action(
|
||||
result: Annotated[schemas.ActionResult, Depends(services.action_wrapper)]
|
||||
):
|
||||
return result
|
||||
|
||||
@ -1,16 +1,42 @@
|
||||
from typing import Union
|
||||
from typing import Union, List
|
||||
from pydantic import BaseModel
|
||||
from uuid import UUID
|
||||
from ..auth import schemas as auth_schemas
|
||||
|
||||
|
||||
class QueueGroup(BaseModel):
|
||||
name: str
|
||||
priority: int
|
||||
|
||||
|
||||
class QueueGroupDetail(QueueGroup):
|
||||
id: UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class QueueUser(BaseModel):
|
||||
id: UUID
|
||||
position: int
|
||||
passed: bool
|
||||
group_id: UUID | None = None
|
||||
user: auth_schemas.AnonUser
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ParticipantInfo(BaseModel):
|
||||
total: int
|
||||
remaining: int
|
||||
users_list: List[QueueUser]
|
||||
|
||||
|
||||
class Queue(BaseModel):
|
||||
name: str
|
||||
description: Union[str, None] = None
|
||||
groups: List[QueueGroup] | None = None
|
||||
|
||||
|
||||
class QueueInList(Queue):
|
||||
@ -20,8 +46,10 @@ class QueueInList(Queue):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class QueueInDb(Queue):
|
||||
class QueueInDb(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: Union[str, None] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -29,4 +57,19 @@ class QueueInDb(Queue):
|
||||
|
||||
class QueueDetail(Queue):
|
||||
id: UUID
|
||||
status: str
|
||||
owner_id: UUID
|
||||
participants: ParticipantInfo
|
||||
groups: List[QueueGroupDetail] | None
|
||||
|
||||
|
||||
class ActionResult(BaseModel):
|
||||
action: str
|
||||
result: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class JoinRequest(BaseModel):
|
||||
group_id: UUID | None = None
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from typing import Annotated
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func
|
||||
from uuid import UUID
|
||||
import redis
|
||||
import asyncio
|
||||
|
||||
from ...dependencies import get_db
|
||||
from ...dependencies import get_db, get_pubsub, get_redis
|
||||
from ...db import models
|
||||
|
||||
from ..auth import services as auth_services
|
||||
@ -12,22 +15,49 @@ from ..auth import schemas as auth_schemas
|
||||
from . import schemas
|
||||
|
||||
|
||||
def get_user_queues(
|
||||
def get_queue_by_id(
|
||||
queue_id: UUID, db: Annotated[Session, Depends(get_db)]
|
||||
) -> models.Queue:
|
||||
q = db.query(models.Queue).filter(models.Queue.id == queue_id).first()
|
||||
return q
|
||||
|
||||
|
||||
def get_owned_queues(
|
||||
current_user: Annotated[auth_schemas.User, Depends(auth_services.get_current_user)]
|
||||
) -> list[schemas.QueueInDb]:
|
||||
return [schemas.QueueInDb.model_validate(q) for q in current_user.owns_queues]
|
||||
|
||||
|
||||
def get_user_queues(
|
||||
current_user: Annotated[auth_schemas.User, Depends(auth_services.get_anon_user)]
|
||||
) -> list[schemas.QueueInDb]:
|
||||
return [
|
||||
schemas.QueueInDb.model_validate(q.queue)
|
||||
for q in current_user.parts_in_queues.filter(models.QueueUser.passed == False)
|
||||
]
|
||||
|
||||
|
||||
def create_queue(
|
||||
new_queue: schemas.Queue,
|
||||
current_user: auth_schemas.UserInDB,
|
||||
db: Session,
|
||||
) -> schemas.QueueInDb:
|
||||
q = models.Queue(
|
||||
name=new_queue.name, description=new_queue.description, owner_id=current_user.id
|
||||
name=new_queue.name,
|
||||
description=new_queue.description,
|
||||
owner_id=current_user.id,
|
||||
status="created",
|
||||
)
|
||||
db.add(q)
|
||||
db.commit()
|
||||
if new_queue.groups:
|
||||
db.add_all(
|
||||
instances=[
|
||||
models.QueueGroup(name=qg.name, priority=qg.priority, queue_id=q.id)
|
||||
for qg in new_queue.groups
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return schemas.QueueInDb.model_validate(q)
|
||||
|
||||
|
||||
@ -36,18 +66,224 @@ def get_detailed_queue(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> schemas.QueueDetail:
|
||||
q = db.query(models.Queue).filter(models.Queue.id == queue_id).first()
|
||||
print("\n\n", queue_id, "\n\n", flush=True)
|
||||
if q:
|
||||
return schemas.QueueDetail(
|
||||
id=q.id,
|
||||
name=q.name,
|
||||
description=q.description,
|
||||
status=q.status,
|
||||
owner_id=q.owner_id,
|
||||
groups=q.groups.order_by(models.QueueGroup.priority.asc()),
|
||||
participants=schemas.ParticipantInfo(
|
||||
total=q.users.count(),
|
||||
remaining=q.users.filter(models.QueueUser.passed == False).count(),
|
||||
users_list=q.users.filter(models.QueueUser.passed == False).order_by(
|
||||
models.QueueUser.position.asc()
|
||||
),
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
)
|
||||
|
||||
|
||||
async def join_queue(
|
||||
queue_id: UUID,
|
||||
join_request: schemas.JoinRequest | None,
|
||||
client: Annotated[auth_schemas.AnonUser, Depends(auth_services.get_anon_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
r: Annotated[redis.client.Redis, Depends(get_redis)],
|
||||
) -> schemas.QueueUser:
|
||||
q = get_queue_by_id(queue_id, db)
|
||||
if q:
|
||||
if not q.users.filter(models.QueueUser.user_id == client.id).first():
|
||||
last_qu = q.users.order_by(models.QueueUser.position.desc()).first()
|
||||
position = last_qu.position + 1 if last_qu else 0
|
||||
new_qu = models.QueueUser(
|
||||
user_id=client.id,
|
||||
queue_id=q.id,
|
||||
position=position,
|
||||
group_id=(
|
||||
join_request.group_id
|
||||
if join_request and join_request.group_id
|
||||
else None
|
||||
),
|
||||
)
|
||||
db.add(new_qu)
|
||||
db.commit()
|
||||
await rebuild_queue(queue=q, db=db)
|
||||
await r.publish(str(queue_id), "updated")
|
||||
return new_qu
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Already joined",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Not Found",
|
||||
)
|
||||
|
||||
|
||||
async def set_queue_listener(
|
||||
queue_id: UUID,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
ps: Annotated[redis.client.PubSub, Depends(get_pubsub)],
|
||||
) -> schemas.QueueDetail:
|
||||
await ps.subscribe(str(queue_id))
|
||||
async for m in ps.listen():
|
||||
if m.get("data", None) == b"updated":
|
||||
break
|
||||
await ps.unsubscribe()
|
||||
new_queue = get_detailed_queue(queue_id=queue_id, db=db)
|
||||
return new_queue
|
||||
|
||||
|
||||
async def get_queue_owner(
|
||||
queue: Annotated[models.Queue, Depends(get_queue_by_id)]
|
||||
) -> auth_schemas.UserInDB:
|
||||
return queue.owner if queue else None
|
||||
|
||||
|
||||
async def verify_queue_owner(
|
||||
queue_owner: Annotated[auth_schemas.UserInDB, Depends(get_queue_owner)],
|
||||
current_user: Annotated[
|
||||
auth_schemas.UserInDB, Depends(auth_services.get_current_user_or_none)
|
||||
],
|
||||
) -> bool:
|
||||
return queue_owner.id == current_user.id if queue_owner and current_user else False
|
||||
|
||||
|
||||
async def rebuild_queue(
|
||||
queue: Annotated[models.Queue, Depends(get_queue_by_id)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
query = (
|
||||
db.query(models.QueueUser)
|
||||
.join(
|
||||
models.QueueGroup,
|
||||
models.QueueUser.group_id == models.QueueGroup.id,
|
||||
isouter=True,
|
||||
)
|
||||
.filter(models.QueueUser.passed == False, models.QueueUser.queue_id == queue.id)
|
||||
.order_by(
|
||||
func.coalesce(models.QueueGroup.priority, 0).asc(),
|
||||
models.QueueUser.position.asc(),
|
||||
)
|
||||
.options(joinedload(models.QueueUser.group))
|
||||
)
|
||||
queueusers = query.all()
|
||||
first_qu_found_and_queue_in_process = False
|
||||
if queue.status == "active":
|
||||
for i, qu in enumerate(queueusers):
|
||||
if qu.position == 0:
|
||||
first_qu_found_and_queue_in_process = True
|
||||
del queueusers[i]
|
||||
break
|
||||
for i, qu in enumerate(queueusers):
|
||||
if first_qu_found_and_queue_in_process:
|
||||
setattr(qu, "position", i + 1)
|
||||
continue
|
||||
setattr(qu, "position", i)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def kick_first(
|
||||
is_owner: Annotated[bool, Depends(verify_queue_owner)],
|
||||
queue: Annotated[models.Queue, Depends(get_queue_by_id)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
if is_owner:
|
||||
first_user = (
|
||||
queue.users.filter(models.QueueUser.passed == False)
|
||||
.order_by(models.QueueUser.position.asc())
|
||||
.first()
|
||||
)
|
||||
if first_user:
|
||||
setattr(first_user, "passed", True)
|
||||
|
||||
db.commit()
|
||||
await rebuild_queue(queue=queue, db=db)
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No first user",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="You are not a queue owner!",
|
||||
)
|
||||
|
||||
|
||||
async def start_queue(
|
||||
is_owner: Annotated[bool, Depends(verify_queue_owner)],
|
||||
queue: Annotated[models.Queue, Depends(get_queue_by_id)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
if queue and is_owner:
|
||||
setattr(queue, "status", "active")
|
||||
db.commit()
|
||||
await rebuild_queue(queue=queue, db=db)
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="You are not a queue owner!",
|
||||
)
|
||||
|
||||
|
||||
async def pass_queueuser(
|
||||
queue: Annotated[models.Queue, Depends(get_queue_by_id)],
|
||||
anon_user: Annotated[auth_schemas.AnonUser, Depends(auth_services.get_anon_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
if anon_user:
|
||||
qu = (
|
||||
db.query(models.QueueUser)
|
||||
.filter(
|
||||
models.QueueUser.queue_id == queue.id,
|
||||
models.QueueUser.user_id == anon_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if qu:
|
||||
setattr(qu, "passed", True)
|
||||
db.commit()
|
||||
await rebuild_queue(queue=queue, db=db)
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found!",
|
||||
)
|
||||
|
||||
|
||||
async def action_wrapper(
|
||||
action: str,
|
||||
is_owner: Annotated[bool, Depends(verify_queue_owner)],
|
||||
queue: Annotated[models.Queue, Depends(get_queue_by_id)],
|
||||
anon_user: Annotated[auth_schemas.AnonUser, Depends(auth_services.get_anon_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
r: Annotated[redis.client.Redis, Depends(get_redis)],
|
||||
) -> schemas.ActionResult:
|
||||
if queue:
|
||||
if action == "kick-first":
|
||||
await kick_first(is_owner=is_owner, queue=queue, db=db)
|
||||
await r.publish(str(queue.id), "updated")
|
||||
return {"action": action, "status": "success"}
|
||||
if action == "pass":
|
||||
await pass_queueuser(queue=queue, anon_user=anon_user, db=db)
|
||||
await r.publish(str(queue.id), "updated")
|
||||
return {"action": action, "status": "success"}
|
||||
if action == "start":
|
||||
await start_queue(queue=queue, is_owner=is_owner, db=db)
|
||||
await r.publish(str(queue.id), "updated")
|
||||
return {"action": action, "status": "success"}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Action not found!",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Queue not found!",
|
||||
)
|
||||
|
||||
@ -5,3 +5,5 @@ sqlalchemy
|
||||
psycopg2-binary
|
||||
python-jose[cryptography]
|
||||
passlib[all]
|
||||
captcha
|
||||
redis[hiredis]
|
||||
12
dev.yml
12
dev.yml
@ -13,6 +13,8 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
build:
|
||||
context: frontend
|
||||
@ -51,3 +53,13 @@ services:
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 5
|
||||
redis:
|
||||
image: redis:7.2.4-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 6379
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 5
|
||||
|
||||
@ -9,10 +9,11 @@ services:
|
||||
env_file:
|
||||
- path: ./env/backend/prod.env
|
||||
required: true
|
||||
volumes:
|
||||
- ./backend/app:/code/app:z
|
||||
networks:
|
||||
- db
|
||||
- default
|
||||
depends_on:
|
||||
postgres:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
nginx:
|
||||
build:
|
||||
@ -23,20 +24,37 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
postgres:
|
||||
image: postgres:16.2-alpine
|
||||
- "8880:80"
|
||||
networks:
|
||||
- default
|
||||
# postgres:
|
||||
# image: postgres:16.2-alpine
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# volumes:
|
||||
# - ./postgres_data:/var/lib/postgresql/data/
|
||||
# env_file:
|
||||
# - path: ./env/postgres.env
|
||||
# required: true
|
||||
# healthcheck:
|
||||
# test:
|
||||
# ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} --user $${POSTGRES_USER}"]
|
||||
# interval: 2s
|
||||
# timeout: 2s
|
||||
# retries: 5
|
||||
redis:
|
||||
image: redis:7.2.4-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./postgres_data:/var/lib/postgresql/data/
|
||||
env_file:
|
||||
- path: ./env/postgres.env
|
||||
required: true
|
||||
- 6379
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} --user $${POSTGRES_USER}"]
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
db:
|
||||
name: docker-net
|
||||
external: true
|
||||
|
||||
2
env/backend/dev.env
vendored
2
env/backend/dev.env
vendored
@ -2,3 +2,5 @@ POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=db
|
||||
DEBUG=1
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
2
env/backend/prod.env
vendored
2
env/backend/prod.env
vendored
@ -1,3 +1,3 @@
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=db
|
||||
POSTGRES_DB=queueful
|
||||
40
frontend/app/package-lock.json
generated
40
frontend/app/package-lock.json
generated
@ -16,13 +16,15 @@
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
"react-router-dom": "^6.22.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^0.5.4",
|
||||
"@rsbuild/plugin-react": "^0.5.4",
|
||||
"@types/react": "^18.2.71",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
@ -711,6 +713,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
|
||||
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz",
|
||||
@ -1251,12 +1259,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.0.1"
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -2037,10 +2046,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
@ -2653,6 +2663,7 @@
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
@ -4519,6 +4530,7 @@
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
@ -4743,6 +4755,18 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz",
|
||||
|
||||
@ -16,13 +16,15 @@
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
"react-router-dom": "^6.22.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^0.5.4",
|
||||
"@rsbuild/plugin-react": "^0.5.4",
|
||||
"@types/react": "^18.2.71",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
|
||||
@ -9,6 +9,7 @@ export default defineConfig({
|
||||
},
|
||||
html: {
|
||||
favicon: "./static/favicon-32x32.png",
|
||||
appIcon: "./static/android-chrome-512x512.png",
|
||||
title: "queueful!",
|
||||
template: "./static/index.html",
|
||||
},
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import React, { createContext } from "react";
|
||||
import { ConfigProvider, message } from "antd";
|
||||
import { message } from "antd";
|
||||
import "./App.css";
|
||||
import HeaderComponent from "./components/HeaderComponent";
|
||||
import { theme } from "./config/style";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./config/store";
|
||||
import { MessageInstance } from "antd/es/message/interface";
|
||||
import AppRoutes from "./pages/AppRoutes";
|
||||
import ThemeProviderWrapper from "./config/ThemeProviderWrapper";
|
||||
|
||||
export const MessageContext = createContext({} as MessageInstance);
|
||||
|
||||
@ -17,7 +17,7 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ConfigProvider theme={theme}>
|
||||
<ThemeProviderWrapper>
|
||||
<MessageContext.Provider value={messageApi}>
|
||||
<div className="content">
|
||||
{contextHolder}
|
||||
@ -26,7 +26,7 @@ const App = () => {
|
||||
</AppRoutes>
|
||||
</div>
|
||||
</MessageContext.Provider>
|
||||
</ConfigProvider>
|
||||
</ThemeProviderWrapper>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,20 +1,33 @@
|
||||
import { Carousel, Form, Input, Menu, MenuProps, Modal, Spin } from "antd";
|
||||
import {
|
||||
Button,
|
||||
Carousel,
|
||||
Form,
|
||||
Input,
|
||||
Menu,
|
||||
MenuProps,
|
||||
Modal,
|
||||
Spin,
|
||||
} from "antd";
|
||||
import {
|
||||
KeyOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
UserAddOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
TokenResponse,
|
||||
useGetClientQuery,
|
||||
useGetUserQuery,
|
||||
useLoginMutation,
|
||||
useRegisterMutation,
|
||||
} from "../slice/AuthApi";
|
||||
import { MessageContext } from "../App";
|
||||
import { store, updateToken, updateUser } from "../config/store";
|
||||
import { store, updateClient, updateToken, updateUser } from "../config/store";
|
||||
import tr from "../config/translation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { baseUrl } from "../config/baseUrl";
|
||||
import { CarouselRef } from "antd/es/carousel";
|
||||
|
||||
const AuthModal = (props: {
|
||||
open: boolean;
|
||||
@ -23,11 +36,27 @@ const AuthModal = (props: {
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const messageApi = useContext(MessageContext);
|
||||
const carousel = useRef();
|
||||
const carousel = React.createRef<CarouselRef>();
|
||||
|
||||
const [loginForm] = Form.useForm();
|
||||
const [registerForm] = Form.useForm();
|
||||
|
||||
const [captchaId, setCaptchaId] = useState("");
|
||||
const [captchaPic, setCaptchaPic] = useState("");
|
||||
const [captchaFetching, setCaptchaFetching] = useState(false);
|
||||
|
||||
const fetchCaptcha = async () => {
|
||||
setCaptchaFetching(true);
|
||||
registerForm.setFieldValue(["captcha", "prompt"], "");
|
||||
const id = uuidv4();
|
||||
setCaptchaId(id);
|
||||
const res = await fetch(`${baseUrl}/auth/captcha/${id}`);
|
||||
const imageBlob = await res.blob();
|
||||
const imageObjectURL = URL.createObjectURL(imageBlob);
|
||||
setCaptchaPic(imageObjectURL);
|
||||
setCaptchaFetching(false);
|
||||
};
|
||||
|
||||
const { data, refetch, isFetching, isError } = useGetUserQuery({});
|
||||
useEffect(() => {
|
||||
if (!isFetching && !isError) {
|
||||
@ -35,9 +64,24 @@ const AuthModal = (props: {
|
||||
}
|
||||
}, [data, isFetching, useGetUserQuery]);
|
||||
|
||||
const {
|
||||
data: clientData,
|
||||
isFetching: isFetchingClient,
|
||||
isError: isErrorClient,
|
||||
} = useGetClientQuery({});
|
||||
useEffect(() => {
|
||||
if (!isFetchingClient) {
|
||||
if (isErrorClient) {
|
||||
store.dispatch(updateClient(null));
|
||||
} else {
|
||||
store.dispatch(updateClient(clientData.id));
|
||||
}
|
||||
}
|
||||
}, [clientData, isFetchingClient, useGetClientQuery]);
|
||||
|
||||
const [current, setCurrent] = useState("login");
|
||||
useEffect(() => {
|
||||
if (carousel?.current !== undefined) {
|
||||
if (carousel && carousel.current && carousel?.current !== undefined) {
|
||||
carousel.current.goTo(["login", "register"].indexOf(current));
|
||||
}
|
||||
}, [current]);
|
||||
@ -60,7 +104,7 @@ const AuthModal = (props: {
|
||||
.then(() => props.setOpen(false))
|
||||
.then(() => navigate("/dashboard"))
|
||||
.then(() => props.setDrawerOpen(false))
|
||||
.catch(() => messageApi.error(tr("Login failed!")));
|
||||
.catch((e) => messageApi.error(tr(e.data.detail)));
|
||||
};
|
||||
|
||||
const submitRegisterForm = (formData: {
|
||||
@ -68,12 +112,19 @@ const AuthModal = (props: {
|
||||
name: string | undefined;
|
||||
password: string;
|
||||
password2: string;
|
||||
captcha: { id: string; prompt: string };
|
||||
}) => {
|
||||
formData.captcha.id = captchaId;
|
||||
registerUser(formData)
|
||||
.unwrap()
|
||||
.then(() => submitLoginForm(formData))
|
||||
.then(() => props.setOpen(false))
|
||||
.catch(() => messageApi.error(tr("Registration failed!")));
|
||||
.then(() => setCaptchaPic(""))
|
||||
.then(() => registerForm.resetFields())
|
||||
.catch((e) => {
|
||||
messageApi.error(tr(e.data.detail));
|
||||
fetchCaptcha();
|
||||
});
|
||||
};
|
||||
|
||||
const items: MenuProps["items"] = [
|
||||
@ -100,10 +151,7 @@ const AuthModal = (props: {
|
||||
okText={current === "login" ? tr("Log in") : tr("Register")}
|
||||
confirmLoading={isLoggingIn}
|
||||
>
|
||||
<Spin
|
||||
spinning={isLoggingIn || isRegistering}
|
||||
indicator={<LoadingOutlined style={{ fontSize: 36 }} spin />}
|
||||
>
|
||||
<Spin spinning={isLoggingIn || isRegistering}>
|
||||
<div
|
||||
style={{ display: "flex", width: "100%", justifyContent: "center" }}
|
||||
>
|
||||
@ -209,9 +257,39 @@ const AuthModal = (props: {
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input type="password" />
|
||||
</Form.Item>
|
||||
<Form.Item label={tr("Captcha")}>
|
||||
{captchaId ? (
|
||||
<Spin spinning={captchaFetching}>
|
||||
<img
|
||||
onClick={() => fetchCaptcha()}
|
||||
src={captchaPic}
|
||||
alt={tr("Captcha")}
|
||||
/>
|
||||
</Spin>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => fetchCaptcha()}
|
||||
icon={<ReloadOutlined />}
|
||||
>
|
||||
{tr("Fetch captcha")}
|
||||
</Button>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["captcha", "prompt"]}
|
||||
label={tr("Captcha prompt")}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: tr("Please enter captcha!"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
disabled={!captchaId}
|
||||
onPressEnter={() => registerForm.submit()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@ -1,17 +1,26 @@
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
MoonOutlined,
|
||||
PicCenterOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Drawer, Layout, Menu, MenuProps } from "antd";
|
||||
import { Drawer, Layout, Menu, MenuProps, Switch } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import AuthModal from "./AuthModal";
|
||||
import "./styles.css";
|
||||
import { StorePrototype, logOut, setLanguage, store } from "../config/store";
|
||||
import {
|
||||
StorePrototype,
|
||||
logOut,
|
||||
setLanguage,
|
||||
setTheme,
|
||||
store,
|
||||
} from "../config/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import tr from "../config/translation";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@ -43,6 +52,10 @@ const HeaderComponent = () => {
|
||||
(state: StorePrototype) => state.auth.user
|
||||
);
|
||||
|
||||
const currentTheme: string | undefined = useSelector(
|
||||
(state: StorePrototype) => state.settings.theme
|
||||
);
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const keys = [];
|
||||
@ -101,6 +114,11 @@ const HeaderComponent = () => {
|
||||
icon: <DesktopOutlined />,
|
||||
disabled: !user,
|
||||
},
|
||||
{
|
||||
label: <Link to="/parting">{tr("Current queues")}</Link>,
|
||||
key: "parting",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
{
|
||||
label: <Link to="/news">{tr("News")}</Link>,
|
||||
key: "news",
|
||||
@ -113,6 +131,18 @@ const HeaderComponent = () => {
|
||||
children: languageSelectItems,
|
||||
style: { background: "#001529" },
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Switch
|
||||
onChange={(v: boolean) =>
|
||||
store.dispatch(setTheme(v ? "light" : "dark"))
|
||||
}
|
||||
defaultChecked={currentTheme === "light"}
|
||||
/>
|
||||
),
|
||||
key: "theme",
|
||||
icon: currentTheme === "dark" ? <MoonOutlined /> : <SunOutlined />,
|
||||
},
|
||||
{
|
||||
label: user ? user.username : tr("Log in"),
|
||||
key: "login",
|
||||
@ -147,6 +177,12 @@ const HeaderComponent = () => {
|
||||
disabled: !user,
|
||||
onClick: () => setDrawerOpen(false),
|
||||
},
|
||||
{
|
||||
label: <Link to="/parting">{tr("Current queues")}</Link>,
|
||||
key: "parting",
|
||||
icon: <ClockCircleOutlined />,
|
||||
onClick: () => setDrawerOpen(false),
|
||||
},
|
||||
{
|
||||
label: <Link to="/news">{tr("News")}</Link>,
|
||||
key: "news",
|
||||
@ -167,6 +203,18 @@ const HeaderComponent = () => {
|
||||
onClick: () => !user && setAuthModalOpen(true),
|
||||
...(user ? { children: userMenuItems } : {}),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Switch
|
||||
onChange={(v: boolean) =>
|
||||
store.dispatch(setTheme(v ? "light" : "dark"))
|
||||
}
|
||||
defaultChecked={currentTheme === "light"}
|
||||
/>
|
||||
),
|
||||
key: "theme",
|
||||
icon: currentTheme === "dark" ? <MoonOutlined /> : <SunOutlined />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
123
frontend/app/src/components/queue/ApproveQueueJoinCard.tsx
Normal file
123
frontend/app/src/components/queue/ApproveQueueJoinCard.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import "../styles.css";
|
||||
import { Button, Input, Select, Spin } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileTextOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import tr from "../../config/translation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useGetQueueDetailQuery,
|
||||
useJoinQueueMutation,
|
||||
} from "../../slice/QueueApi";
|
||||
import { MessageContext } from "../../App";
|
||||
import { usePatchAnonMutation } from "../../slice/AuthApi";
|
||||
|
||||
const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const messageApi = useContext(MessageContext);
|
||||
|
||||
const { data, refetch, isFetching, isError } = useGetQueueDetailQuery(
|
||||
props.id,
|
||||
{
|
||||
skip: !props.id,
|
||||
}
|
||||
);
|
||||
const [joinQueue, { isLoading }] = useJoinQueueMutation();
|
||||
const [patchAnon] = usePatchAnonMutation();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>();
|
||||
|
||||
const onJoinButtonClick = () => {
|
||||
joinQueue({ queueId: props.id, data: { group_id: selectedGroup } })
|
||||
.unwrap()
|
||||
.then(() => navigate(`/queue/${props.id}`))
|
||||
.then(() => refetch())
|
||||
.then(() => messageApi.success(tr("Successfully joined queue")))
|
||||
.catch((e) => messageApi.error(tr(e.data.detail)));
|
||||
};
|
||||
|
||||
const patchName = () => {
|
||||
patchAnon({ name: newName })
|
||||
.unwrap()
|
||||
.then(() => messageApi.success(tr("Successfully changed name")))
|
||||
.catch(() => messageApi.error(tr("Error changing name")));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<Spin spinning={isFetching}>
|
||||
{isError ? (
|
||||
<>
|
||||
<Title level={3}>{tr("Queue not found!")}</Title>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
type="primary"
|
||||
onClick={() => navigate("/queue/join")}
|
||||
>
|
||||
{tr("Go back")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="queue-info">
|
||||
<Spin spinning={isLoading}>
|
||||
<Title level={3} style={{ textAlign: "left" }}>
|
||||
{data?.name}
|
||||
</Title>
|
||||
<p>
|
||||
<FileTextOutlined />
|
||||
{" "}
|
||||
{data?.description}
|
||||
</p>
|
||||
<p>
|
||||
<UserOutlined />
|
||||
{" "}
|
||||
{data?.participants?.remaining} / {data?.participants?.total}
|
||||
</p>
|
||||
<Title level={4}>{tr("Update your name")}</Title>
|
||||
<div style={{ display: "flex", flexFlow: "row", width: "30vw" }}>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={tr("Enter new name")}
|
||||
/>
|
||||
<Button style={{ marginLeft: "1rem" }} onClick={patchName}>
|
||||
{tr("Update")}
|
||||
</Button>
|
||||
</div>
|
||||
{data?.groups.length !== undefined && data?.groups.length > 0 && (
|
||||
<>
|
||||
<Title level={4}>{tr("Select group in queue")}</Title>
|
||||
<Select
|
||||
options={data?.groups.map((obj) => ({
|
||||
value: obj.id,
|
||||
label: obj.name,
|
||||
}))}
|
||||
placeholder={tr("Select group...")}
|
||||
value={selectedGroup}
|
||||
style={{ width: "30vw", marginTop: "1rem" }}
|
||||
onChange={setSelectedGroup}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
style={{ width: "100%", marginTop: "2rem" }}
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onJoinButtonClick}
|
||||
>
|
||||
{tr("Join")}
|
||||
</Button>
|
||||
</Spin>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ApproveQueueJoinCard;
|
||||
@ -1,16 +1,23 @@
|
||||
import React, { useContext } from "react";
|
||||
import React, { useContext, useReducer, useState } from "react";
|
||||
import {
|
||||
CreateQueue,
|
||||
CreateQueueRequest,
|
||||
Queue,
|
||||
useCreateQueueMutation,
|
||||
} from "../../slice/QueueApi";
|
||||
import "../styles.css";
|
||||
import { Button, Form, Input, Spin } from "antd";
|
||||
import { Button, Form, Input, List, Spin, Tooltip, Typography } from "antd";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import tr from "../../config/translation";
|
||||
import { MessageContext } from "../../App";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PlusCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
PlusCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
UpOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
const CreateQueueCard = (): JSX.Element => {
|
||||
const messageApi = useContext(MessageContext);
|
||||
@ -19,14 +26,95 @@ const CreateQueueCard = (): JSX.Element => {
|
||||
const [form] = Form.useForm();
|
||||
const [createQueue, { isLoading }] = useCreateQueueMutation();
|
||||
|
||||
const submit = (formData: CreateQueueRequest) => {
|
||||
createQueue(formData)
|
||||
const [newGroupValue, setNewGroupValue] = useState("");
|
||||
const [groupsList, setGroupsList] = useState<[{ name: string; id: string }]>([
|
||||
{ name: tr("Default"), id: "default" },
|
||||
]);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const pushGroupUp = (element: { name: string; id: string }) => {
|
||||
const index = groupsList.indexOf(element);
|
||||
if (index > 0) {
|
||||
const newArr = groupsList;
|
||||
const temp = newArr[index - 1];
|
||||
newArr[index - 1] = element;
|
||||
newArr[index] = temp;
|
||||
setGroupsList(newArr);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const pushGroupDown = (element: { name: string; id: string }) => {
|
||||
const index = groupsList.indexOf(element);
|
||||
if (index < groupsList.length - 1) {
|
||||
const newArr = groupsList;
|
||||
const temp = newArr[index + 1];
|
||||
newArr[index + 1] = element;
|
||||
newArr[index] = temp;
|
||||
setGroupsList(newArr);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = (element: { name: string; id: string }) => {
|
||||
if (groupsList.includes(element)) {
|
||||
const newArr = groupsList;
|
||||
newArr.splice(groupsList.indexOf(element), 1);
|
||||
setGroupsList(newArr);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
type OrderedGroup = { name: string; priority: number };
|
||||
|
||||
const getOrderedGroups = () => {
|
||||
const divId = groupsList.findIndex((ele) => ele.id === "default");
|
||||
if (divId || divId === 0) {
|
||||
const arr1: OrderedGroup[] = [];
|
||||
groupsList
|
||||
.slice(0, divId)
|
||||
.reverse()
|
||||
.forEach((ele, i) => arr1.push({ name: ele.name, priority: -(i + 1) }));
|
||||
const arr2: OrderedGroup[] = [];
|
||||
groupsList
|
||||
.slice(divId + 1)
|
||||
.forEach((ele, i) => arr2.push({ name: ele.name, priority: i + 1 }));
|
||||
return [...arr1, ...arr2];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getOrderedGroups();
|
||||
|
||||
const submit = (formData: CreateQueue) => {
|
||||
createQueue({ ...formData, groups: getOrderedGroups() })
|
||||
.unwrap()
|
||||
.then((data: Queue) => navigate(`/queue/${data.id}`))
|
||||
.then(() => messageApi.success(tr("Queue created")))
|
||||
.catch(() => messageApi.error(tr("Failed to create queue")));
|
||||
};
|
||||
|
||||
const addGroupToList = () => {
|
||||
if (newGroupValue) {
|
||||
const tmp = groupsList;
|
||||
if (tmp) {
|
||||
tmp.push({
|
||||
name: newGroupValue,
|
||||
id: tmp.length.toString(),
|
||||
});
|
||||
setGroupsList(tmp);
|
||||
} else {
|
||||
setGroupsList((v) => [
|
||||
{
|
||||
name: newGroupValue,
|
||||
id: v.length.toString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
setNewGroupValue("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<Spin spinning={isLoading}>
|
||||
@ -55,6 +143,77 @@ const CreateQueueCard = (): JSX.Element => {
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<List
|
||||
dataSource={groupsList}
|
||||
header={
|
||||
<div style={{ display: "flex", gap: "1rem" }}>
|
||||
<Title level={4}>
|
||||
{tr("Queue groups") + " " + tr("(optional)")}
|
||||
</Title>
|
||||
<Tooltip
|
||||
title={tr(
|
||||
"Queue's groups function allows you to define groups inside this particular queue. Groups have name and priority. Every participant selects theirs group for this queue, and their position is calculated by group's priority. Groups higher in the list will have higher priority."
|
||||
)}
|
||||
>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div style={{ display: "flex", gap: "1rem" }}>
|
||||
<Input
|
||||
placeholder={tr("Group's name")}
|
||||
value={newGroupValue}
|
||||
onChange={(e) => setNewGroupValue(e.target.value)}
|
||||
onPressEnter={addGroupToList}
|
||||
/>
|
||||
<Button onClick={addGroupToList}>{tr("Add group")}</Button>
|
||||
</div>
|
||||
}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item
|
||||
style={{
|
||||
display: "flex",
|
||||
alignContent: "space-between",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignContent: "space-between",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Typography.Text>#{index + 1}</Typography.Text>
|
||||
<Typography.Text>{item.name}</Typography.Text>
|
||||
</div>
|
||||
{item.id !== "default" && (
|
||||
<div style={{ display: "flex" }}>
|
||||
<Button
|
||||
onClick={() => pushGroupUp(item)}
|
||||
icon={<UpOutlined />}
|
||||
disabled={groupsList.indexOf(item) === 0}
|
||||
/>
|
||||
|
||||
<Button
|
||||
icon={<DownOutlined />}
|
||||
onClick={() => pushGroupDown(item)}
|
||||
disabled={
|
||||
groupsList.indexOf(item) === groupsList.length - 1
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => deleteGroup(item)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
></List>
|
||||
</Form.Item>
|
||||
<Button
|
||||
style={{ width: "100%" }}
|
||||
icon={<PlusCircleOutlined />}
|
||||
|
||||
47
frontend/app/src/components/queue/JoinQueueCard.tsx
Normal file
47
frontend/app/src/components/queue/JoinQueueCard.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React, { useState } from "react";
|
||||
import "../styles.css";
|
||||
import { Button, Divider, Input } from "antd";
|
||||
import { CameraOutlined } from "@ant-design/icons";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import tr from "../../config/translation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const getLink = (v: string) => {
|
||||
if (v.includes("v")) {
|
||||
return "/queue/join/" + v.split("/").slice(-1).pop();
|
||||
}
|
||||
return `/queue/join/${v}`;
|
||||
};
|
||||
|
||||
const JoinQueueCard = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const [value, setValue] = useState("");
|
||||
const processLink = () => {
|
||||
navigate(getLink(value));
|
||||
};
|
||||
return (
|
||||
<div className="card">
|
||||
<CameraOutlined />
|
||||
<Title level={3}>{tr("QR-code scanner in development")}</Title>
|
||||
<Divider>{tr("OR")}</Divider>
|
||||
<Title level={3}>{tr("Join queue by link or id")}</Title>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexFlow: "row",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
style={{ width: "100rem" }}
|
||||
placeholder={tr("Queue link or id")}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" onClick={() => processLink()}>
|
||||
{tr("Join")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default JoinQueueCard;
|
||||
104
frontend/app/src/components/queue/JoinQueueCard.tsx.old
Normal file
104
frontend/app/src/components/queue/JoinQueueCard.tsx.old
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useGetQueueDetailQuery } from "../../slice/QueueApi";
|
||||
import "../styles.css";
|
||||
import { Button, Divider, Spin } from "antd";
|
||||
import {
|
||||
FileTextOutlined,
|
||||
LoadingOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import tr from "../../config/translation";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../../config/store";
|
||||
import QrScanner from "qr-scanner";
|
||||
|
||||
export function useUserMedia() {
|
||||
const requestedMedia = {
|
||||
audio: false,
|
||||
video: { facingMode: "environment" },
|
||||
};
|
||||
const [mediaStream, setMediaStream] = useState<MediaStream>();
|
||||
|
||||
useEffect(() => {
|
||||
async function enableStream() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(
|
||||
requestedMedia
|
||||
);
|
||||
setMediaStream(stream);
|
||||
} catch (err) {
|
||||
// Removed for brevity
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaStream) {
|
||||
enableStream();
|
||||
} else {
|
||||
return function cleanup() {
|
||||
mediaStream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [mediaStream, requestedMedia]);
|
||||
|
||||
return mediaStream;
|
||||
}
|
||||
|
||||
const JoinQueueCard = (): JSX.Element => {
|
||||
const clientId = useSelector((state: StorePrototype) => state.auth.clientId);
|
||||
const mediaStream = useUserMedia();
|
||||
|
||||
// const permission = navigator.permissions.query({ name: "camera" });
|
||||
|
||||
const videoElem = React.createRef<HTMLVideoElement>();
|
||||
|
||||
if (mediaStream && videoElem.current && !videoElem.current.srcObject) {
|
||||
videoElem.current.srcObject = mediaStream;
|
||||
}
|
||||
|
||||
const [scanner, setScanner] = useState<QrScanner>();
|
||||
const [scannerStarted, setScannerStarted] = useState(false);
|
||||
if (videoElem.current) {
|
||||
const newScanner = new QrScanner(
|
||||
videoElem.current,
|
||||
(result) => console.log("decoded qr code:", result),
|
||||
{
|
||||
/* your options or returnDetailedScanResult: true if you're not specifying any other options */
|
||||
}
|
||||
);
|
||||
setScanner(newScanner);
|
||||
}
|
||||
if (scanner) {
|
||||
scanner.start();
|
||||
}
|
||||
|
||||
// useEffect(() => {
|
||||
// if (videoElem.current) {
|
||||
// const newScanner = new QrScanner(
|
||||
// videoElem.current,
|
||||
// (result) => console.log("decoded qr code:", result),
|
||||
// {
|
||||
// /* your options or returnDetailedScanResult: true if you're not specifying any other options */
|
||||
// }
|
||||
// );
|
||||
// setScanner(newScanner);
|
||||
// newScanner.start();
|
||||
// }
|
||||
// }, [videoElem]);
|
||||
// scanner.start();
|
||||
|
||||
// const qrScanner =
|
||||
// videoElem.current &&
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<video ref={videoElem} autoPlay playsInline muted />
|
||||
|
||||
<Divider>{tr("OR")}</Divider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default JoinQueueCard;
|
||||
60
frontend/app/src/components/queue/PartingQueuesList.tsx
Normal file
60
frontend/app/src/components/queue/PartingQueuesList.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useGetQueuesQuery } from "../../slice/QueueApi";
|
||||
import "../styles.css";
|
||||
import { Button, Spin } from "antd";
|
||||
import { LoadingOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import tr from "../../config/translation";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../../config/store";
|
||||
|
||||
type Queue = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const PartingQueuesList = (): JSX.Element => {
|
||||
const user = useSelector((state: StorePrototype) => state.auth.user);
|
||||
const { data, refetch, isLoading } = useGetQueuesQuery({});
|
||||
useEffect(() => {
|
||||
user && refetch();
|
||||
}, [user]);
|
||||
return (
|
||||
<div className="card">
|
||||
<Title level={2}>{tr("Queues you are in")}</Title>
|
||||
<br />
|
||||
<br />
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 36 }} spin />}
|
||||
spinning={isLoading}
|
||||
>
|
||||
{data?.length ? (
|
||||
data?.map((ele: Queue) => (
|
||||
<div className="card secondary queue-in-list" key={ele.id}>
|
||||
<Title level={4}>{ele.name}</Title>
|
||||
<Link to={`/queue/${ele.id}`}>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
type="primary"
|
||||
size="large"
|
||||
style={{ height: "100%", width: "4rem" }}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Title level={3}>{tr("You are not parting in any queues!")}</Title>
|
||||
<div className="button-box">
|
||||
<Link to="/queue/join">
|
||||
<Button size="large">{tr("Join a queue")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default PartingQueuesList;
|
||||
@ -1,23 +1,125 @@
|
||||
import React from "react";
|
||||
import { useGetQueueDetailQuery } from "../../slice/QueueApi";
|
||||
import "../styles.css";
|
||||
import { Button, Spin } from "antd";
|
||||
import React, { useContext, useState } from "react";
|
||||
import {
|
||||
ArrowUpOutlined,
|
||||
useGetQueueDetailQuery,
|
||||
useKickFirstActionMutation,
|
||||
useStartQueueActionMutation,
|
||||
} from "../../slice/QueueApi";
|
||||
import "../styles.css";
|
||||
import { Button, QRCode, Spin } from "antd";
|
||||
import {
|
||||
FieldTimeOutlined,
|
||||
FileTextOutlined,
|
||||
FlagOutlined,
|
||||
HourglassOutlined,
|
||||
LoadingOutlined,
|
||||
PlusCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
UserOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import tr from "../../config/translation";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import AnonUserCard from "../user/AnonUserCard";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../../config/store";
|
||||
import { MessageContext } from "../../App";
|
||||
import { baseClientUrl } from "../../config/baseUrl";
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "created":
|
||||
return (
|
||||
<p>
|
||||
<PlusCircleOutlined /> {" "}
|
||||
{tr("Created")}
|
||||
</p>
|
||||
);
|
||||
case "waiting":
|
||||
return (
|
||||
<p>
|
||||
<HourglassOutlined /> {" "}
|
||||
{tr("Waiting for start")}
|
||||
</p>
|
||||
);
|
||||
case "active":
|
||||
return (
|
||||
<p>
|
||||
<FieldTimeOutlined /> {" "}
|
||||
{tr("In progress")}
|
||||
</p>
|
||||
);
|
||||
case "finished":
|
||||
return (
|
||||
<p>
|
||||
<FlagOutlined /> {" "}
|
||||
{tr("Finished")}
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<p>
|
||||
<QuestionCircleOutlined /> {" "}
|
||||
{tr("Unknown status")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const QueueCard = (): JSX.Element => {
|
||||
const user = useSelector((state: StorePrototype) => state.auth.user);
|
||||
const messageApi = useContext(MessageContext);
|
||||
|
||||
const { queueId } = useParams();
|
||||
const { data, isFetching } = useGetQueueDetailQuery(queueId);
|
||||
const { data, isFetching, refetch, error } = useGetQueueDetailQuery(queueId, {
|
||||
skip: !queueId,
|
||||
});
|
||||
const user = useSelector((state: StorePrototype) => state.auth.user);
|
||||
const [kickFirstAction] = useKickFirstActionMutation();
|
||||
const [startQueueAction] = useStartQueueActionMutation();
|
||||
|
||||
const [qrShown, setQrShown] = useState(false);
|
||||
const [largeQr, setLargeQr] = useState(false);
|
||||
|
||||
const kickFirst = () => {
|
||||
if (queueId) {
|
||||
kickFirstAction(queueId)
|
||||
.unwrap()
|
||||
.then(() => refetch())
|
||||
.then(() => messageApi.success(tr("First user in queue kicked!")))
|
||||
.catch(() => messageApi.error(tr("Action error")));
|
||||
}
|
||||
};
|
||||
|
||||
const startQueue = () => {
|
||||
if (queueId) {
|
||||
startQueueAction(queueId)
|
||||
.unwrap()
|
||||
.then(() => refetch())
|
||||
.then(() => messageApi.success(tr("Queue has started!")))
|
||||
.catch(() => messageApi.error(tr("Action error")));
|
||||
}
|
||||
};
|
||||
|
||||
const getJoinLink = () => {
|
||||
if (data) {
|
||||
return baseClientUrl + `/queue/join/${data.id}`;
|
||||
}
|
||||
return baseClientUrl;
|
||||
};
|
||||
|
||||
const copyJoinLink = async () => {
|
||||
if (data) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getJoinLink());
|
||||
messageApi.success(tr("Copied!"));
|
||||
} catch (error) {
|
||||
messageApi.error(tr("Error occured!"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!error) {
|
||||
return (
|
||||
<div className="card">
|
||||
<Spin
|
||||
@ -38,9 +140,71 @@ const QueueCard = (): JSX.Element => {
|
||||
{" "}
|
||||
{data?.participants?.remaining} / {data?.participants?.total}
|
||||
</p>
|
||||
{data && getStatusText(data.status)}
|
||||
{data && user && user.id === data.owner_id && (
|
||||
<div style={{ display: "flex", flexFlow: "row wrap" }}>
|
||||
<Button onClick={kickFirst}>{tr("Kick first")}</Button>
|
||||
{data?.status === "created" && (
|
||||
<Button onClick={startQueue}>{tr("Start queue")}</Button>
|
||||
)}
|
||||
<Button onClick={copyJoinLink}>{tr("Copy join link")}</Button>
|
||||
{qrShown ? (
|
||||
<Button onClick={() => setQrShown(false)}>
|
||||
{tr("Hide QR-code")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setQrShown(true)}>
|
||||
{tr("Show QR-code")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{data && qrShown && (
|
||||
<div style={{ display: "flex", flexFlow: "row nowrap" }}>
|
||||
<QRCode
|
||||
errorLevel="H"
|
||||
value={getJoinLink()}
|
||||
icon={
|
||||
baseClientUrl + "/static/image/android-chrome-512x512.png"
|
||||
}
|
||||
size={largeQr ? 320 : 160}
|
||||
/>
|
||||
<Button
|
||||
icon={largeQr ? <ZoomOutOutlined /> : <ZoomInOutlined />}
|
||||
onClick={() => setLargeQr((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Title level={3} style={{ textAlign: "left" }}>
|
||||
{tr("Queue participants")}
|
||||
</Title>
|
||||
{data?.participants.users_list.map((v) => {
|
||||
return (
|
||||
<AnonUserCard
|
||||
key={v.id}
|
||||
queueUser={v}
|
||||
queue={data}
|
||||
refetch={refetch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%", marginTop: "3rem" }}>
|
||||
<QuestionCircleOutlined style={{ fontSize: "5rem" }} />
|
||||
</div>
|
||||
<Title>{tr("Queue not found")}</Title>
|
||||
<Title level={3}>404</Title>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default QueueCard;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useGetQueuesQuery } from "../../slice/QueueApi";
|
||||
import { useGetOwnedQueuesQuery } from "../../slice/QueueApi";
|
||||
import "../styles.css";
|
||||
import { Button, Spin } from "antd";
|
||||
import {
|
||||
@ -21,7 +21,7 @@ type Queue = {
|
||||
|
||||
const QueuesList = (): JSX.Element => {
|
||||
const user = useSelector((state: StorePrototype) => state.auth.user);
|
||||
const { data, refetch, isLoading } = useGetQueuesQuery({});
|
||||
const { data, refetch, isLoading } = useGetOwnedQueuesQuery({});
|
||||
useEffect(() => {
|
||||
user && refetch();
|
||||
}, [user]);
|
||||
|
||||
@ -101,3 +101,26 @@
|
||||
.queue-info > * {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.anon-circle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.anon-card {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
animation: 0.3s ease-out 0s 1 cardPopup;
|
||||
background: #001d39;
|
||||
margin-top: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
margin-left: 1rem;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
border: 2px solid #00d8a4;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
130
frontend/app/src/components/user/AnonUserCard.tsx
Normal file
130
frontend/app/src/components/user/AnonUserCard.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useContext } from "react";
|
||||
import { QueueUser } from "../../slice/AuthApi";
|
||||
import "../styles.css";
|
||||
import tr from "../../config/translation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../../config/store";
|
||||
import { Button } from "antd";
|
||||
import { QueueDetail, usePassQueueActionMutation } from "../../slice/QueueApi";
|
||||
import { MessageContext } from "../../App";
|
||||
|
||||
const UUIDToColor = (uuid: string): string => {
|
||||
return (
|
||||
"#" +
|
||||
uuid.split("-").reduce((store: string, v: string) => {
|
||||
return store + v.substring(0, 1);
|
||||
}, "") +
|
||||
uuid.substring(uuid.length - 1)
|
||||
);
|
||||
};
|
||||
|
||||
const getProfileText = (name: string): string => {
|
||||
return name.substring(0, 1);
|
||||
};
|
||||
|
||||
const AnonUserCard = (props: {
|
||||
queueUser: QueueUser;
|
||||
queue: QueueDetail | undefined;
|
||||
refetch: () => void;
|
||||
}): JSX.Element => {
|
||||
const messageApi = useContext(MessageContext);
|
||||
|
||||
const clientId = useSelector((state: StorePrototype) => state.auth.clientId);
|
||||
const [passAction] = usePassQueueActionMutation();
|
||||
|
||||
const passQueue = () => {
|
||||
if (props.queue) {
|
||||
passAction(props.queue.id)
|
||||
.unwrap()
|
||||
.then(props.refetch)
|
||||
.then(() => messageApi.success(tr("You left the queue")))
|
||||
.catch(() => messageApi.error(tr("Failed to left")));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="anon-card">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
flexFlow: "row wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: "1rem" }}>#{props.queueUser.position}</span>
|
||||
<div
|
||||
className="anon-circle"
|
||||
style={{ background: UUIDToColor(props.queueUser.user.id) }}
|
||||
>
|
||||
{props.queueUser.user.name
|
||||
? getProfileText(props.queueUser.user.name)
|
||||
: props.queueUser.id.substring(0, 2)}
|
||||
</div>
|
||||
<p color="white" style={{ marginLeft: "10px" }}>
|
||||
{props.queueUser.user.name
|
||||
? props.queueUser.user.name
|
||||
: tr("Anonymous") + " #" + props.queueUser.id.substring(0, 4)}
|
||||
</p>
|
||||
{props.queueUser.group_id && (
|
||||
<p color="white" style={{ marginLeft: "10px" }}>
|
||||
{tr("Group") +
|
||||
": " +
|
||||
props.queue?.groups.find(
|
||||
(ele) => ele.id === props.queueUser.group_id
|
||||
)?.name}
|
||||
</p>
|
||||
)}
|
||||
{props.queueUser && clientId === props.queueUser.user.id && (
|
||||
<span
|
||||
style={{
|
||||
background: "#00d8a4",
|
||||
marginLeft: "1rem",
|
||||
marginRight: "1rem",
|
||||
marginBottom: "0",
|
||||
borderRadius: "5px",
|
||||
padding: "2px",
|
||||
}}
|
||||
>
|
||||
{tr("YOU")}
|
||||
</span>
|
||||
)}
|
||||
{props.queueUser &&
|
||||
clientId === props.queueUser.user.id &&
|
||||
props.queueUser.position === 0 &&
|
||||
props.queue?.status === "active" && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "1rem",
|
||||
marginRight: "1rem",
|
||||
marginBottom: "0",
|
||||
borderRadius: "5px",
|
||||
padding: "2px",
|
||||
}}
|
||||
>
|
||||
{tr("It is your turn!")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
flexFlow: "row wrap",
|
||||
}}
|
||||
>
|
||||
{props.queueUser &&
|
||||
clientId === props.queueUser.user.id &&
|
||||
(props.queueUser.position === 0 &&
|
||||
props.queue?.status === "active" ? (
|
||||
<Button onClick={() => passQueue()}>{tr("Pass")}</Button>
|
||||
) : (
|
||||
<Button onClick={() => passQueue()}>{tr("Leave")}</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnonUserCard;
|
||||
24
frontend/app/src/config/ThemeProviderWrapper.tsx
Normal file
24
frontend/app/src/config/ThemeProviderWrapper.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "./store";
|
||||
import { ConfigProvider, Spin } from "antd";
|
||||
import { darkTheme, lightTheme } from "./style";
|
||||
import PropTypes from "prop-types";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
|
||||
const ThemeProviderWrapper = ({ children }: { children: ReactNode }) => {
|
||||
const theme = useSelector((state: StorePrototype) => state.settings.theme);
|
||||
Spin.setDefaultIndicator(<LoadingOutlined style={{ fontSize: 36 }} spin />);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={theme === "dark" ? darkTheme : lightTheme}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeProviderWrapper.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default ThemeProviderWrapper;
|
||||
@ -1 +1,2 @@
|
||||
export const baseUrl = `${window.location.protocol}//${window.location.host}/api`;
|
||||
export const baseClientUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
@ -11,20 +11,24 @@ import { NewsApi } from "../slice/NewsApi";
|
||||
|
||||
export type AuthDataType = {
|
||||
token: string | null;
|
||||
user: { name: string | null; username: string } | null;
|
||||
clientId: string | null;
|
||||
user: { id: string | null; name: string | null; username: string } | null;
|
||||
};
|
||||
|
||||
const initialAuthDataState: AuthDataType = {
|
||||
token: null,
|
||||
clientId: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
export type SettingsType = {
|
||||
language: string | undefined;
|
||||
theme: string | undefined;
|
||||
};
|
||||
|
||||
const initialSettingsState: SettingsType = {
|
||||
language: undefined,
|
||||
theme: undefined,
|
||||
};
|
||||
|
||||
export type StorePrototype = {
|
||||
@ -35,11 +39,15 @@ export type StorePrototype = {
|
||||
|
||||
export const updateToken = createAction<string>("auth/updateToken");
|
||||
export const getLocalToken = createAction("auth/getLocalToken");
|
||||
export const updateClient = createAction<string | null>("auth/updateClient");
|
||||
export const getLocalClient = createAction("auth/getLocalClient");
|
||||
export const updateUser = createAction<User>("auth/updateUser");
|
||||
export const logOut = createAction("auth/logOut");
|
||||
|
||||
export const setLanguage = createAction<string>("settings/setLanguage");
|
||||
export const loadLanguage = createAction("settings/loadLanguage");
|
||||
export const setTheme = createAction<string>("settings/setTheme");
|
||||
export const loadTheme = createAction("settings/loadTheme");
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@ -58,6 +66,20 @@ export const store = configureStore({
|
||||
state.token = token;
|
||||
}
|
||||
});
|
||||
builder.addCase(updateClient, (state, action) => {
|
||||
if (action.payload) {
|
||||
state.clientId = action.payload;
|
||||
localStorage.setItem("clientId", action.payload);
|
||||
} else {
|
||||
localStorage.removeItem("clientId");
|
||||
}
|
||||
});
|
||||
builder.addCase(getLocalClient, (state) => {
|
||||
const clientId: string | null = localStorage.getItem("clientId");
|
||||
if (clientId) {
|
||||
state.clientId = clientId;
|
||||
}
|
||||
});
|
||||
builder.addCase(updateUser, (state, action) => {
|
||||
state.user = action.payload;
|
||||
});
|
||||
@ -77,7 +99,24 @@ export const store = configureStore({
|
||||
if (language) {
|
||||
state.language = language;
|
||||
} else {
|
||||
state.language = "en";
|
||||
const clientLanguage = navigator.language.startsWith("en-")
|
||||
? "en"
|
||||
: "ru";
|
||||
state.language = clientLanguage;
|
||||
}
|
||||
});
|
||||
builder.addCase(setTheme, (state, action) => {
|
||||
state.theme = action.payload || "dark";
|
||||
localStorage.setItem("theme", action.payload || "dark");
|
||||
});
|
||||
builder.addCase(loadTheme, (state) => {
|
||||
const theme: string | null = localStorage.getItem("theme");
|
||||
if (theme) {
|
||||
state.theme = theme;
|
||||
} else {
|
||||
// const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); // TODO
|
||||
// state.theme = darkThemeMq.matches ? "dark" : "light";
|
||||
state.theme = "dark";
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ThemeConfig } from "antd";
|
||||
|
||||
export const theme: ThemeConfig = {
|
||||
export const darkTheme: ThemeConfig = {
|
||||
token: {
|
||||
colorText: "white",
|
||||
colorIcon: "white",
|
||||
@ -15,6 +15,20 @@ export const theme: ThemeConfig = {
|
||||
components: {
|
||||
Input: {
|
||||
activeBorderColor: "#001529",
|
||||
colorTextPlaceholder: "grey",
|
||||
},
|
||||
Select: {
|
||||
colorTextPlaceholder: "grey",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const lightTheme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: "#00d8a4",
|
||||
colorIconHover: "#00d8a4",
|
||||
borderRadius: 5,
|
||||
fontFamily: "Comfortaa",
|
||||
// colorWarningBg: "#001529",
|
||||
},
|
||||
};
|
||||
|
||||
@ -100,5 +100,143 @@
|
||||
},
|
||||
"BETA! Be aware about loosing ALL your data here, as well as unexpected service shutdowns, bugs and other beta's features :)": {
|
||||
"ru" : "БЕТА! Помните, что ВСЕ ваши данные здесь могут быть утеряны, сервер может прилечь на неизвестное количество времени, ну и про баги-фичи тоже не забывайте :)"
|
||||
},
|
||||
"Captcha": {
|
||||
"ru": "Капча"
|
||||
},
|
||||
"Captcha prompt": {
|
||||
"ru": "Ввод капчи"
|
||||
},
|
||||
"Please enter captcha!": {
|
||||
"ru": "Пожалуйста, введите капчу!"
|
||||
},
|
||||
"Fetch captcha": {
|
||||
"ru": "Загрузить капчу"
|
||||
},
|
||||
"Invalid captcha": {
|
||||
"ru": "Неверная капча"
|
||||
},
|
||||
"YOU": {
|
||||
"ru": "ВЫ"
|
||||
},
|
||||
"Current queues": {
|
||||
"ru": "Текущие очереди"
|
||||
},
|
||||
"Queues you are in": {
|
||||
"ru": "Очереди, в которых вы участвуете"
|
||||
},
|
||||
"Queue participants": {
|
||||
"ru": "Участники очереди"
|
||||
},
|
||||
"Anonymous": {
|
||||
"ru": "Аноним"
|
||||
},
|
||||
"Join": {
|
||||
"ru": "Присоединиться"
|
||||
},
|
||||
"QR-code scanner in development": {
|
||||
"ru": "Сканер QR-кодов в разработке"
|
||||
},
|
||||
"OR": {
|
||||
"ru": "ИЛИ"
|
||||
},
|
||||
"Join queue by link or id": {
|
||||
"ru": "Присоединиться к очереди по ссылке или айди"
|
||||
},
|
||||
"You are not parting in any queues!": {
|
||||
"ru": "Вы не принимаете участие ни в одной очереди!"
|
||||
},
|
||||
"Created": {
|
||||
"ru": "Создана"
|
||||
},
|
||||
"Waiting": {
|
||||
"ru": "Ожидает"
|
||||
},
|
||||
"In progress": {
|
||||
"ru": "В процессе"
|
||||
},
|
||||
"Finished": {
|
||||
"ru": "Завершена"
|
||||
},
|
||||
"Kick first": {
|
||||
"ru": "Кикнуть первого"
|
||||
},
|
||||
"It is your turn!": {
|
||||
"ru": "Сейчас ваша очередь!"
|
||||
},
|
||||
"Start queue": {
|
||||
"ru": "Начать очередь"
|
||||
},
|
||||
"Copy join link": {
|
||||
"ru": "Скопировать ссылку для вступления"
|
||||
},
|
||||
"Show QR-code": {
|
||||
"ru": "Показать QR-код"
|
||||
},
|
||||
"Hide QR-code": {
|
||||
"ru": "Спрятать QR-код"
|
||||
},
|
||||
"Copied!": {
|
||||
"ru": "Скопировано!"
|
||||
},
|
||||
"Pass": {
|
||||
"ru": "Пройти очередь"
|
||||
},
|
||||
"Leave": {
|
||||
"ru": "Покинуть очередь"
|
||||
},
|
||||
"Queue not found": {
|
||||
"ru": "Очередь не найдена"
|
||||
},
|
||||
"Update your name": {
|
||||
"ru": "Обновите свое имя"
|
||||
},
|
||||
"Enter new name": {
|
||||
"ru": "Введите новое имя"
|
||||
},
|
||||
"Update": {
|
||||
"ru": "Обновить"
|
||||
},
|
||||
"Successfully joined queue": {
|
||||
"ru": "Успешное присоединение к очереди"
|
||||
},
|
||||
"Successfully changed name": {
|
||||
"ru": "Успешное изменение имени"
|
||||
},
|
||||
"Select group in queue": {
|
||||
"ru": "Выберите группу в очереди"
|
||||
},
|
||||
"Select group...": {
|
||||
"ru": "Выберите группу..."
|
||||
},
|
||||
"Queue groups": {
|
||||
"ru": "Группы очереди"
|
||||
},
|
||||
"Queue's groups function allows you to define groups inside this particular queue. Groups have name and priority. Every participant selects theirs group for this queue, and their position is calculated by group's priority. Groups higher in the list will have higher priority.": {
|
||||
"ru": "Функция групп очереди позволяет вам определять группы внутри этой конкретной очереди. Группы имеют имя и приоритет. Каждый участник выбирает свою группу для этой очереди, и его позиция рассчитывается по приоритету группы. Группы, расположенные выше в списке, будут иметь более высокий приоритет."
|
||||
},
|
||||
"Group's name": {
|
||||
"ru": "Название группы"
|
||||
},
|
||||
"Group": {
|
||||
"ru": "Группа"
|
||||
},
|
||||
"Add group": {
|
||||
"ru": "Добавить группу"
|
||||
},
|
||||
"Default": {
|
||||
"ru": "Группа по умолчанию"
|
||||
},
|
||||
"You left the queue": {
|
||||
"ru": "Вы покинули очередь"
|
||||
},
|
||||
"Failed to left": {
|
||||
"ru": "Не удалось покинуть"
|
||||
},
|
||||
"First user in queue kicked": {
|
||||
"ru": "Первый участник очереди кикнут"
|
||||
},
|
||||
"Action error": {
|
||||
"ru": "Ошибка действия"
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import MainPage from "./MainPage";
|
||||
import { getLocalToken, loadLanguage, store } from "../config/store";
|
||||
import {
|
||||
getLocalClient,
|
||||
getLocalToken,
|
||||
loadLanguage,
|
||||
loadTheme,
|
||||
store,
|
||||
} from "../config/store";
|
||||
import DashboardPage from "./DashboardPage";
|
||||
import PropTypes from "prop-types";
|
||||
import NotFoundPage from "./NotFoundPage";
|
||||
@ -9,10 +15,15 @@ import NewQueuePage from "./NewQueuePage";
|
||||
import NewsPage from "./NewsPage";
|
||||
import CreateNewsPage from "./CreateNewsPage";
|
||||
import QueueCard from "../components/queue/QueueCard";
|
||||
import JoinQueuePage from "./JoinQueuePage";
|
||||
import ApproveQueueJoinPage from "./ApproveQueueJoinPage";
|
||||
import PartingQueuesPage from "./PartingQueuesPage";
|
||||
|
||||
const AppRoutes = ({ children }: { children: ReactNode }) => {
|
||||
store.dispatch(getLocalToken());
|
||||
store.dispatch(getLocalClient());
|
||||
store.dispatch(loadLanguage());
|
||||
store.dispatch(loadTheme());
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@ -20,7 +31,10 @@ const AppRoutes = ({ children }: { children: ReactNode }) => {
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/parting" element={<PartingQueuesPage />} />
|
||||
<Route path="/queue/:queueId" element={<QueueCard />} />
|
||||
<Route path="/queue/join" element={<JoinQueuePage />} />
|
||||
<Route path="/queue/join/:queueId" element={<ApproveQueueJoinPage />} />
|
||||
<Route path="/dashboard/new" element={<NewQueuePage />} />
|
||||
<Route path="/news" element={<NewsPage />} />
|
||||
<Route path="/news/new" element={<CreateNewsPage />} />
|
||||
|
||||
19
frontend/app/src/pages/ApproveQueueJoinPage.tsx
Normal file
19
frontend/app/src/pages/ApproveQueueJoinPage.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import "./styles.css";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../config/store";
|
||||
import NotFoundPage from "./NotFoundPage";
|
||||
import ApproveQueueJoinCard from "../components/queue/ApproveQueueJoinCard";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const ApproveQueueJoinPage = () => {
|
||||
const clientId = useSelector((state: StorePrototype) => state.auth.clientId);
|
||||
const { queueId } = useParams();
|
||||
return clientId && queueId ? (
|
||||
<ApproveQueueJoinCard id={queueId} />
|
||||
) : (
|
||||
<NotFoundPage />
|
||||
);
|
||||
};
|
||||
|
||||
export default ApproveQueueJoinPage;
|
||||
13
frontend/app/src/pages/JoinQueuePage.tsx
Normal file
13
frontend/app/src/pages/JoinQueuePage.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import "./styles.css";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../config/store";
|
||||
import NotFoundPage from "./NotFoundPage";
|
||||
import JoinQueueCard from "../components/queue/JoinQueueCard";
|
||||
|
||||
const JoinQueuePage = () => {
|
||||
const clientId = useSelector((state: StorePrototype) => state.auth.clientId);
|
||||
return clientId ? <JoinQueueCard /> : <NotFoundPage />;
|
||||
};
|
||||
|
||||
export default JoinQueuePage;
|
||||
@ -5,6 +5,7 @@ import { Alert, Button } from "antd";
|
||||
import tr from "../config/translation";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { WarningOutlined } from "@ant-design/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const MainPage = () => {
|
||||
return (
|
||||
@ -23,7 +24,9 @@ const MainPage = () => {
|
||||
style={{ marginBottom: "1rem" }}
|
||||
/>
|
||||
<div className="button-box">
|
||||
<Link to="/queue/join">
|
||||
<Button size="large">{tr("Join a queue")}</Button>
|
||||
</Link>
|
||||
{!(
|
||||
isMobile && window.screen.orientation.type === "portrait-primary"
|
||||
) && <div style={{ width: "3rem" }} />}
|
||||
|
||||
18
frontend/app/src/pages/PartingQueuesPage.tsx
Normal file
18
frontend/app/src/pages/PartingQueuesPage.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import "./styles.css";
|
||||
import { useSelector } from "react-redux";
|
||||
import { StorePrototype } from "../config/store";
|
||||
import tr from "../config/translation";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import PartingQueuesList from "../components/queue/PartingQueuesList";
|
||||
|
||||
const PartingQueuesPage = () => {
|
||||
const clientId = useSelector((state: StorePrototype) => state.auth.clientId);
|
||||
return clientId ? (
|
||||
<PartingQueuesList />
|
||||
) : (
|
||||
<Title level={2}>{tr("Whoops!")}</Title>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartingQueuesPage;
|
||||
@ -3,6 +3,7 @@ import { baseUrl } from "../config/baseUrl";
|
||||
import { RootState } from "../config/store";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}
|
||||
@ -24,6 +25,23 @@ export type TokenResponse = {
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
export type QueueUser = {
|
||||
id: string;
|
||||
position: number;
|
||||
passed: boolean;
|
||||
user: AnonUser;
|
||||
group_id: string | undefined;
|
||||
};
|
||||
|
||||
export type AnonUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type AnonUserPatch = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const AuthApi = createApi({
|
||||
reducerPath: "AuthApi",
|
||||
baseQuery: fetchBaseQuery({
|
||||
@ -34,6 +52,10 @@ export const AuthApi = createApi({
|
||||
if (token) {
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
}
|
||||
const clientID = (getState() as RootState).auth.clientId;
|
||||
if (clientID) {
|
||||
headers.set("X-Client-Id", clientID);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
@ -41,6 +63,9 @@ export const AuthApi = createApi({
|
||||
getUser: builder.query({
|
||||
query: () => "/me",
|
||||
}),
|
||||
getClient: builder.query({
|
||||
query: () => "/anon",
|
||||
}),
|
||||
login: builder.mutation({
|
||||
query: (data: FormData) => ({
|
||||
url: "/token",
|
||||
@ -56,8 +81,20 @@ export const AuthApi = createApi({
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
patchAnon: builder.mutation({
|
||||
query: (data: AnonUserPatch) => ({
|
||||
url: "/anon",
|
||||
method: "PATCH",
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetUserQuery, useLoginMutation, useRegisterMutation } =
|
||||
AuthApi;
|
||||
export const {
|
||||
useGetUserQuery,
|
||||
useGetClientQuery,
|
||||
useLoginMutation,
|
||||
useRegisterMutation,
|
||||
usePatchAnonMutation,
|
||||
} = AuthApi;
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
import { baseUrl } from "../config/baseUrl";
|
||||
import { RootState } from "../config/store";
|
||||
import { QueueUser } from "./AuthApi";
|
||||
|
||||
type OrderedGroup = {
|
||||
name: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type ResponseGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export type CreateQueueRequest = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
groups: OrderedGroup[];
|
||||
};
|
||||
|
||||
export type CreateQueue = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type Queue = {
|
||||
@ -13,6 +31,24 @@ export type Queue = {
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type QueueDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
owner_id: string;
|
||||
groups: ResponseGroup[];
|
||||
participants: {
|
||||
total: number;
|
||||
remaining: number;
|
||||
users_list: [QueueUser];
|
||||
};
|
||||
};
|
||||
|
||||
export type JoinRequest = {
|
||||
group_id: string | undefined;
|
||||
};
|
||||
|
||||
export const QueueApi = createApi({
|
||||
reducerPath: "QueueApi",
|
||||
baseQuery: fetchBaseQuery({
|
||||
@ -22,16 +58,30 @@ export const QueueApi = createApi({
|
||||
if (token) {
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
}
|
||||
const clientID = (getState() as RootState).auth.clientId;
|
||||
if (clientID) {
|
||||
headers.set("X-Client-Id", clientID);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
endpoints: (builder) => ({
|
||||
getQueues: builder.query({
|
||||
getQueues: builder.query<[Queue], unknown>({
|
||||
query: () => "/",
|
||||
}),
|
||||
getQueueDetail: builder.query({
|
||||
getOwnedQueues: builder.query<[Queue], unknown>({
|
||||
query: () => "/owned",
|
||||
}),
|
||||
getQueueDetail: builder.query<QueueDetail, string | undefined>({
|
||||
query: (queueId: string | undefined) => `/${queueId}`,
|
||||
}),
|
||||
joinQueue: builder.mutation({
|
||||
query: (args: { queueId: string; data: JoinRequest }) => ({
|
||||
url: `/${args.queueId}/join`,
|
||||
method: "POST",
|
||||
body: args.data,
|
||||
}),
|
||||
}),
|
||||
createQueue: builder.mutation({
|
||||
query: (data: CreateQueueRequest) => ({
|
||||
url: "/",
|
||||
@ -39,11 +89,34 @@ export const QueueApi = createApi({
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
passQueueAction: builder.mutation({
|
||||
query: (queueId: string) => ({
|
||||
url: `/${queueId}/action/pass`,
|
||||
method: "POST",
|
||||
}),
|
||||
}),
|
||||
kickFirstAction: builder.mutation({
|
||||
query: (queueId: string) => ({
|
||||
url: `/${queueId}/action/kick-first`,
|
||||
method: "POST",
|
||||
}),
|
||||
}),
|
||||
startQueueAction: builder.mutation({
|
||||
query: (queueId: string) => ({
|
||||
url: `/${queueId}/action/start`,
|
||||
method: "POST",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetQueuesQuery,
|
||||
useGetOwnedQueuesQuery,
|
||||
useGetQueueDetailQuery,
|
||||
useJoinQueueMutation,
|
||||
useCreateQueueMutation,
|
||||
usePassQueueActionMutation,
|
||||
useKickFirstActionMutation,
|
||||
useStartQueueActionMutation,
|
||||
} = QueueApi;
|
||||
|
||||
Reference in New Issue
Block a user