diff --git a/backend/app/views/auth/api.py b/backend/app/views/auth/api.py index 22cd634..bd0e893 100644 --- a/backend/app/views/auth/api.py +++ b/backend/app/views/auth/api.py @@ -86,6 +86,13 @@ async def get_anon_user( 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": {}}}}, diff --git a/backend/app/views/auth/schemas.py b/backend/app/views/auth/schemas.py index 98d2cae..826ef08 100644 --- a/backend/app/views/auth/schemas.py +++ b/backend/app/views/auth/schemas.py @@ -48,3 +48,7 @@ class AnonUser(BaseModel): class Config: from_attributes = True + + +class AnonUserPatch(BaseModel): + name: str diff --git a/backend/app/views/auth/services.py b/backend/app/views/auth/services.py index 92a81a5..112d7e6 100644 --- a/backend/app/views/auth/services.py +++ b/backend/app/views/auth/services.py @@ -147,6 +147,27 @@ def get_anon_user( 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: diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index 5dc3000..4596ed6 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -1259,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" @@ -2045,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" }, @@ -2661,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" } @@ -4527,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" }, diff --git a/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx b/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx index 13c8822..905c612 100644 --- a/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx +++ b/frontend/app/src/components/queue/ApproveQueueJoinCard.tsx @@ -1,6 +1,6 @@ -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; import "../styles.css"; -import { Button, Spin } from "antd"; +import { Button, Input, Spin } from "antd"; import { ArrowLeftOutlined, FileTextOutlined, @@ -15,6 +15,7 @@ import { useJoinQueueMutation, } from "../../slice/QueueApi"; import { MessageContext } from "../../App"; +import { usePatchAnonMutation } from "../../slice/AuthApi"; const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => { const navigate = useNavigate(); @@ -27,6 +28,8 @@ const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => { } ); const [joinQueue, { isLoading }] = useJoinQueueMutation(); + const [patchAnon] = usePatchAnonMutation(); + const [newName, setNewName] = useState(""); const onJoinButtonClick = () => { joinQueue(props.id) @@ -37,6 +40,13 @@ const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => { .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 (
@@ -67,7 +77,23 @@ const ApproveQueueJoinCard = (props: { id: string }): JSX.Element => { {data?.participants?.remaining} / {data?.participants?.total}

- +
+ diff --git a/frontend/app/src/components/queue/QueueCard.tsx b/frontend/app/src/components/queue/QueueCard.tsx index 58a14ba..f50fee0 100644 --- a/frontend/app/src/components/queue/QueueCard.tsx +++ b/frontend/app/src/components/queue/QueueCard.tsx @@ -1,11 +1,11 @@ -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; import { useGetQueueDetailQuery, useKickFirstActionMutation, useStartQueueActionMutation, } from "../../slice/QueueApi"; import "../styles.css"; -import { Button, Spin } from "antd"; +import { Button, QRCode, Spin } from "antd"; import { FieldTimeOutlined, FileTextOutlined, @@ -15,6 +15,8 @@ import { PlusCircleOutlined, QuestionCircleOutlined, UserOutlined, + ZoomInOutlined, + ZoomOutOutlined, } from "@ant-design/icons"; import Title from "antd/es/typography/Title"; import tr from "../../config/translation"; @@ -23,6 +25,7 @@ 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) { @@ -68,13 +71,16 @@ const QueueCard = (): JSX.Element => { const messageApi = useContext(MessageContext); const { queueId } = useParams(); - const { data, isFetching, refetch } = 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) @@ -95,46 +101,103 @@ const QueueCard = (): JSX.Element => { } }; - return ( -
- } - spinning={isFetching} - > -
- - {data?.name} - -

- - {" "} - {data?.description} -

-

- - {" "} - {data?.participants?.remaining} / {data?.participants?.total} -

- {data && getStatusText(data.status)} - {user && user.id && ( -
- - {data?.status === "created" && ( - - )} + 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 ( +
+ } + spinning={isFetching} + > +
+ + {data?.name} + +

+ + {" "} + {data?.description} +

+

+ + {" "} + {data?.participants?.remaining} / {data?.participants?.total} +

+ {data && getStatusText(data.status)} + {data && user && user.id === data.owner_id && ( +
+ + {data?.status === "created" && ( + + )} + + {qrShown ? ( + + ) : ( + + )} +
+ )} +
+ {data && qrShown && ( +
+ +
)} -
-
- - {tr("Queue participants")} - - {data?.participants.users_list.map((v) => { - return ; - })} -
- -
+ +
+ + {tr("Queue participants")} + + {data?.participants.users_list.map((v) => { + return ; + })} +
+ +
+ ); + } + + return ( + <> +
+ +
+ {tr("Queue not found")} + 404 + ); }; export default QueueCard; diff --git a/frontend/app/src/components/user/AnonUserCard.tsx b/frontend/app/src/components/user/AnonUserCard.tsx index 1f0389d..4b94f14 100644 --- a/frontend/app/src/components/user/AnonUserCard.tsx +++ b/frontend/app/src/components/user/AnonUserCard.tsx @@ -5,7 +5,7 @@ import tr from "../../config/translation"; import { useSelector } from "react-redux"; import { StorePrototype } from "../../config/store"; import { Button } from "antd"; -import { Queue, usePassQueueActionMutation } from "../../slice/QueueApi"; +import { QueueDetail, usePassQueueActionMutation } from "../../slice/QueueApi"; import { MessageContext } from "../../App"; const UUIDToColor = (uuid: string): string => { @@ -24,7 +24,7 @@ const getProfileText = (name: string): string => { const AnonUserCard = (props: { queueUser: QueueUser; - queue: Queue | undefined; + queue: QueueDetail | undefined; }): JSX.Element => { const messageApi = useContext(MessageContext); @@ -72,7 +72,8 @@ const AnonUserCard = (props: { )} {props.queueUser && clientId === props.queueUser.user.id && - props.queueUser.position === 0 && ( + props.queueUser.position === 0 && + props.queue?.status === "active" && ( )} {props.queueUser && - clientId === props.queueUser.user.id && - props.queueUser.position === 0 && ( - - )} + clientId === props.queueUser.user.id && + props.queueUser.position === 0 && + props.queue?.status === "active" ? ( + + ) : ( + + )}

); diff --git a/frontend/app/src/config/baseUrl.ts b/frontend/app/src/config/baseUrl.ts index 78bd332..0604e5e 100644 --- a/frontend/app/src/config/baseUrl.ts +++ b/frontend/app/src/config/baseUrl.ts @@ -1 +1,2 @@ export const baseUrl = `${window.location.protocol}//${window.location.host}/api`; +export const baseClientUrl = `${window.location.protocol}//${window.location.host}`; diff --git a/frontend/app/src/config/store.ts b/frontend/app/src/config/store.ts index ef37368..6afacc2 100644 --- a/frontend/app/src/config/store.ts +++ b/frontend/app/src/config/store.ts @@ -114,8 +114,9 @@ export const store = configureStore({ if (theme) { state.theme = theme; } else { - const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); - state.theme = darkThemeMq.matches ? "dark" : "light"; + // const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); // TODO + // state.theme = darkThemeMq.matches ? "dark" : "light"; + state.theme = "dark"; } }); }), diff --git a/frontend/app/src/config/style.ts b/frontend/app/src/config/style.ts index a46e01a..5b240bb 100644 --- a/frontend/app/src/config/style.ts +++ b/frontend/app/src/config/style.ts @@ -15,6 +15,7 @@ export const darkTheme: ThemeConfig = { components: { Input: { activeBorderColor: "#001529", + colorTextPlaceholder: "grey", }, }, }; diff --git a/frontend/app/src/config/translationMap.json b/frontend/app/src/config/translationMap.json index e91d10f..74d7a94 100644 --- a/frontend/app/src/config/translationMap.json +++ b/frontend/app/src/config/translationMap.json @@ -164,7 +164,46 @@ "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": "Пройти" + "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": "Успешное изменение имени" + }, + "Already joined": { + "ru": "Вы уже присоединились к этой очереди" } } diff --git a/frontend/app/src/slice/AuthApi.ts b/frontend/app/src/slice/AuthApi.ts index 99f0182..cd4d488 100644 --- a/frontend/app/src/slice/AuthApi.ts +++ b/frontend/app/src/slice/AuthApi.ts @@ -37,6 +37,10 @@ export type AnonUser = { name: string; }; +export type AnonUserPatch = { + name: string; +}; + export const AuthApi = createApi({ reducerPath: "AuthApi", baseQuery: fetchBaseQuery({ @@ -76,6 +80,13 @@ export const AuthApi = createApi({ body: data, }), }), + patchAnon: builder.mutation({ + query: (data: AnonUserPatch) => ({ + url: "/anon", + method: "PATCH", + body: data, + }), + }), }), }); @@ -84,4 +95,5 @@ export const { useGetClientQuery, useLoginMutation, useRegisterMutation, + usePatchAnonMutation, } = AuthApi; diff --git a/frontend/app/src/slice/QueueApi.ts b/frontend/app/src/slice/QueueApi.ts index 33256be..75fd0a6 100644 --- a/frontend/app/src/slice/QueueApi.ts +++ b/frontend/app/src/slice/QueueApi.ts @@ -19,6 +19,7 @@ export type QueueDetail = { name: string; description: string | null; status: string; + owner_id: string; participants: { total: number; remaining: number;