Compare commits

26 Commits

Author SHA1 Message Date
6e2d65b622 Update file docker-compose.yml 2025-04-26 13:57:54 +03:00
44ebf0a934 Update file docker-compose.yml 2025-01-04 14:25:35 +00:00
78dbd0944a tr update 2024-06-15 23:07:48 +03:00
c31b205154 tr update 2024-06-15 22:54:52 +03:00
7024328629 Merge branch 'master' of https://gitlab.com/ollyhearn/queueful 2024-06-15 22:37:29 +03:00
e51140583b groups functional & bugfixes 2024-06-15 22:36:45 +03:00
e9fa4f2ead upd captcha length 2024-06-13 07:03:39 +00:00
480d2fe141 upd 2024-06-12 16:06:30 +03:00
72ec735e9d css upd 2024-06-12 16:03:56 +03:00
2951a559bc upd 2024-06-12 15:54:56 +03:00
3fb38cac3a upd 2024-06-09 20:41:57 +03:00
175d2f9cd4 participantlist works! 2024-05-11 15:28:58 +03:00
0ebfd11851 redis & listening works!! 2024-05-10 15:11:25 +03:00
2a696f96c1 join queue 2024-04-22 00:48:39 +03:00
227f1c5782 fixes & spin global icon 2024-04-20 17:59:17 +03:00
f80431aba0 tr 2024-04-20 17:46:52 +03:00
03da5914fa captcha 2024-04-20 17:37:41 +03:00
ed0ecf9f51 work on light theme 2024-04-16 00:42:40 +03:00
3033d1f34b upd 2024-04-15 23:55:06 +03:00
d4e5d42f81 upd 2024-04-15 23:52:02 +03:00
b751983838 upd 2024-04-15 23:47:37 +03:00
31b1c977ed upd 2024-04-15 23:45:04 +03:00
aecce43357 upd net 2024-04-15 23:39:36 +03:00
d2dd2977df external postgres net 2024-04-15 23:20:23 +03:00
ffe45a821d clientid works 2024-04-14 20:28:21 +03:00
af608f5b26 favicon & appicon 2024-04-14 14:48:35 +03:00
42 changed files with 2075 additions and 134 deletions

View File

@ -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
View 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

View File

@ -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()

View File

@ -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"}

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!",
)

View File

@ -5,3 +5,5 @@ sqlalchemy
psycopg2-binary
python-jose[cryptography]
passlib[all]
captcha
redis[hiredis]

12
dev.yml
View File

@ -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

View File

@ -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
View File

@ -2,3 +2,5 @@ POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=db
DEBUG=1
REDIS_HOST=redis
REDIS_PORT=6379

View File

@ -1,3 +1,3 @@
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=db
POSTGRES_DB=queueful

View File

@ -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",

View File

@ -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",

View File

@ -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",
},

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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 (

View 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;

View File

@ -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 />}

View 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;

View 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;

View 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;

View File

@ -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;

View File

@ -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]);

View File

@ -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;
}

View 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;

View 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;

View File

@ -1 +1,2 @@
export const baseUrl = `${window.location.protocol}//${window.location.host}/api`;
export const baseClientUrl = `${window.location.protocol}//${window.location.host}`;

View File

@ -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";
}
});
}),

View File

@ -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",
},
};

View File

@ -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": "Ошибка действия"
}
}

View File

@ -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 />} />

View 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;

View 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;

View File

@ -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" }} />}

View 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;

View File

@ -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;

View File

@ -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;