diff --git a/.env b/.env index 331448f..e57bb46 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ REACT_APP_BACKEND_URL=https://localhost:5001/api REACT_APP_TEMPVAL=tempval -REACT_APP_API_URL=https://localhost:5001/api \ No newline at end of file +REACT_APP_API_URL=https://localhost:5001/api + +REACT_APP_GOOGLE_CLIENT_ID= \ No newline at end of file diff --git a/README.md b/README.md index 8faddbe..caa6458 100644 --- a/README.md +++ b/README.md @@ -209,3 +209,11 @@ Visit our *site*, we wil - **[MIT license](http://opensource.org/licenses/mit-license.php)** - Copyright 2022 © SoftServe IT Academy. + + + +## . Create .env file in repository root example + +```env +GoogleAuth__ClientId=123721973387-7i3rs06c8iui5lrb805f22o0k2s6gk1o.apps.googleusercontent.com +``` diff --git a/config/webpack.dev.js b/config/webpack.dev.js index 857c6e3..7cc7e88 100644 --- a/config/webpack.dev.js +++ b/config/webpack.dev.js @@ -9,6 +9,13 @@ module.exports = { open: true, port: "3000", historyApiFallback: true, + client: { + // Показывать оверлеем только ошибки, без потока предупреждений. + overlay: { + errors: true, + warnings: false, + }, + }, }, module: { rules: require('./webpack.rules'), diff --git a/config/webpack.rules.js b/config/webpack.rules.js index ff6425d..99ce71c 100644 --- a/config/webpack.rules.js +++ b/config/webpack.rules.js @@ -34,7 +34,22 @@ module.exports = [ use: [ { loader: 'style-loader' }, { loader: 'css-loader' }, - { loader: 'sass-loader' }, + { + loader: 'sass-loader', + options: { + // Заглушаем deprecation-предупреждения Dart Sass, которые + // флудят dev-сервер (по одному на каждый .scss-файл). + sassOptions: { + quietDeps: true, // тихо для зависимостей (node_modules, swiper) + silenceDeprecations: [ + 'legacy-js-api', + 'import', + 'global-builtin', + 'color-functions', + ], + }, + }, + }, ], } ].filter(Boolean); \ No newline at end of file diff --git a/package.json b/package.json index 2f8dbfb..0e944be 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ "private": true, "dependencies": { "@ant-design/icons": "^4.8.0", - "@dnd-kit/core": "^6.0.8", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^7.0.2", - "@dnd-kit/utilities": "^3.2.1", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-free": "^6.4.0", "@react-google-maps/api": "^2.18.1", "@react-hook/resize-observer": "^1.2.6", + "@react-oauth/google": "^0.13.5", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", diff --git a/src/app/api/agent.api.ts b/src/app/api/agent.api.ts index 290b0f9..0092171 100644 --- a/src/app/api/agent.api.ts +++ b/src/app/api/agent.api.ts @@ -37,25 +37,25 @@ axios.interceptors.response.use( errorMessage = message; } switch (response?.status) { - case StatusCodes.INTERNAL_SERVER_ERROR: - errorMessage = ReasonPhrases.INTERNAL_SERVER_ERROR; - break; - case StatusCodes.UNAUTHORIZED: - errorMessage = ReasonPhrases.UNAUTHORIZED; - UserLoginStore.clearUserData(); - globalThis.location.href = FRONTEND_ROUTES.ADMIN.LOGIN; - break; - case StatusCodes.NOT_FOUND: - errorMessage = ReasonPhrases.NOT_FOUND; - break; - case StatusCodes.BAD_REQUEST: - errorMessage = getErrorMessage(response?.data) || ReasonPhrases.BAD_REQUEST; - break; - case StatusCodes.FORBIDDEN: - errorMessage = ReasonPhrases.FORBIDDEN; - break; - default: - break; + case StatusCodes.INTERNAL_SERVER_ERROR: + errorMessage = ReasonPhrases.INTERNAL_SERVER_ERROR; + break; + case StatusCodes.UNAUTHORIZED: + errorMessage = ReasonPhrases.UNAUTHORIZED; + UserLoginStore.clearUserData(); + globalThis.location.href = FRONTEND_ROUTES.ADMIN.LOGIN; + break; + case StatusCodes.NOT_FOUND: + errorMessage = ReasonPhrases.NOT_FOUND; + break; + case StatusCodes.BAD_REQUEST: + errorMessage = getErrorMessage(response?.data) || ReasonPhrases.BAD_REQUEST; + break; + case StatusCodes.FORBIDDEN: + errorMessage = ReasonPhrases.FORBIDDEN; + break; + default: + break; } if (errorMessage !== '' && process.env.NODE_ENV === 'development') { toast.error(errorMessage); @@ -65,22 +65,22 @@ axios.interceptors.response.use( }, ); -const responseBody = (response: AxiosResponse) => response.data; +const responseBody = (response: AxiosResponse) => response.data; const Agent = { - get: async (url: string, params?: URLSearchParams) => { + get: async (url: string, params?: URLSearchParams) => { axios.defaults.headers.common.Authorization = `Bearer ${UserLoginStore.getToken()}`; return axios.get(url, { params }) .then(responseBody); }, - post: async (url: string, body: object, headers?: object) => { + post: async (url: string, body: object, headers?: object) => { axios.defaults.headers.common.Authorization = `Bearer ${UserLoginStore.getToken()}`; return axios.post(url, body, headers) .then(responseBody); }, - put: async (url: string, body: object) => { + put: async (url: string, body: object) => { axios.defaults.headers.common.Authorization = `Bearer ${UserLoginStore.getToken()}`; return axios.put(url, body) .then(responseBody); diff --git a/src/app/api/media/art-slide-templates.api.ts b/src/app/api/media/art-slide-templates.api.ts new file mode 100644 index 0000000..72046ee --- /dev/null +++ b/src/app/api/media/art-slide-templates.api.ts @@ -0,0 +1,9 @@ +import Agent from '@api/agent.api'; +import { API_ROUTES } from '@constants/api-routes.constants'; +import { ArtSlideTemplate } from '@models/media/art-slide-template.model'; + +const ArtSlideTemplatesApi = { + getAll: () => Agent.get(API_ROUTES.ART_SLIDE_TEMPLATES.GET_ALL), +}; + +export default ArtSlideTemplatesApi; \ No newline at end of file diff --git a/src/app/api/media/art-slide.api.ts b/src/app/api/media/art-slide.api.ts new file mode 100644 index 0000000..14ff78c --- /dev/null +++ b/src/app/api/media/art-slide.api.ts @@ -0,0 +1,22 @@ +import Agent from '@api/agent.api'; +import { API_ROUTES } from '@constants/api-routes.constants'; +import { ArtSlide, CreateArtSlide, UpdateArtSlide } from '@models/media/art-slide.model'; + +const ArtSlidesApi = { + getAllByStreetcodeId: (streetcodeId: number) => + Agent.get(`${API_ROUTES.ART_SLIDES.GET_BY_STREETCODE_ID}/${streetcodeId}`), + + create: (artSlide: CreateArtSlide) => + Agent.post(`${API_ROUTES.ART_SLIDES.CREATE}`, artSlide), + + createAll: (artSlides: CreateArtSlide[]) => + Agent.post(`${API_ROUTES.ART_SLIDES.CREATE_ALL}`, artSlides), + + update: (artSlide: UpdateArtSlide) => + Agent.put(`${API_ROUTES.ART_SLIDES.UPDATE}`, artSlide), + + delete: (id: number) => + Agent.delete(`${API_ROUTES.ART_SLIDES.DELETE}/${id}`), +}; + +export default ArtSlidesApi; \ No newline at end of file diff --git a/src/app/api/media/arts.api.ts b/src/app/api/media/arts.api.ts index 46c3296..bcd913c 100644 --- a/src/app/api/media/arts.api.ts +++ b/src/app/api/media/arts.api.ts @@ -7,9 +7,10 @@ const ArtsApi = { getById: (id: number) => Agent.get(`${API_ROUTES.ARTS.GET}/${id}`), - create: (art: Art) => Agent.post(`${API_ROUTES.ARTS.CREATE}`, art), + create: (data: FormData) => Agent.post(`${API_ROUTES.ARTS.CREATE}`, data), - update: (art: Art) => Agent.post(`${API_ROUTES.ARTS.UPDATE}`, art), + update: (id: number, data: { title: string; description: string }) => + Agent.put(`${API_ROUTES.ARTS.UPDATE}/${id}`, data), delete: (id: number) => Agent.delete(`${API_ROUTES.ARTS.DELETE}/${id}`), }; diff --git a/src/app/api/user/user.api.ts b/src/app/api/user/user.api.ts index aba34d5..a9a34e4 100644 --- a/src/app/api/user/user.api.ts +++ b/src/app/api/user/user.api.ts @@ -25,6 +25,12 @@ import { loginParams, ), + googleLogin: (body: { idToken: string }) => + Agent.post( + API_ROUTES.ADMIN_AUTHORIZATION.GOOGLE_LOGIN, + body, + ), + adminRefreshToken: (token: RefreshTokenRequest) => Agent.post( API_ROUTES.ADMIN_AUTHORIZATION.REFRESH_TOKEN, diff --git a/src/app/common/components/ImageTemplates-grid/TemplateRenderer.tsx b/src/app/common/components/ImageTemplates-grid/TemplateRenderer.tsx new file mode 100644 index 0000000..07e5f73 --- /dev/null +++ b/src/app/common/components/ImageTemplates-grid/TemplateRenderer.tsx @@ -0,0 +1,25 @@ +import { observer } from 'mobx-react-lite'; +import { TEMPLATE_CLASS_MAP } from '@constants/template.map'; +import './shared-grid.styles.scss'; + +export const TemplateRenderer = observer(({ template, renderSlot, className }: any) => { + + const config = TEMPLATE_CLASS_MAP[template?.name]; + + const slots = config?.slots ?? []; + const gap = config?.gap ?? 5; + const templateClass = config?.className || 'template-default'; + + return ( +
+ {slots.map((slot: any) => ( +
+ {renderSlot(slot)} +
+ ))} +
+ ); +}); diff --git a/src/app/common/components/ImageTemplates-grid/shared-grid.styles.scss b/src/app/common/components/ImageTemplates-grid/shared-grid.styles.scss new file mode 100644 index 0000000..112071c --- /dev/null +++ b/src/app/common/components/ImageTemplates-grid/shared-grid.styles.scss @@ -0,0 +1,97 @@ +.preview-slot { + background: #000; + border-radius: 4px; + overflow: hidden; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + object-fit: cover; + object-position: center; + display: block; + } +} + + +.preview-grid { + position: relative; + display: grid; + gap: 5px; + + // 1-2 слотф + &.template-single-large { grid-template-columns: 255px; grid-template-rows: 205px; grid-template-areas: "slot-0"; } + &.template-single-medium { grid-template-columns: 137px; grid-template-rows: 203px; grid-template-areas: "slot-0"; } + &.template-two-horizontal { grid-template-columns: 137px 137px; grid-template-rows: 203px; grid-template-areas: "slot-0 slot-1"; } + + // 3 слота + &.template-three-mixed { + grid-template-columns: 137px 137px; + grid-template-rows: 203px 100px; + grid-template-areas: "slot-0 slot-1" "slot-2 slot-2"; + } + + // 4 слота + &.template-four-grid { grid-template-columns: repeat(2, 135px); grid-template-rows: repeat(2, 101px); grid-template-areas: "slot-0 slot-1" "slot-2 slot-3"; } + &.template-four-sidebar-left { + grid-template-columns: 135px 137px; grid-template-rows: 101px 101px; + grid-template-areas: "slot-0 slot-1" "slot-0 slot-2"; + } + &.template-four-sidebar-right { + grid-template-columns: 137px 135px; grid-template-rows: 101px 101px; + grid-template-areas: "slot-1 slot-0" "slot-2 slot-0"; + } + + // 5 слотів + &.template-five-mixed { grid-template-columns: 137px 65px 65px; grid-template-rows: 101px 101px; grid-template-areas: "slot-0 slot-1 slot-2" "slot-0 slot-3 slot-4"; } + &.template-five-grid { grid-template-columns: repeat(3, 85px); grid-template-rows: repeat(2, 101px); grid-template-areas: "slot-0 slot-1 slot-2" "slot-3 slot-4 slot-4"; } + &.template-five-complex { grid-template-columns: repeat(2, 130px); grid-template-rows: repeat(3, 65px); grid-template-areas: "slot-0 slot-1" "slot-2 slot-3" "slot-4 slot-4"; } + + // 6 слотів + &.template-six-grid { grid-template-columns: repeat(3, 85px); grid-template-rows: repeat(2, 101px); grid-template-areas: "slot-0 slot-1 slot-2" "slot-3 slot-4 slot-5"; } + &.template-six-compact { grid-template-columns: repeat(3, 85px); grid-template-rows: repeat(2, 101px); grid-template-areas: "slot-0 slot-1 slot-2" "slot-3 slot-4 slot-5"; } + &.template-six-mixed { grid-template-columns: repeat(3, 85px); grid-template-rows: repeat(2, 101px); grid-template-areas: "slot-0 slot-0 slot-1" "slot-2 slot-3 slot-4"; } + &.template-six-large { grid-template-columns: repeat(3, 85px); grid-template-rows: repeat(2, 101px); grid-template-areas: "slot-0 slot-1 slot-2" "slot-3 slot-4 slot-5"; } +} + +.preview-grid.carousel-preview { + .preview-slot { border-radius: 30px; } + + /* 1 слот */ + &.template-single-large { grid-template-columns: 380px; grid-template-rows: 305px; } + &.template-single-medium { grid-template-columns: 205px; grid-template-rows: 305px; } + + /* 2 слота */ + &.template-two-horizontal { grid-template-columns: repeat(2, 205px); grid-template-rows: 305px; } + + /* 3 слота */ + &.template-three-mixed { grid-template-columns: 205px 205px; grid-template-rows: 305px 150px; gap: 6px; } + + /* 4 слота */ + &.template-four-grid { grid-template-columns: repeat(2, 200px); grid-template-rows: repeat(2, 150px); gap: 6px; } + &.template-four-sidebar-left { + grid-template-columns: 200px 205px; grid-template-rows: 150px 150px; gap: 6px; + .preview-slot:first-child { grid-row: span 2; } + } + &.template-four-sidebar-right { + grid-template-columns: 205px 200px; grid-template-rows: 150px 150px; gap: 6px; + .preview-slot:first-child { grid-row: span 2; } + } + + /* 5 слотів */ + &.template-five-mixed { grid-template-columns: 205px 100px 100px; grid-template-rows: 150px 150px; gap: 6px; } + &.template-five-grid { grid-template-columns: repeat(3, 130px); grid-template-rows: repeat(2, 150px); gap: 6px; } + &.template-five-complex { grid-template-columns: repeat(2, 200px); grid-template-rows: repeat(3, 100px); gap: 6px; } + + /* 6 слотів */ + &.template-six-grid { grid-template-columns: repeat(3, 130px); grid-template-rows: repeat(2, 150px); gap: 6px; } + &.template-six-compact { grid-template-columns: repeat(3, 130px); grid-template-rows: repeat(2, 150px); gap: 6px; } + &.template-six-mixed { grid-template-columns: repeat(3, 130px); grid-template-rows: repeat(2, 150px); gap: 6px; } + &.template-six-large { grid-template-columns: repeat(3, 130px); grid-template-rows: repeat(2, 150px); gap: 6px; } +} \ No newline at end of file diff --git a/src/app/common/components/modals/EditImageModal/DeleteImageModal.component.tsx b/src/app/common/components/modals/EditImageModal/DeleteImageModal.component.tsx new file mode 100644 index 0000000..29d115f --- /dev/null +++ b/src/app/common/components/modals/EditImageModal/DeleteImageModal.component.tsx @@ -0,0 +1,42 @@ +import { observer } from 'mobx-react-lite'; +import { useModalContext } from '@stores/root-store'; +import { Modal, Button } from 'antd'; + +const DeleteImageModal = observer(() => { + const { modalStore: { setModal, modalsState: { deleteImage } } } = useModalContext(); + + const onConfirm = () => { + deleteImage.image?.onConfirm?.(); + setModal('deleteImage', undefined, false); + }; + return ( + setModal('deleteImage')} + footer={[ + , + , + ]} + > +

Ви впевнені?

+
+ ); +}); + +export default DeleteImageModal; \ No newline at end of file diff --git a/src/app/common/components/modals/EditImageModal/DeleteImageModal.styles.scss b/src/app/common/components/modals/EditImageModal/DeleteImageModal.styles.scss new file mode 100644 index 0000000..aa17622 --- /dev/null +++ b/src/app/common/components/modals/EditImageModal/DeleteImageModal.styles.scss @@ -0,0 +1,26 @@ +@use "@sass/variables/_variables.colors.scss" as c; +@use "@sass/mixins/_utils.mixins.scss" as mut; + +.deleteModal { + + .ant-btn-default { + + color: #000000; + + &:hover { + border-color: c.$accented-red-color; + color: c.$accented-red-color; + } + } + + .ant-btn-primary { + background-color: c.$accented-red-color; + border-color: c.$accented-red-color; + color: c.$pure-white-color; + + &:hover { + background-color: c.$dark-red-color; + border-color: c.$dark-red-color; + } + } +} \ No newline at end of file diff --git a/src/app/common/components/modals/EditImageModal/DeleteImageTemplatesModal.component.tsx b/src/app/common/components/modals/EditImageModal/DeleteImageTemplatesModal.component.tsx new file mode 100644 index 0000000..ee2f4ff --- /dev/null +++ b/src/app/common/components/modals/EditImageModal/DeleteImageTemplatesModal.component.tsx @@ -0,0 +1,42 @@ +import { observer } from 'mobx-react-lite'; +import { useModalContext } from '@stores/root-store'; +import { Modal, Button } from 'antd'; + +const DeleteImageTemplatesModal = observer(() => { + const { modalStore: { setModal, modalsState: { deleteImageTemplates } } } = useModalContext(); + + const onConfirm = () => { + deleteImageTemplates.image?.onConfirm?.(); + setModal('deleteImageTemplates', undefined, false); + }; + + return ( + setModal('deleteImageTemplates', undefined, false)} + footer={[ + , + , + ]} + > +

Ви впевнені, що хочете видалити цей елемент?

+
+ ); +}); +export default DeleteImageTemplatesModal; \ No newline at end of file diff --git a/src/app/common/components/modals/EditImageModal/EditImageModal.component.tsx b/src/app/common/components/modals/EditImageModal/EditImageModal.component.tsx new file mode 100644 index 0000000..010f5d2 --- /dev/null +++ b/src/app/common/components/modals/EditImageModal/EditImageModal.component.tsx @@ -0,0 +1,100 @@ +import './EditImageModal.styles.scss'; +import CancelBtn from '@images/utils/Cancel_btn.svg'; +import { observer } from 'mobx-react-lite'; +import { useModalContext } from '@stores/root-store'; +import { Button, Form, Input, Modal } from 'antd'; +import FormItem from 'antd/es/form/FormItem'; +import TextArea from 'antd/es/input/TextArea'; +import { useEffect, useState } from 'react'; + +const EditImageModal = observer(() => { + const [form] = Form.useForm(); + + const { artStore, imageTemplateStore, modalStore: { setModal, modalsState: { editImage } } } = useModalContext(); + const [loading, setLoading] = useState(false); + + const data = editImage.image; + + const existingArt = data ? imageTemplateStore.getArtByImageId(data.id) : null; + const isEditing = !!existingArt; + + useEffect(() => { + if (editImage.isOpen && data) { + form.setFieldsValue({ + title: existingArt?.title || data.title, + description: existingArt?.description || data.description, + }); + } + }, [editImage.isOpen, data, form, existingArt]); + + const onSuccessfulSubmit = async (values: any) => { + if (!data) return; + setLoading(true); + + setLoading(true); + + try { + if (isEditing) { + const updatedArt = await artStore.updateArt(existingArt.id, { + ...values, + imageId: data.id + }); + imageTemplateStore.setArtForImage(data.id, updatedArt); + } else { + const createdArt = await artStore.createArt({ + ...values, + imageId: data.id + }); + imageTemplateStore.setArtForImage(data.id, createdArt); + } + + setModal('editImage', undefined, false); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + return ( + setModal('editImage', undefined, false)} + footer={null} + closeIcon={} + > +

Додаткові дані

+ + {data && ( + art + )} + +
+ + + + + +