upd
This commit is contained in:
@ -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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
import uuid
|
import uuid
|
||||||
@ -7,6 +7,21 @@ import datetime
|
|||||||
from .database import Base
|
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):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@ -47,8 +62,20 @@ class Queue(Base):
|
|||||||
description = Column(String, index=True)
|
description = Column(String, index=True)
|
||||||
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||||
start_time = Column(DateTime, nullable=True)
|
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")
|
users = relationship("QueueUser", backref="queue", lazy="dynamic")
|
||||||
|
logs = relationship("QueueLog", backref="queue", lazy="dynamic")
|
||||||
|
|
||||||
|
|
||||||
class QueueUser(Base):
|
class QueueUser(Base):
|
||||||
@ -66,6 +93,7 @@ class QueueLog(Base):
|
|||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
action = Column(String)
|
action = Column(String)
|
||||||
|
queue_id = Column(UUID(as_uuid=True), ForeignKey("queues.id"))
|
||||||
created = Column(DateTime, default=datetime.datetime.utcnow)
|
created = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -72,8 +72,10 @@ async def register(
|
|||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def read_users_me(
|
async def read_users_me(
|
||||||
current_user: Annotated[schemas.User, Depends(services.get_current_active_user)],
|
current_user: Annotated[
|
||||||
) -> schemas.User:
|
schemas.UserInDB, Depends(services.get_current_active_user)
|
||||||
|
],
|
||||||
|
) -> schemas.UserInDB:
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated, Union
|
from typing import Annotated, Union
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
import redis
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
@ -9,7 +10,8 @@ from fastapi.security import OAuth2PasswordRequestForm
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ...config import jwt_config
|
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 schemas
|
||||||
from . import services
|
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("/")
|
@router.get("/")
|
||||||
async def user_queues_list(
|
async def anonuser_queues_list(
|
||||||
queues: Annotated[schemas.Queue, Depends(services.get_user_queues)],
|
queues: Annotated[schemas.Queue, Depends(services.get_user_queues)],
|
||||||
) -> list[schemas.QueueInDb]:
|
) -> list[schemas.QueueInDb]:
|
||||||
return queues
|
return queues
|
||||||
@ -60,3 +69,10 @@ async def listen_queue(
|
|||||||
updated_queue: Annotated[schemas.QueueDetail, Depends(services.set_queue_listener)]
|
updated_queue: Annotated[schemas.QueueDetail, Depends(services.set_queue_listener)]
|
||||||
) -> schemas.QueueDetail:
|
) -> schemas.QueueDetail:
|
||||||
return updated_queue
|
return updated_queue
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{queue_id}/action/{action}")
|
||||||
|
async def perform_queue_action(
|
||||||
|
result: Annotated[schemas.ActionResult, Depends(services.action_wrapper)]
|
||||||
|
):
|
||||||
|
return result
|
||||||
|
|||||||
@ -41,4 +41,14 @@ class QueueInDb(Queue):
|
|||||||
|
|
||||||
class QueueDetail(Queue):
|
class QueueDetail(Queue):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
status: str
|
||||||
|
owner_id: UUID
|
||||||
participants: ParticipantInfo
|
participants: ParticipantInfo
|
||||||
|
|
||||||
|
|
||||||
|
class ActionResult(BaseModel):
|
||||||
|
action: str
|
||||||
|
result: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@ -14,24 +14,38 @@ from ..auth import schemas as auth_schemas
|
|||||||
from . import schemas
|
from . import schemas
|
||||||
|
|
||||||
|
|
||||||
def get_queue_by_id(queue_id: UUID, db: Session) -> models.Queue:
|
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()
|
q = db.query(models.Queue).filter(models.Queue.id == queue_id).first()
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
def get_user_queues(
|
def get_owned_queues(
|
||||||
current_user: Annotated[auth_schemas.User, Depends(auth_services.get_current_user)]
|
current_user: Annotated[auth_schemas.User, Depends(auth_services.get_current_user)]
|
||||||
) -> list[schemas.QueueInDb]:
|
) -> list[schemas.QueueInDb]:
|
||||||
return [schemas.QueueInDb.model_validate(q) for q in current_user.owns_queues]
|
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(
|
def create_queue(
|
||||||
new_queue: schemas.Queue,
|
new_queue: schemas.Queue,
|
||||||
current_user: auth_schemas.UserInDB,
|
current_user: auth_schemas.UserInDB,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> schemas.QueueInDb:
|
) -> schemas.QueueInDb:
|
||||||
q = models.Queue(
|
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.add(q)
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -48,6 +62,8 @@ def get_detailed_queue(
|
|||||||
id=q.id,
|
id=q.id,
|
||||||
name=q.name,
|
name=q.name,
|
||||||
description=q.description,
|
description=q.description,
|
||||||
|
status=q.status,
|
||||||
|
owner_id=q.owner_id,
|
||||||
participants=schemas.ParticipantInfo(
|
participants=schemas.ParticipantInfo(
|
||||||
total=q.users.count(),
|
total=q.users.count(),
|
||||||
remaining=q.users.filter(models.QueueUser.passed == False).count(),
|
remaining=q.users.filter(models.QueueUser.passed == False).count(),
|
||||||
@ -102,3 +118,132 @@ async def set_queue_listener(
|
|||||||
await ps.unsubscribe()
|
await ps.unsubscribe()
|
||||||
new_queue = get_detailed_queue(queue_id=queue_id, db=db)
|
new_queue = get_detailed_queue(queue_id=queue_id, db=db)
|
||||||
return new_queue
|
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)
|
||||||
|
],
|
||||||
|
) -> 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)],
|
||||||
|
):
|
||||||
|
for i, qu in enumerate(
|
||||||
|
queue.users.filter(models.QueueUser.passed == False).order_by(
|
||||||
|
models.QueueUser.position.asc()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
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!",
|
||||||
|
)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
@ -113,6 +114,11 @@ const HeaderComponent = () => {
|
|||||||
icon: <DesktopOutlined />,
|
icon: <DesktopOutlined />,
|
||||||
disabled: !user,
|
disabled: !user,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: <Link to="/parting">{tr("Current queues")}</Link>,
|
||||||
|
key: "parting",
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: <Link to="/news">{tr("News")}</Link>,
|
label: <Link to="/news">{tr("News")}</Link>,
|
||||||
key: "news",
|
key: "news",
|
||||||
@ -171,6 +177,11 @@ const HeaderComponent = () => {
|
|||||||
disabled: !user,
|
disabled: !user,
|
||||||
onClick: () => setDrawerOpen(false),
|
onClick: () => setDrawerOpen(false),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: <Link to="/parting">{tr("Current queues")}</Link>,
|
||||||
|
key: "parting",
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: <Link to="/news">{tr("News")}</Link>,
|
label: <Link to="/news">{tr("News")}</Link>,
|
||||||
key: "news",
|
key: "news",
|
||||||
|
|||||||
@ -31,11 +31,9 @@ const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => {
|
|||||||
const onJoinButtonClick = () => {
|
const onJoinButtonClick = () => {
|
||||||
joinQueue(props.id)
|
joinQueue(props.id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.then(() =>
|
|
||||||
messageApi.success(tr("Successfully joined queue ") + data.name)
|
|
||||||
)
|
|
||||||
.then(() => navigate(`/queue/${props.id}`))
|
.then(() => navigate(`/queue/${props.id}`))
|
||||||
.then(() => refetch())
|
.then(() => refetch())
|
||||||
|
.then(() => messageApi.success(tr("Successfully joined queue")))
|
||||||
.catch((e) => messageApi.error(tr(e.data.detail)));
|
.catch((e) => messageApi.error(tr(e.data.detail)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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,26 +1,99 @@
|
|||||||
import React from "react";
|
import React, { useContext } from "react";
|
||||||
import { useGetQueueDetailQuery } from "../../slice/QueueApi";
|
import {
|
||||||
|
useGetQueueDetailQuery,
|
||||||
|
useKickFirstActionMutation,
|
||||||
|
useStartQueueActionMutation,
|
||||||
|
} from "../../slice/QueueApi";
|
||||||
import "../styles.css";
|
import "../styles.css";
|
||||||
import { Button, Spin } from "antd";
|
import { Button, Spin } from "antd";
|
||||||
import {
|
import {
|
||||||
ArrowUpOutlined,
|
FieldTimeOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
|
FlagOutlined,
|
||||||
|
HourglassOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
|
PlusCircleOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import Title from "antd/es/typography/Title";
|
import Title from "antd/es/typography/Title";
|
||||||
import tr from "../../config/translation";
|
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 { useSelector } from "react-redux";
|
||||||
import { StorePrototype } from "../../config/store";
|
import { StorePrototype } from "../../config/store";
|
||||||
import AnonUserCard from "../user/AnonUserCard";
|
import { MessageContext } from "../../App";
|
||||||
|
|
||||||
|
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 QueueCard = (): JSX.Element => {
|
||||||
const user = useSelector((state: StorePrototype) => state.auth.user);
|
const messageApi = useContext(MessageContext);
|
||||||
|
|
||||||
const { queueId } = useParams();
|
const { queueId } = useParams();
|
||||||
const { data, isFetching } = useGetQueueDetailQuery(queueId, {
|
const { data, isFetching, refetch } = useGetQueueDetailQuery(queueId, {
|
||||||
skip: !queueId,
|
skip: !queueId,
|
||||||
});
|
});
|
||||||
|
const user = useSelector((state: StorePrototype) => state.auth.user);
|
||||||
|
const [kickFirstAction] = useKickFirstActionMutation();
|
||||||
|
const [startQueueAction] = useStartQueueActionMutation();
|
||||||
|
|
||||||
|
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")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@ -42,13 +115,22 @@ const QueueCard = (): JSX.Element => {
|
|||||||
{" "}
|
{" "}
|
||||||
{data?.participants?.remaining} / {data?.participants?.total}
|
{data?.participants?.remaining} / {data?.participants?.total}
|
||||||
</p>
|
</p>
|
||||||
|
{data && getStatusText(data.status)}
|
||||||
|
{user && user.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Title level={3} style={{ textAlign: "left" }}>
|
<Title level={3} style={{ textAlign: "left" }}>
|
||||||
{tr("Queue participants")}
|
{tr("Queue participants")}
|
||||||
</Title>
|
</Title>
|
||||||
{data?.participants.users_list.map((v) => {
|
{data?.participants.users_list.map((v) => {
|
||||||
return <AnonUserCard key={v.id} user={v} />;
|
return <AnonUserCard key={v.id} queueUser={v} queue={data} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useGetQueuesQuery } from "../../slice/QueueApi";
|
import { useGetOwnedQueuesQuery } from "../../slice/QueueApi";
|
||||||
import "../styles.css";
|
import "../styles.css";
|
||||||
import { Button, Spin } from "antd";
|
import { Button, Spin } from "antd";
|
||||||
import {
|
import {
|
||||||
@ -21,7 +21,7 @@ type Queue = {
|
|||||||
|
|
||||||
const QueuesList = (): JSX.Element => {
|
const QueuesList = (): JSX.Element => {
|
||||||
const user = useSelector((state: StorePrototype) => state.auth.user);
|
const user = useSelector((state: StorePrototype) => state.auth.user);
|
||||||
const { data, refetch, isLoading } = useGetQueuesQuery({});
|
const { data, refetch, isLoading } = useGetOwnedQueuesQuery({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
user && refetch();
|
user && refetch();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import React from "react";
|
import React, { useContext } from "react";
|
||||||
import { AnonUser } from "../../slice/AuthApi";
|
import { QueueUser } from "../../slice/AuthApi";
|
||||||
import "../styles.css";
|
import "../styles.css";
|
||||||
import tr from "../../config/translation";
|
import tr from "../../config/translation";
|
||||||
import Title from "antd/es/typography/Title";
|
import { useSelector } from "react-redux";
|
||||||
import Paragraph from "antd/es/typography/Paragraph";
|
import { StorePrototype } from "../../config/store";
|
||||||
|
import { Button } from "antd";
|
||||||
|
import { Queue, usePassQueueActionMutation } from "../../slice/QueueApi";
|
||||||
|
import { MessageContext } from "../../App";
|
||||||
|
|
||||||
const UUIDToColor = (uuid: string): string => {
|
const UUIDToColor = (uuid: string): string => {
|
||||||
return (
|
return (
|
||||||
@ -20,24 +23,74 @@ const getProfileText = (name: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AnonUserCard = (props: {
|
const AnonUserCard = (props: {
|
||||||
user: AnonUser;
|
queueUser: QueueUser;
|
||||||
backlight?: "self" | "active" | undefined;
|
queue: Queue | undefined;
|
||||||
}): JSX.Element => {
|
}): 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(() => messageApi.success(tr("You passed")))
|
||||||
|
.catch(() => messageApi.error(tr("Failed to pass")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="anon-card">
|
<div className="anon-card">
|
||||||
|
<span style={{ marginRight: "1rem" }}>#{props.queueUser.position}</span>
|
||||||
<div
|
<div
|
||||||
className="anon-circle"
|
className="anon-circle"
|
||||||
style={{ background: UUIDToColor(props.user.id) }}
|
style={{ background: UUIDToColor(props.queueUser.user.id) }}
|
||||||
>
|
>
|
||||||
{props.user.name
|
{props.queueUser.user.name
|
||||||
? getProfileText(props.user.name)
|
? getProfileText(props.queueUser.user.name)
|
||||||
: props.user.id.substring(0, 2)}
|
: props.queueUser.id.substring(0, 2)}
|
||||||
</div>
|
</div>
|
||||||
<p color="white" style={{ marginLeft: "10px" }}>
|
<p color="white" style={{ marginLeft: "10px" }}>
|
||||||
{props.user.name
|
{props.queueUser.user.name
|
||||||
? props.user.name
|
? props.queueUser.user.name
|
||||||
: tr("Anonymous") + " #" + props.user.id.substring(0, 4)}
|
: tr("Anonymous") + " #" + props.queueUser.id.substring(0, 4)}
|
||||||
</p>
|
</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 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "1rem",
|
||||||
|
marginRight: "1rem",
|
||||||
|
marginBottom: "0",
|
||||||
|
borderRadius: "5px",
|
||||||
|
padding: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr("It is your turn!")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{props.queueUser &&
|
||||||
|
clientId === props.queueUser.user.id &&
|
||||||
|
props.queueUser.position === 0 && (
|
||||||
|
<Button onClick={() => passQueue()}>{tr("Pass")}</Button>
|
||||||
|
)}
|
||||||
|
<p></p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { NewsApi } from "../slice/NewsApi";
|
|||||||
export type AuthDataType = {
|
export type AuthDataType = {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
user: { name: string | null; username: string } | null;
|
user: { id: string | null; name: string | null; username: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialAuthDataState: AuthDataType = {
|
const initialAuthDataState: AuthDataType = {
|
||||||
|
|||||||
@ -115,5 +115,56 @@
|
|||||||
},
|
},
|
||||||
"Invalid captcha": {
|
"Invalid captcha": {
|
||||||
"ru": "Неверная капча"
|
"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": "Сейчас ваша очередь!"
|
||||||
|
},
|
||||||
|
"Pass": {
|
||||||
|
"ru": "Пройти"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,6 +17,7 @@ import CreateNewsPage from "./CreateNewsPage";
|
|||||||
import QueueCard from "../components/queue/QueueCard";
|
import QueueCard from "../components/queue/QueueCard";
|
||||||
import JoinQueuePage from "./JoinQueuePage";
|
import JoinQueuePage from "./JoinQueuePage";
|
||||||
import ApproveQueueJoinPage from "./ApproveQueueJoinPage";
|
import ApproveQueueJoinPage from "./ApproveQueueJoinPage";
|
||||||
|
import PartingQueuesPage from "./PartingQueuesPage";
|
||||||
|
|
||||||
const AppRoutes = ({ children }: { children: ReactNode }) => {
|
const AppRoutes = ({ children }: { children: ReactNode }) => {
|
||||||
store.dispatch(getLocalToken());
|
store.dispatch(getLocalToken());
|
||||||
@ -30,6 +31,7 @@ const AppRoutes = ({ children }: { children: ReactNode }) => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainPage />} />
|
<Route path="/" element={<MainPage />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/parting" element={<PartingQueuesPage />} />
|
||||||
<Route path="/queue/:queueId" element={<QueueCard />} />
|
<Route path="/queue/:queueId" element={<QueueCard />} />
|
||||||
<Route path="/queue/join" element={<JoinQueuePage />} />
|
<Route path="/queue/join" element={<JoinQueuePage />} />
|
||||||
<Route path="/queue/join/:queueId" element={<ApproveQueueJoinPage />} />
|
<Route path="/queue/join/:queueId" element={<ApproveQueueJoinPage />} />
|
||||||
|
|||||||
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";
|
import { RootState } from "../config/store";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
@ -24,6 +25,13 @@ export type TokenResponse = {
|
|||||||
token_type: string;
|
token_type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueueUser = {
|
||||||
|
id: string;
|
||||||
|
position: number;
|
||||||
|
passed: boolean;
|
||||||
|
user: AnonUser;
|
||||||
|
};
|
||||||
|
|
||||||
export type AnonUser = {
|
export type AnonUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||||
import { baseUrl } from "../config/baseUrl";
|
import { baseUrl } from "../config/baseUrl";
|
||||||
import { RootState } from "../config/store";
|
import { RootState } from "../config/store";
|
||||||
import { AnonUser } from "./AuthApi";
|
import { QueueUser } from "./AuthApi";
|
||||||
|
|
||||||
export type CreateQueueRequest = {
|
export type CreateQueueRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -18,10 +18,11 @@ export type QueueDetail = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
status: string;
|
||||||
participants: {
|
participants: {
|
||||||
total: number;
|
total: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
users_list: [AnonUser];
|
users_list: [QueueUser];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -42,9 +43,12 @@ export const QueueApi = createApi({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
getQueues: builder.query<[Queue], undefined>({
|
getQueues: builder.query<[Queue], unknown>({
|
||||||
query: () => "/",
|
query: () => "/",
|
||||||
}),
|
}),
|
||||||
|
getOwnedQueues: builder.query<[Queue], unknown>({
|
||||||
|
query: () => "/owned",
|
||||||
|
}),
|
||||||
getQueueDetail: builder.query<QueueDetail, string | undefined>({
|
getQueueDetail: builder.query<QueueDetail, string | undefined>({
|
||||||
query: (queueId: string | undefined) => `/${queueId}`,
|
query: (queueId: string | undefined) => `/${queueId}`,
|
||||||
}),
|
}),
|
||||||
@ -58,12 +62,34 @@ export const QueueApi = createApi({
|
|||||||
body: data,
|
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 {
|
export const {
|
||||||
useGetQueuesQuery,
|
useGetQueuesQuery,
|
||||||
|
useGetOwnedQueuesQuery,
|
||||||
useGetQueueDetailQuery,
|
useGetQueueDetailQuery,
|
||||||
useJoinQueueMutation,
|
useJoinQueueMutation,
|
||||||
useCreateQueueMutation,
|
useCreateQueueMutation,
|
||||||
|
usePassQueueActionMutation,
|
||||||
|
useKickFirstActionMutation,
|
||||||
|
useStartQueueActionMutation,
|
||||||
} = QueueApi;
|
} = QueueApi;
|
||||||
|
|||||||
Reference in New Issue
Block a user