Merge branch 'graph' into dev

This commit is contained in:
Nastya 2023-10-30 10:32:55 +03:00
commit c9f9f3a4b0
63 changed files with 4600 additions and 33256 deletions

2
.gitignore vendored

@ -21,3 +21,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea

8
.idea/.gitignore vendored

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/squiz.iml" filepath="$PROJECT_DIR$/.idea/squiz.iml" />
</modules>
</component>
</project>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

10
cypress.config.ts Normal file

@ -0,0 +1,10 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1440,
viewportHeight: 900,
supportFile: false,
},
});

@ -0,0 +1,52 @@
describe("Quiz preview", () => {
beforeEach(() => {
cy.visit("/list");
cy.get("[data-cy=create-quiz]").click();
cy.get("[data-cy=create-quiz-card]").click();
cy.get("[data-cy=select-quiz-layout-standard]").click();
cy.get("[data-cy=setup-questions]").click();
});
it("container and layout elements should exist", () => {
cy.get("[data-cy=quiz-preview-container]").should("exist");
cy.get("[data-cy=quiz-preview-layout]").should("exist");
});
describe("Variant question", () => {
beforeEach(function fillTitleAndOptions() {
cy.get("[data-cy=expand-question]").click();
cy.get("[data-cy=select-questiontype-variant]").click();
cy.get("[data-cy=quiz-question-card]").eq(0).within(() => {
cy.get("[data-cy=quiz-question-title]").type("Question Title");
cy.get("[data-cy=quiz-variant-question-answer]").eq(0).type("Answer 1{enter}");
cy.get("[data-cy=quiz-variant-question-answer]").eq(1).type("Answer 2{enter}");
cy.get("[data-cy=quiz-variant-question-answer]").eq(2).type("Answer 3");
});
});
it("should contain title and options, and be selected properly", () => {
cy.get("[data-cy=quiz-preview-layout]").within(() => {
cy.get("[data-cy=variant-title]").should("have.text", "Question Title");
cy.get("[data-cy=variant-answer]").eq(0).should("have.text", "Answer 1");
cy.get("[data-cy=variant-answer]").eq(1).should("have.text", "Answer 2");
cy.get("[data-cy=variant-answer]").eq(2).should("have.text", "Answer 3");
cy.get("[data-cy=variant-answer]").eq(0).click();
cy.get("[data-cy=variant-radio]").eq(0).should("be.checked");
cy.get("[data-cy=variant-radio]").eq(1).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(2).should("not.be.checked");
cy.get("[data-cy=variant-answer]").eq(1).click();
cy.get("[data-cy=variant-radio]").eq(0).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(1).should("be.checked");
cy.get("[data-cy=variant-radio]").eq(2).should("not.be.checked");
cy.get("[data-cy=variant-answer]").eq(2).click();
cy.get("[data-cy=variant-radio]").eq(0).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(1).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(2).should("be.checked");
});
});
});
});

12
cypress/tsconfig.json Normal file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"noEmit": true,
"types": [
"cypress"
]
},
"include": [
"../node_modules/cypress",
"./**/*.ts"
]
}

31358
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -49,23 +49,14 @@
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "craco eject"
"eject": "craco eject",
"cypress:open": "cypress open"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"quotes": [
"warn",
"double",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
]
}
]
},
"browserslist": {
"production": [

@ -24,6 +24,7 @@ export const QUIZ_QUESTION_BASE: Omit<QuizQuestionInitial, "id"> = {
],
},
back: "",
originalBack: "",
autofill: false,
},
};

@ -16,6 +16,7 @@ export const QUIZ_QUESTION_EMOJI: Omit<QuizQuestionEmoji, "id"> = {
{
answer: "",
extendedText: "",
hints: ""
},
],
},

@ -19,6 +19,8 @@ export const QUIZ_QUESTION_IMAGES: Omit<QuizQuestionImages, "id"> = {
{
answer: "",
extendedText: "",
originalImageUrl: "",
hints: ""
},
],
largeCheck: false,

@ -11,6 +11,7 @@ export const QUIZ_QUESTION_PAGE: Omit<QuizQuestionPage, "id"> = {
innerName: "",
text: "",
picture: "",
originalPicture: "",
video: "",
},
};

@ -12,6 +12,6 @@ export const QUIZ_QUESTION_SELECT: Omit<QuizQuestionSelect, "id"> = {
innerNameCheck: false,
innerName: "",
default: "",
variants: [{ answer: "", extendedText: "" }],
variants: [{ answer: "", extendedText: "", hints: "" }],
},
};

@ -13,6 +13,6 @@ export const QUIZ_QUESTION_VARIANT: Omit<QuizQuestionVariant, "id"> = {
innerNameCheck: false,
required: false,
innerName: "",
variants: [{ answer: "", extendedText: "" }],
variants: [{ answer: "", extendedText: "", hints: "" }],
},
};

@ -11,7 +11,7 @@ export const QUIZ_QUESTION_VARIMG: Omit<QuizQuestionVarImg, "id"> = {
innerNameCheck: false,
innerName: "",
required: false,
variants: [{ answer: "", extendedText: "" }],
variants: [{ answer: "", hints: "", extendedText: "", originalImageUrl: "" }],
largeCheck: false,
replText: "",
},

@ -5,7 +5,7 @@ import { HTML5Backend } from "react-dnd-html5-backend";
import "./index.css";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import lightTheme from "./utils/themes/light";
import { ThemeProvider } from "@mui/material";
import { CssBaseline, ThemeProvider } from "@mui/material";
import StartPage from "./pages/startPage/StartPage";
import Main from "./pages/main";
import QuestionsPage from "./pages/Questions/QuestionsPage";
@ -50,6 +50,7 @@ root.render(
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru" localeText={localeText}>
<ThemeProvider theme={lightTheme}>
<SnackbarProvider>
<CssBaseline />
<ContactFormModal />
<BrowserRouter>
<Routes>

@ -18,6 +18,7 @@ export interface QuizQuestionDate extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
};
}

@ -22,6 +22,7 @@ export interface QuizQuestionEmoji extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
};
}

@ -29,5 +29,6 @@ export interface QuizQuestionFile extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
};
}

@ -1,35 +1,36 @@
import type {
QuizQuestionBase,
QuestionVariant,
QuestionHint,
QuestionBranchingRule,
ImageQuestionVariant,
QuestionBranchingRule,
QuestionHint,
QuizQuestionBase
} from "./shared";
export interface QuizQuestionImages extends QuizQuestionBase {
type: "images";
content: {
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Можно несколько" */
multi: boolean;
/** Пропорции */
xy: "1:1" | "1:2" | "2:1";
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Большие картинки" */
large: boolean;
/** Форма */
format: "carousel" | "masonry";
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Варианты (картинки) */
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
autofill: boolean;
largeCheck: boolean;
};
type: "images";
content: {
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Можно несколько" */
multi: boolean;
/** Пропорции */
xy: "1:1" | "1:2" | "2:1";
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Большие картинки" */
large: boolean;
/** Форма */
format: "carousel" | "masonry";
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Варианты (картинки) */
variants: ImageQuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
largeCheck: boolean;
};
}

@ -27,6 +27,7 @@ export interface QuizQuestionNumber extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
form: "star" | "trophie" | "flag" | "heart" | "like" | "bubble" | "hashtag";
};

@ -13,10 +13,12 @@ export interface QuizQuestionPage extends QuizQuestionBase {
innerName: string;
text: string;
picture: string;
originalPicture: string;
video: string;
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
};
}

@ -20,6 +20,7 @@ export interface QuizQuestionRating extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
/** Позитивное описание рейтинга */
ratingPositiveDescription: string;

@ -22,6 +22,7 @@ export interface QuizQuestionSelect extends QuizQuestionBase {
rule: QuestionBranchingRule;
hint: QuestionHint;
back: string;
originalBack: string;
autofill: boolean;
};
}

@ -11,45 +11,53 @@ import type { QuizQuestionVariant } from "./variant";
import type { QuizQuestionVarImg } from "./varimg";
export interface QuestionBranchingRule {
/** Радиокнопка "Все условия обязательны" */
or: boolean;
show: boolean;
title: string;
reqs: {
id: string;
/** Список выбранных вариантов */
vars: number[];
}[];
/** Радиокнопка "Все условия обязательны" */
or: boolean;
show: boolean;
title: string;
reqs: {
id: string;
/** Список выбранных вариантов */
vars: number[];
}[];
}
export interface QuestionHint {
/** Текст подсказки */
text: string;
/** URL видео подсказки */
video: string;
/** Текст подсказки */
text: string;
/** URL видео подсказки */
video: string;
}
export type QuestionVariant = {
/** Текст */
answer: string;
/** Дополнительное поле для текста, emoji, ссылки на картинку */
extendedText: string;
/** Текст */
answer: string;
/** Текст подсказки */
hints: string;
/** Дополнительное поле для текста, emoji, ссылки на картинку */
extendedText: string;
};
export interface ImageQuestionVariant extends QuestionVariant {
/** Оригинал изображения (до кропа) */
originalImageUrl: string;
}
export interface QuizQuestionBase {
id: number;
title: string;
type: string;
expanded: boolean;
required: boolean;
deleted: boolean;
deleteTimeoutId: number;
content: {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
autofill: boolean;
};
id: number;
title: string;
type: string;
expanded: boolean;
required: boolean;
deleted: boolean;
deleteTimeoutId: number;
content: {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
};
}
export interface QuizQuestionInitial extends QuizQuestionBase {
@ -57,18 +65,18 @@ export interface QuizQuestionInitial extends QuizQuestionBase {
}
export type AnyQuizQuestion =
| QuizQuestionVariant
| QuizQuestionImages
| QuizQuestionVarImg
| QuizQuestionEmoji
| QuizQuestionText
| QuizQuestionSelect
| QuizQuestionDate
| QuizQuestionNumber
| QuizQuestionFile
| QuizQuestionPage
| QuizQuestionRating
| QuizQuestionInitial;
| QuizQuestionVariant
| QuizQuestionImages
| QuizQuestionVarImg
| QuizQuestionEmoji
| QuizQuestionText
| QuizQuestionSelect
| QuizQuestionDate
| QuizQuestionNumber
| QuizQuestionFile
| QuizQuestionPage
| QuizQuestionRating
| QuizQuestionInitial;
export type QuizQuestionType = AnyQuizQuestion["type"];

@ -20,5 +20,6 @@ export interface QuizQuestionText extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
};
}

@ -25,6 +25,7 @@ export interface QuizQuestionVariant extends QuizQuestionBase {
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
};
}

@ -1,27 +1,28 @@
import type {
QuizQuestionBase,
QuestionVariant,
QuestionHint,
QuestionBranchingRule,
ImageQuestionVariant,
QuestionBranchingRule,
QuestionHint,
QuizQuestionBase
} from "./shared";
export interface QuizQuestionVarImg extends QuizQuestionBase {
type: "varimg";
content: {
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
autofill: boolean;
largeCheck: boolean;
replText: string;
};
type: "varimg";
content: {
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
variants: ImageQuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
largeCheck: boolean;
replText: string;
};
}

13
src/mui.d.ts vendored

@ -9,7 +9,7 @@ declare module "@mui/material/styles" {
grey1: Palette["primary"],
grey2: Palette["primary"],
grey3: Palette["primary"],
grey4: Palette ["primary"],
grey4: Palette["primary"],
orange: Palette["primary"],
navbarbg: Palette["primary"],
}
@ -21,7 +21,7 @@ declare module "@mui/material/styles" {
grey1?: PaletteOptions["primary"],
grey2?: PaletteOptions["primary"],
grey3?: PaletteOptions["primary"],
grey4?: PaletteOptions ["primary"],
grey4?: PaletteOptions["primary"],
orange?: PaletteOptions["primary"],
navbarbg?: PaletteOptions["primary"],
}
@ -40,4 +40,11 @@ declare module "@mui/material/Typography" {
infographic: true;
p1: true;
}
}
}
type DataAttributeKey = `data-${string}`;
declare module 'react' {
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
[dataAttribute: DataAttributeKey]: unknown;
}
}

@ -7,6 +7,7 @@ import {
FormControl,
InputAdornment,
IconButton,
Popover,
useTheme,
useMediaQuery,
} from "@mui/material";
@ -16,8 +17,11 @@ import { questionStore, updateQuestionsList } from "@root/questions";
import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import { MessageIcon } from "@icons/messagIcon";
import TextareaAutosize from "@mui/base/TextareaAutosize";
import type { ChangeEvent, KeyboardEvent, ReactNode } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import type { DroppableProvided } from "react-beautiful-dnd";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
import type { QuestionVariant } from "../../../model/questionTypes/shared";
@ -49,7 +53,6 @@ export const AnswerItem = ({
const debounced = useDebouncedCallback((value) => {
const answerNew = variants.slice();
answerNew[index].answer = value;
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: {
...question.content,
@ -58,9 +61,23 @@ export const AnswerItem = ({
});
}, 1000);
console.log(provided)
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
};
const addNewAnswer = () => {
const answerNew = variants.slice();
answerNew.push({ answer: "", extendedText: "" });
answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
@ -76,79 +93,125 @@ export const AnswerItem = ({
});
};
const changeAnswerHint = (event: ChangeEvent<HTMLTextAreaElement>) => {
const answerNew = variants.slice();
answerNew[index].hints = event.target.value;
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
return (
<Box>
<FormControl
key={index}
fullWidth
variant="standard"
sx={{
margin: isTablet ? " 15px 0 20px 0" : "0 0 20px 0",
borderRadius: "10px",
border: "1px solid rgba(0, 0, 0, 0.23)",
background: "white",
}}
>
<TextField
defaultValue={variant.answer}
<Draggable draggableId={String(index)} index={index}>
{(provided) => (
<Box ref={provided.innerRef} {...provided.draggableProps}>
<FormControl
key={index}
fullWidth
focused={false}
placeholder={"Добавьте ответ"}
multiline={question.content.largeCheck}
onChange={({ target }) => debounced(target.value)}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.code === "Enter" && !question.content.largeCheck) {
addNewAnswer();
}
}}
InputProps={{
startAdornment: (
<>
<InputAdornment {...provided.droppableProps} position="start">
<PointsIcon style={{ color: "#9A9AAF", fontSize: "30px" }} />
</InputAdornment>
{additionalContent}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton sx={{ padding: "0" }} onClick={deleteAnswer}>
<DeleteIcon
style={{
color: theme.palette.grey2.main,
marginRight: "-1px",
}}
/>
</IconButton>
</InputAdornment>
),
}}
variant="standard"
sx={{
"& .MuiInputBase-root": {
padding: additionalContent
? isTablet
? "13px"
: "5px 13px"
: "13px",
borderRadius: "10px",
background: "#ffffff",
"& input.MuiInputBase-input": {
height: "22px",
},
"& textarea.MuiInputBase-input": {
marginTop: "1px",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
},
},
margin: isTablet ? " 15px 0 20px 0" : "0 0 20px 0",
borderRadius: "10px",
border: "1px solid rgba(0, 0, 0, 0.23)",
background: "white",
}}
inputProps={{
sx: { fontSize: "18px", lineHeight: "21px", py: 0, ml: "13px" },
}}
/>
{additionalMobile}
</FormControl>
</Box>
>
<TextField
defaultValue={variant.answer}
fullWidth
focused={false}
placeholder={"Добавьте ответ"}
multiline={question.content.largeCheck}
onChange={({ target }) => debounced(target.value)}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.code === "Enter" && !question.content.largeCheck) {
addNewAnswer();
}
}}
InputProps={{
startAdornment: (
<>
<InputAdornment
{...provided.dragHandleProps}
position="start"
>
<PointsIcon
style={{ color: "#9A9AAF", fontSize: "30px" }}
/>
</InputAdornment>
{additionalContent}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
sx={{ padding: "0" }}
aria-describedby="my-popover-id"
onClick={handleClick}
>
<MessageIcon
style={{
color: "#9A9AAF",
fontSize: "30px",
marginRight: "6.5px",
}}
/>
</IconButton>
<Popover
id="my-popover-id"
open={isOpen}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
<TextareaAutosize
style={{ margin: "10px" }}
placeholder="Подсказка для этого ответа"
value={variant.hints}
onChange={changeAnswerHint}
onKeyDown={(
event: KeyboardEvent<HTMLTextAreaElement>
) => event.stopPropagation()}
/>
</Popover>
<IconButton sx={{ padding: "0" }} onClick={deleteAnswer}>
<DeleteIcon
style={{
color: theme.palette.grey2.main,
marginRight: "-1px",
}}
/>
</IconButton>
</InputAdornment>
),
}}
sx={{
"& .MuiInputBase-root": {
padding: additionalContent ? "5px 13px" : "13px",
borderRadius: "10px",
background: "#ffffff",
"& input.MuiInputBase-input": {
height: "22px",
},
"& textarea.MuiInputBase-input": {
marginTop: "1px",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
},
},
}}
inputProps={{
sx: { fontSize: "18px", lineHeight: "21px", py: 0, ml: "13px" },
}}
/>
{additionalMobile}
</FormControl>
</Box>
)}
</Draggable>
);
};

@ -4,21 +4,21 @@ import { DragDropContext, Droppable } from "react-beautiful-dnd";
import { AnswerItem } from "./AnswerItem";
import { updateVariants } from "@root/questions";
import { reorderVariants } from "@root/questions";
import { reorder } from "./helper";
import type { ReactNode } from "react";
import { type ReactNode } from "react";
import type { DropResult } from "react-beautiful-dnd";
import type { QuestionVariant } from "../../../model/questionTypes/shared";
import type { ImageQuestionVariant, QuestionVariant } from "../../../model/questionTypes/shared";
type AnswerDraggableListProps = {
variants: QuestionVariant[];
totalIndex: number;
additionalContent?: (variant: QuestionVariant, index: number) => ReactNode;
additionalMobile?: (variant: QuestionVariant, index: number) => ReactNode;
additionalContent?: (variant: QuestionVariant | ImageQuestionVariant, index: number) => ReactNode;
additionalMobile?: (variant: QuestionVariant | ImageQuestionVariant, index: number) => ReactNode;
};
export const AnswerDraggableList = ({
variants,
totalIndex,
@ -29,9 +29,7 @@ export const AnswerDraggableList = ({
const onDragEnd = ({ destination, source }: DropResult) => {
if (destination) {
const newItems = reorder(variants, source.index, destination.index);
updateVariants(quizId, totalIndex, newItems);
reorderVariants(quizId, totalIndex, source.index, destination.index);
}
};

@ -171,6 +171,7 @@ export default function QuestionsPageCard({
<>
<Paper
id={String(totalIndex)}
data-cy="quiz-question-card"
sx={{
maxWidth: "796px",
width: "100%",
@ -248,6 +249,7 @@ export default function QuestionsPageCard({
py: 0,
paddingLeft: question.type.length === 0 ? 0 : "18px",
},
"data-cy": "quiz-question-title",
}}
/>
</FormControl>
@ -263,6 +265,7 @@ export default function QuestionsPageCard({
<IconButton
sx={{ padding: "0", margin: "5px" }}
disableRipple
data-cy="expand-question"
onClick={() =>
updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, {
expanded: !question.expanded,

@ -29,7 +29,7 @@ export default function DropDown({ totalIndex }: Props) {
const addNewAnswer = () => {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" });
answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionSelect>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },

@ -213,7 +213,7 @@ export default function Emoji({ totalIndex }: Props) {
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" });
answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionEmoji>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },

@ -1,4 +1,3 @@
import { useState } from "react";
import {
Box,
Link,
@ -12,270 +11,361 @@ import {
TextareaAutosize,
TextField,
} from "@mui/material";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { AnswerDraggableList } from "../AnswerDraggableList";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { AnswerDraggableList } from "../AnswerDraggableList";
import { UploadImageModal } from "../UploadImage/UploadImageModal";
import AddImage from "@icons/questionsPage/addImage";
import Image from "@icons/questionsPage/image";
import { ImageAddIcons } from "@icons/ImageAddIcons";
import { MessageIcon } from "@icons/messagIcon";
import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import { questionStore, setVariantImageUrl, setVariantOriginalImageUrl, updateQuestionsList } from "@root/questions";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import ButtonsOptionsAndPict from "../ButtonsOptionsAndPict";
import SwitchOptionsAndPict from "./switchOptionsAndPict";
import React from "react";
import { questionStore, updateQuestionsList } from "@root/questions";
import { ImageAddIcons } from "@icons/ImageAddIcons";
import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { MessageIcon } from "@icons/messagIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import PlusImage from "@icons/questionsPage/plus";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import type { QuizQuestionVarImg } from "../../../model/questionTypes/varimg";
import { produce } from "immer";
interface Props {
totalIndex: number;
}
export default function OptionsAndPicture({ totalIndex }: Props) {
const [open, setOpen] = useState(false);
const [opened, setOpened] = useState<boolean>(false);
const [switchState, setSwitchState] = useState("setting");
const [currentIndex, setCurrentIndex] = useState<number>(0);
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const question = listQuestions[quizId][totalIndex] as QuizQuestionVarImg;
const [isUploadImageModalOpen, setIsUploadImageModalOpen] = useState(false);
const [switchState, setSwitchState] = useState("setting");
const [currentIndex, setCurrentIndex] = useState<number>(0);
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const question = listQuestions[quizId][totalIndex] as QuizQuestionVarImg;
const SSHC = (data: string) => {
setSwitchState(data);
};
const uploadImage = (files: FileList | null) => {
if (files?.length) {
const [file] = Array.from(files);
const handleImageUpload = (files: FileList | null) => {
if (!files?.length) return;
const clonedContent = { ...question.content };
clonedContent.variants[currentIndex].extendedText =
URL.createObjectURL(file);
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, {
content: clonedContent,
});
const [file] = Array.from(files);
const url = URL.createObjectURL(file);
setOpen(false);
setOpened(true);
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
setVariantOriginalImageUrl(quizId, totalIndex, currentIndex, url);
setIsUploadImageModalOpen(false);
openCropModal(url, url);
};
function handleCropModalSaveClick(url: string) {
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
}
};
return (
<>
return (
<>
<Box sx={{ pl: "20px", pr: "20px" }}>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (
<Box
sx={{ cursor: "pointer" }}
onClick={() => {
setCurrentIndex(index);
setOpen(true);
}}
>
{variant.extendedText ? (
<Box
sx={{
overflow: "hidden",
width: "60px",
display: "flex",
alignItems: "center",
background: "#EEE4FC",
borderRadius: "3px",
margin: "0 10px",
height: "40px",
}}
>
<Box sx={{ display: "flex", width: "40px" }}>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/>
</Box>
<PlusImage />
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<AddImage
sx={{
height: "40px",
width: "60px",
margin: "0 10px",
}}
/>
</Button>
)}
</Box>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index);
if (variant.extendedText) return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ mx: "10px" }}
/>
)}
</>
)}
</>
)}
additionalMobile={(variant, index) => (
<>
{isMobile && (
<Box
onClick={() => {
setCurrentIndex(index);
setOpen(true);
additionalMobile={(variant, index) => (
<>
{isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index);
if (variant.extendedText) return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
/>
<UploadImageModal
open={isUploadImageModalOpen}
onClose={() => setIsUploadImageModalOpen(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Box
sx={{
width: "100%",
border: "1px solid #9A9AAF",
borderRadius: "8px",
display: isTablet ? "block" : "none",
}}
>
<TextField
fullWidth
focused={false}
placeholder={"Добавьте ответ"}
multiline={question.content.largeCheck}
InputProps={{
startAdornment: (
<>
<InputAdornment position="start">
<PointsIcon
style={{ color: "#9A9AAF", fontSize: "30px" }}
/>
</InputAdornment>
{!isMobile && (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
marginRight: "20px",
marginLeft: "12px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
}}
>
<ImageAddIcons fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
sx={{ padding: "0" }}
aria-describedby="my-popover-id"
>
<MessageIcon
style={{
color: "#9A9AAF",
fontSize: "30px",
marginRight: "6.5px",
}}
/>
</IconButton>
<Popover
id="my-popover-id"
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
open={false}
>
<TextareaAutosize
style={{ margin: "10px" }}
placeholder="Подсказка для этого ответа"
/>
</Popover>
<IconButton sx={{ padding: "0" }}>
<DeleteIcon
style={{
color: theme.palette.grey2.main,
marginRight: "-1px",
}}
/>
</IconButton>
</InputAdornment>
),
}}
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
m: "8px",
position: "relative",
borderRadius: "3px",
"& .MuiInputBase-root": {
padding: "13.5px",
borderRadius: "10px",
background: "#ffffff",
height: "48px",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
},
}}
>
inputProps={{
sx: { fontSize: "18px", lineHeight: "21px", py: 0 },
}}
/>
{isMobile && (
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{variant.extendedText ? (
<Box
sx={{
overflow: "hidden",
width: "40px",
sx={{
display: "flex",
alignItems: "center",
background: "#EEE4FC",
height: "30px",
borderRadius: "3px",
}}
>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/>
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<Image
sx={{
height: "40px",
width: "60px",
margin: "0 10px",
}}
/>
</Button>
)}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
m: "8px",
position: "relative",
}}
>
+
<Box
sx={{ width: "100%", background: "#EEE4FC", height: "40px" }}
/>
<ImageAddIcons
style={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
/>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
>
<Box
sx={{ width: "100%", background: "#EEE4FC", height: "40px" }}
/>
<ImageAddIcons
style={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
/>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
>
+
</Box>
</Box>
</Box>
</Box>
)}
</>
)}
/>
<UploadImageModal
open={open}
onClose={() => setOpen(false)}
imgHC={uploadImage}
/>
<CropModal
opened={opened}
onClose={() => setOpened(false)}
picture={question.content.variants[currentIndex]?.extendedText}
onCropPress={(url) => {
const content = produce(question.content, (draft) => {
draft.variants[currentIndex].extendedText = url;
});
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, {
content,
});
}}
/>
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => {
const clonedContent = { ...question.content };
clonedContent.variants.push({
answer: "",
extendedText: "",
});
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, {
content: clonedContent,
});
}}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => {
const clonedContent = { ...question.content };
clonedContent.variants.push({
answer: "",
hints: "",
extendedText: "",
originalImageUrl: "",
});
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, {
content: clonedContent,
});
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
</Box>
<ButtonsOptionsAndPict
switchState={switchState}
SSHC={SSHC}
totalIndex={totalIndex}
switchState={switchState}
SSHC={SSHC}
totalIndex={totalIndex}
/>
<SwitchOptionsAndPict switchState={switchState} totalIndex={totalIndex} />
</>
</>
);
}

@ -1,5 +1,3 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
Box,
Link,
@ -8,251 +6,167 @@ import {
useTheme,
useMediaQuery,
} from "@mui/material";
import { useState } from "react";
import { useParams } from "react-router-dom";
import ButtonsOptions from "../ButtonsOptions";
import { AnswerDraggableList } from "../AnswerDraggableList";
import { questionStore, setVariantImageUrl, updateQuestionsList, setVariantOriginalImageUrl } from "@root/questions";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../ButtonsOptions";
import { UploadImageModal } from "../UploadImage/UploadImageModal";
import { questionStore, updateQuestionsList } from "@root/questions";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import AddImage from "../../../assets/icons/questionsPage/addImage";
import Image from "../../../assets/icons/questionsPage/image";
import SwitchAnswerOptionsPict from "./switchOptionsPict";
import PlusImage from "@icons/questionsPage/plus";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
import { produce } from "immer";
interface Props {
totalIndex: number;
}
export default function OptionsPicture({ totalIndex }: Props) {
const [open, setOpen] = useState(false);
const [opened, setOpened] = useState<boolean>(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const isTablet = useMediaQuery(theme.breakpoints.down(790));
const quizId = Number(useParams().quizId);
const [switchState, setSwitchState] = useState("setting");
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionImages;
const [isUploadImageModalOpen, setIsUploadImageModalOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const quizId = Number(useParams().quizId);
const [switchState, setSwitchState] = useState("setting");
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionImages;
const SSHC = (data: string) => {
setSwitchState(data);
};
const uploadImage = (files: FileList | null) => {
if (files?.length) {
const [file] = Array.from(files);
const handleImageUpload = (files: FileList | null) => {
if (!files?.length) return;
const clonedContent = { ...question.content };
clonedContent.variants[currentIndex].extendedText =
URL.createObjectURL(file);
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content: clonedContent,
});
const [file] = Array.from(files);
const url = URL.createObjectURL(file);
setOpen(false);
setOpened(true);
}
};
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
setVariantOriginalImageUrl(quizId, totalIndex, currentIndex, url);
setIsUploadImageModalOpen(false);
openCropModal(url, url);
};
const addNewAnswer = () => {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" });
const addNewAnswer = () => {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", hints: "", extendedText: "", originalImageUrl: "" });
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
return (
<>
function handleCropModalSaveClick(url: string) {
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
}
return (
<>
<Box sx={{ padding: "20px" }}>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (
<Box
sx={{ cursor: "pointer" }}
onClick={() => {
setCurrentIndex(index);
setOpen(true);
}}
>
{variant.extendedText ? (
<Box
sx={{
overflow: "hidden",
width: "60px",
display: "flex",
alignItems: "center",
background: "#EEE4FC",
borderRadius: "3px",
margin: "0 10px",
height: "40px",
}}
>
<Box sx={{ display: "flex", width: "40px" }}>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/>
</Box>
<PlusImage />
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<AddImage
sx={{ height: "40px", width: "60px", margin: "0 10px" }}
/>
</Button>
)}
</Box>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ mx: "10px" }}
/>
)}
</>
)}
</>
)}
additionalMobile={(variant, index) => (
<>
{isMobile && (
<Box
onClick={() => {
setCurrentIndex(index);
setOpen(true);
}}
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
m: "8px",
position: "relative",
borderRadius: "3px",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{variant.extendedText ? (
<Box
sx={{
overflow: "hidden",
width: "40px",
display: "flex",
alignItems: "center",
background: "#EEE4FC",
height: "30px",
borderRadius: "3px",
}}
>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/>
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<Image
sx={{
height: "40px",
width: "60px",
margin: "0 10px",
}}
/>
</Button>
)}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
>
+
</Box>
</Box>
additionalMobile={(variant, index) => (
<>
{isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
</>
)}
/>
<UploadImageModal
open={open}
onClose={() => setOpen(false)}
imgHC={uploadImage}
/>
<CropModal
opened={opened}
onClose={() => setOpened(false)}
picture={question.content.variants[currentIndex]?.extendedText}
onCropPress={(url) => {
const content = produce(question.content, (draft) => {
draft.variants[currentIndex].extendedText = url;
});
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content,
});
}}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={addNewAnswer}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
/>
<UploadImageModal
open={isUploadImageModalOpen}
onClose={() => setIsUploadImageModalOpen(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={addNewAnswer}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
</Box>
<ButtonsOptions
switchState={switchState}
SSHC={SSHC}
totalIndex={totalIndex}
/>
<SwitchAnswerOptionsPict
switchState={switchState}
totalIndex={totalIndex}
/>
</>
<ButtonsOptions switchState={switchState} SSHC={SSHC} totalIndex={totalIndex} />
<SwitchAnswerOptionsPict switchState={switchState} totalIndex={totalIndex} />
</>
);
}

@ -1,299 +1,244 @@
import { VideofileIcon } from "@icons/questionsPage/VideofileIcon";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { questionStore, setPageQuestionOriginalPicture, setPageQuestionPicture, updateQuestionsList } from "@root/questions";
import CustomTextField from "@ui_kit/CustomTextField";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useDebouncedCallback } from "use-debounce";
import ButtonsOptions from "../ButtonsOptions";
import CustomTextField from "@ui_kit/CustomTextField";
import AddImage from "../../../assets/icons/questionsPage/addImage";
import AddVideofile from "../../../assets/icons/questionsPage/addVideofile";
import SwitchPageOptions from "./switchPageOptions";
import { questionStore, updateQuestionsList } from "@root/questions";
import { UploadImageModal } from "../UploadImage/UploadImageModal";
import { UploadVideoModal } from "../UploadVideoModal";
import { AddPlusImage } from "@icons/questionsPage/addPlusImage";
import { AddPlusVideo } from "@icons/questionsPage/addPlusVideo";
import { ImageAddIcons } from "@icons/ImageAddIcons";
import { VideofileIcon } from "@icons/questionsPage/VideofileIcon";
import SwitchPageOptions from "./switchPageOptions";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal } from "@ui_kit/Modal/CropModal";
import type { QuizQuestionPage } from "../../../model/questionTypes/page";
type Props = {
disableInput?: boolean;
totalIndex: number;
disableInput?: boolean;
totalIndex: number;
};
export default function PageOptions({ disableInput, totalIndex }: Props) {
const [openImageModal, setOpenImageModal] = useState<boolean>(false);
const [openVideoModal, setOpenVideoModal] = useState<boolean>(false);
const [switchState, setSwitchState] = useState("setting");
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(980));
const isFigmaTablet = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(780));
const question = listQuestions[quizId][totalIndex] as QuizQuestionPage;
const debounced = useDebouncedCallback((value) => {
updateQuestionsList<QuizQuestionPage>(quizId, totalIndex, {
content: { ...question.content, text: value },
});
}, 1000);
const [openImageModal, setOpenImageModal] = useState<boolean>(false);
const [openVideoModal, setOpenVideoModal] = useState<boolean>(false);
const [switchState, setSwitchState] = useState("setting");
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(980));
const isFigmaTablet = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(780));
const question = listQuestions[quizId][totalIndex] as QuizQuestionPage;
const debounced = useDebouncedCallback((value) => {
updateQuestionsList<QuizQuestionPage>(quizId, totalIndex, {
content: { ...question.content, text: value },
});
}, 1000);
const SSHC = (data: string) => {
setSwitchState(data);
};
const SSHC = (data: string) => {
setSwitchState(data);
};
return (
<>
<Box
sx={{
width: isTablet ? "auto" : "100%",
maxWidth: isFigmaTablet ? "549px" : "640px",
display: "flex",
px: "20px",
flexDirection: "column",
gap: isMobile ? "25px" : "20px",
}}
>
<Box sx={{ display: disableInput ? "none" : "", mt: isMobile ? "15px" : "0px" }}>
<CustomTextField
placeholder={"Можно добавить текст"}
text={question.content.text}
onChange={({ target }) => debounced(target.value)}
/>
</Box>
function handleImageUpload(fileList: FileList | null) {
if (!fileList?.length) return;
<Box
sx={{
mb: "20px",
ml: isTablet ? "0px" : "60px",
display: "flex",
alignItems: "center",
gap: "28px",
justifyContent: isMobile ? "space-between" : null,
}}
>
<Box
onClick={() => setOpenImageModal(true)}
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "20px",
}}
>
{isMobile ? (
<Box
const url = URL.createObjectURL(fileList[0]);
setPageQuestionPicture(quizId, totalIndex, url);
setPageQuestionOriginalPicture(quizId, totalIndex, url);
setOpenImageModal(false);
openCropModal(url, url);
}
function handleCropModalSaveClick(url: string) {
setPageQuestionPicture(quizId, totalIndex, url);
}
return (
<>
<Box
sx={{
display: "flex",
alignItems: "center",
width: "120px",
position: "relative",
width: isTablet ? "auto" : "100%",
maxWidth: isFigmaTablet ? "549px" : "640px",
display: "flex",
px: "20px",
flexDirection: "column",
gap: isMobile ? "25px" : "20px",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: "4px",
borderBottomLeftRadius: "4px",
}}
>
<ImageAddIcons
style={{
color: "#7E2AEA",
fontSize: "20px",
}}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
borderTopRightRadius: "4px",
borderBottomRightRadius: "4px",
}}
>
+
</Box>
</Box>
) : (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%" }}>
<ImageAddIcons fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: theme.palette.grey2.main,
}}
>
Изображение
</Typography>
</Box>
<UploadImageModal
open={openImageModal}
onClose={() => setOpenImageModal(false)}
imgHC={(fileList) => {
if (fileList?.length) {
updateQuestionsList<QuizQuestionPage>(quizId, totalIndex, {
content: {
...question.content,
picture: URL.createObjectURL(fileList[0]),
},
});
}
}}
// onClick={() => setOpenVideoModal(true)}
/>
<Typography> или</Typography>
<Box
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{isMobile ? (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "120px",
position: "relative",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: "4px",
borderBottomLeftRadius: "4px",
}}
>
<VideofileIcon
style={{
color: "#7E2AEA",
fontSize: "20px",
}}
/>
<Box sx={{ display: disableInput ? "none" : "", mt: isMobile ? "15px" : "0px" }}>
<CustomTextField
placeholder={"Можно добавить текст"}
text={question.content.text}
onChange={({ target }) => debounced(target.value)}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
borderTopRightRadius: "4px",
borderBottomRightRadius: "4px",
}}
>
+
</Box>
</Box>
) : (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%" }}>
<VideofileIcon fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: theme.palette.grey2.main,
}}
>
Видео
</Typography>
</Box>
<UploadVideoModal
open={openVideoModal}
onClose={() => setOpenVideoModal(false)}
video={question.content.video}
onUpload={(url) => {
updateQuestionsList<QuizQuestionPage>(quizId, totalIndex, {
content: { ...question.content, video: url },
});
}}
/>
</Box>
</Box>
<ButtonsOptions switchState={switchState} SSHC={SSHC} totalIndex={totalIndex} />
<SwitchPageOptions switchState={switchState} totalIndex={totalIndex} />
</>
);
<Box
sx={{
mb: "20px",
ml: isTablet ? "0px" : "60px",
display: "flex",
alignItems: "center",
gap: "28px",
justifyContent: isMobile ? "space-between" : null,
}}
>
<Box
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "20px",
}}
>
<AddOrEditImageButton
imageSrc={question.content.picture}
onImageClick={() => {
if (question.content.picture) {
return openCropModal(
question.content.picture,
question.content.originalPicture
);
}
setOpenImageModal(true);
}}
onPlusClick={() => {
setOpenImageModal(true);
}}
/>
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: theme.palette.grey2.main,
}}
>
Изображение
</Typography>
</Box>
<UploadImageModal
open={openImageModal}
onClose={() => setOpenImageModal(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Typography> или</Typography>
<Box
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{isMobile ? (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "120px",
position: "relative",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: "4px",
borderBottomLeftRadius: "4px",
}}
>
<VideofileIcon
style={{
color: "#7E2AEA",
fontSize: "20px",
}}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
borderTopRightRadius: "4px",
borderBottomRightRadius: "4px",
}}
>
+
</Box>
</Box>
) : (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%" }}>
<VideofileIcon fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: theme.palette.grey2.main,
}}
>
Видео
</Typography>
</Box>
<UploadVideoModal
open={openVideoModal}
onClose={() => setOpenVideoModal(false)}
video={question.content.video}
onUpload={(url) => {
updateQuestionsList<QuizQuestionPage>(quizId, totalIndex, {
content: { ...question.content, video: url },
});
}}
/>
</Box>
</Box>
<ButtonsOptions switchState={switchState} SSHC={SSHC} totalIndex={totalIndex} />
<SwitchPageOptions switchState={switchState} totalIndex={totalIndex} />
</>
);
}

@ -110,6 +110,7 @@ export default function TypeQuestions({ totalIndex }: Props) {
{BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => (
<QuestionsMiniButton
key={title}
dataCy={`select-questiontype-${value}`}
onClick={() => {
const question = { ...listQuestions[quizId][totalIndex] };

@ -1,82 +1,97 @@
import { useParams } from "react-router-dom";
import { useState } from "react";
import { Typography, Box, useTheme, ButtonBase } from "@mui/material";
import UploadBox from "@ui_kit/UploadBox";
import { Box, ButtonBase, Typography, useTheme } from "@mui/material";
import { questionStore, setQuestionBackgroundImage, setQuestionOriginalBackgroundImage } from "@root/questions";
import { CropModal } from "@ui_kit/Modal/CropModal";
import UploadIcon from "../../../assets/icons/UploadIcon";
import UploadBox from "@ui_kit/UploadBox";
import * as React from "react";
import { questionStore, updateQuestionsList } from "@root/questions";
import { useParams } from "react-router-dom";
import UploadIcon from "../../../assets/icons/UploadIcon";
import { UploadImageModal } from "./UploadImageModal";
import { openCropModal } from "@root/cropModal";
import { QuizQuestionBase } from "model/questionTypes/shared";
import type { DragEvent } from "react";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
type UploadImageProps = {
totalIndex: number;
totalIndex: number;
};
export default function UploadImage({ totalIndex }: UploadImageProps) {
const quizId = Number(useParams().quizId);
const theme = useTheme();
const [open, setOpen] = React.useState(false);
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionImages;
const quizId = Number(useParams().quizId);
const theme = useTheme();
const [isUploadImageModalOpen, setIsUploadImageModalOpen] = React.useState(false);
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionBase;
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const imgHC = (files: FileList | null) => {
if (files?.length) {
const [file] = Array.from(files);
const handleImageUpload = (files: FileList | null) => {
if (!files?.length) return;
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content: {
...question.content,
back: URL.createObjectURL(file),
},
});
const [file] = Array.from(files);
handleClose();
setOpened(true);
const url = URL.createObjectURL(file);
setQuestionBackgroundImage(quizId, totalIndex, url);
setQuestionOriginalBackgroundImage(quizId, totalIndex, url);
setIsUploadImageModalOpen(false);
openCropModal(url, url);
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
handleImageUpload(event.dataTransfer.files);
};
function handleCropModalSaveClick(url: string) {
setQuestionBackgroundImage(quizId, totalIndex, url);
}
};
const [opened, setOpened] = useState<boolean>(false);
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
imgHC(event.dataTransfer.files);
};
return (
<Box sx={{ padding: "20px" }}>
<Typography
sx={{
fontWeight: 500,
color: theme.palette.grey3.main,
mt: "11px",
mb: "14px",
}}
>
Загрузить изображение
</Typography>
<ButtonBase
onClick={handleOpen}
sx={{ width: "100%", maxWidth: "260px" }}
>
<UploadBox
handleDrop={handleDrop}
sx={{ maxWidth: "260px" }}
icon={<UploadIcon />}
text="5 MB максимум"
/>
</ButtonBase>
<UploadImageModal open={open} onClose={handleClose} imgHC={imgHC} />
<CropModal
opened={opened}
onClose={() => setOpened(false)}
picture={question.content.back}
/>
</Box>
);
return (
<Box sx={{ padding: "20px" }}>
<Typography
sx={{
fontWeight: 500,
color: theme.palette.grey3.main,
mt: "11px",
mb: "14px",
}}
>
Загрузить изображение
</Typography>
<ButtonBase
onClick={() => setIsUploadImageModalOpen(true)}
sx={{
width: "100%",
maxWidth: "260px",
height: "120px",
}}
>
{question.content.back ?
<img
src={question.content.back}
alt="question background"
style={{
width: "100%",
height: "100%",
objectFit: "scale-down",
display: "block",
}}
/>
:
<UploadBox
handleDrop={handleDrop}
sx={{ maxWidth: "260px" }}
icon={<UploadIcon />}
text="5 MB максимум"
/>
}
</ButtonBase>
<UploadImageModal
open={isUploadImageModalOpen}
onClose={() => setIsUploadImageModalOpen(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
</Box>
);
}

@ -27,7 +27,7 @@ export default function AnswerOptions({ totalIndex }: Props) {
const addNewAnswer = () => {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" });
answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },

File diff suppressed because it is too large Load Diff

@ -1,7 +1,8 @@
import { Box } from "@mui/material";
import Cytoscape from "react-cytoscapejs";
import { createGraphElements } from "./helper";
import { ELEMENTS } from "./elements";
import { VERTICES } from "./elements";
import type { Stylesheet } from "cytoscape";
@ -13,11 +14,6 @@ const stylesheet: Stylesheet[] = [
width: 130,
height: 130,
backgroundColor: "#FFFFFF",
},
},
{
selector: "node[label]",
style: {
label: "data(label)",
"font-size": "16",
color: "#4D4D4D",
@ -31,9 +27,8 @@ const stylesheet: Stylesheet[] = [
width: 30,
"line-color": "#DEDFE7",
"curve-style": "taxi",
"taxi-direction": "downward",
"taxi-turn": 20,
"taxi-turn-min-distance": 5,
"taxi-direction": "horizontal",
"taxi-turn": 60,
},
},
{
@ -46,24 +41,22 @@ const stylesheet: Stylesheet[] = [
},
];
export const Graph = () => {
return (
<Box
sx={{
padding: "20px",
background: "#FFFFFF",
borderRadius: "12px",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
marginTop: "30px",
}}
>
<Cytoscape
wheelSensitivity={0.1}
elements={ELEMENTS}
style={{ height: "480px", background: "#F2F3F7" }}
layout={{ name: "breadthfirst" }}
stylesheet={stylesheet}
/>
</Box>
);
};
export const Graph = () => (
<Box
sx={{
padding: "20px",
background: "#FFFFFF",
borderRadius: "12px",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
marginTop: "30px",
}}
>
<Cytoscape
wheelSensitivity={0.1}
elements={createGraphElements(VERTICES)}
style={{ height: "480px", background: "#F2F3F7" }}
layout={{ name: "preset" }}
stylesheet={stylesheet}
/>
</Box>
);

@ -0,0 +1,140 @@
import type { ElementDefinition } from "cytoscape";
import type { Vertex } from "./elements";
const sortElementsByLevel = (vertices: Vertex[]): Record<number, Vertex[]> => {
const sortedVertices: Record<number, Vertex[]> = {};
vertices.forEach((vertex) => {
if (!sortedVertices[vertex.level]) {
sortedVertices[vertex.level] = [];
}
sortedVertices[vertex.level].push(vertex);
});
return sortedVertices;
};
const getCurrentIndent = (
id: string,
edges: string[] | undefined,
vertices: Vertex[]
): [number, number] => {
let currentIndent = 0;
let bottomIndent = 0;
if (!edges?.length) {
return [100, 0];
}
const calculatePointIndent = (pointId: string, subIndent = false) => {
const point = vertices.find(({ id }) => pointId === id);
if (point?.edges) {
if (!subIndent) {
currentIndent += point.edges.length * 100;
} else {
bottomIndent += (point.edges.length - 1) * 200;
}
point.edges.forEach((edge) => {
calculatePointIndent(edge, true);
});
}
};
calculatePointIndent(id);
return [currentIndent, bottomIndent];
};
const sortVertices = (
elements: ElementDefinition[],
vertices: Vertex[]
): ElementDefinition[] => {
const sortedVertices: ElementDefinition[] = [];
elements.forEach((element) => {
const currentVertex = vertices.find(({ id }) => element.data.id === id);
if (!currentVertex || (currentVertex?.edges?.length || 0) < 2) {
sortedVertices.push(element);
return;
}
const firstChildId = currentVertex.edges?.at(0);
const lastChildId = currentVertex.edges?.at(-1);
const firstChild = elements.find(({ data }) => data.id === firstChildId);
const lastChild = elements.find(({ data }) => data.id === lastChildId);
if (!firstChild?.position || !lastChild?.position) {
sortedVertices.push(element);
return;
}
const parentVertexTopIndent =
firstChild.position.y +
(lastChild.position.y - firstChild.position.y) / 2;
sortedVertices.push({
...element,
position: { x: element.position?.x || 0, y: parentVertexTopIndent },
});
});
return sortedVertices;
};
export const createGraphElements = (
vertices: Vertex[]
): ElementDefinition[] => {
const elements: ElementDefinition[] = [];
const bridges: ElementDefinition[] = [];
const sortedVertices = sortElementsByLevel(vertices);
Object.values(sortedVertices).forEach((vertexItems) => {
let indent = 0;
vertexItems.forEach(({ id, label, level, edges }) => {
const [currentIndent, bottomIndent] = getCurrentIndent(
id,
edges,
vertices
);
const parentVertex = vertices.find(({ edges }) => edges?.includes(id));
const parentElement = elements.find(
({ data }) => parentVertex?.id === data.id
);
const minTopIndent =
(parentElement?.position?.y || 0) -
(parentVertex?.edges?.length || 0) * 100;
if (minTopIndent > indent) {
indent = minTopIndent;
}
elements.push({
data: { id, label },
position: {
x: level * 250,
y: indent + currentIndent,
},
});
edges?.forEach((edge) => {
bridges.push({
data: { source: id, target: edge },
});
});
indent += currentIndent * 2 + bottomIndent;
});
});
const sortedElements = sortVertices(elements, vertices);
return [...sortedElements, ...bridges];
};

@ -2,27 +2,17 @@ import { Box, Typography, useTheme, useMediaQuery } from "@mui/material";
import AddImage from "@icons/questionsPage/addImage";
import AddVideofile from "@icons/questionsPage/addVideofile";
import { useState } from "react";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { openCropModal } from "@root/cropModal";
export default function ImageAndVideoButtons() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(400));
const [opened, setOpened] = useState<boolean>(false);
return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "12px",
mt: "20px",
mb: "20px",
}}
>
<AddImage onClick={() => setOpened(true)} />
<CropModal opened={opened} onClose={() => setOpened(false)} />
<Box sx={{ display: "flex", alignItems: "center", gap: "12px", mt: "20px", mb: "20px" }}>
<AddImage onClick={() => openCropModal("", "")} />
<CropModal />
<Typography
sx={{
fontWeight: 400,

@ -34,6 +34,7 @@ export default function FirstQuiz() {
</Typography>
<Button
variant="contained"
data-cy="create-quiz"
onClick={() => {
navigate(`/setting/${createBlank()}`);
}}

@ -848,26 +848,24 @@ export default function StartPageSettings() {
justifyContent: "flex-end",
}}
/>
<Box>
<Button
variant="contained"
sx={{ display: "block", marginLeft: "auto" }}
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.startpage.background.desktop =
"https://happypik.ru/wp-content/uploads/2019/09/njashnye-kotiki8.jpg";
SPageClone.startpage.background.mobile =
"https://krot.info/uploads/posts/2022-03/1646156155_3-krot-info-p-smeshnie-tolstie-koti-smeshnie-foto-3.png";
SPageClone.startpage.background.video =
"https://youtu.be/dbaPkCiLPKQ";
updateQuizesList(params, { config: SPageClone });
handleNext();
createQuestion(params);
}}
>
Настроить вопросы
</Button>
</Box>
<Button
variant="contained"
data-cy="setup-questions"
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.startpage.background.desktop =
"https://happypik.ru/wp-content/uploads/2019/09/njashnye-kotiki8.jpg";
SPageClone.startpage.background.mobile =
"https://krot.info/uploads/posts/2022-03/1646156155_3-krot-info-p-smeshnie-tolstie-koti-smeshnie-foto-3.png";
SPageClone.startpage.background.video =
"https://youtu.be/dbaPkCiLPKQ";
updateQuizesList(params, { config: SPageClone });
handleNext();
createQuestion(params);
}}
>
Настроить вопросы
</Button>
</Box>
</>
);

@ -28,6 +28,7 @@ export default function StepOne() {
>
<Button
variant="text"
data-cy="create-quiz-card"
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.type = "quize";

66
src/stores/cropModal.ts Normal file

@ -0,0 +1,66 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type CropModalStore = {
isCropModalOpen: boolean;
imageUrl: string | null;
originalImageUrl: string | null;
};
const initialState: CropModalStore = {
isCropModalOpen: false,
imageUrl: null,
originalImageUrl: null,
};
export const useCropModalStore = create<CropModalStore>()(
devtools(
() => initialState,
{
name: "CropModalStore",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
}
),
);
export const openCropModal = (imageUrl: string, originalImageUrl: string) => useCropModalStore.setState(
{
isCropModalOpen: true,
imageUrl,
originalImageUrl,
},
false,
{
type: "openCropModal",
imageUrl,
originalImageUrl,
}
);
export const closeCropModal = () => useCropModalStore.setState(
initialState,
false,
"closeCropModal"
);
export const setCropModalImageUrl = (imageUrl: string | null) => useCropModalStore.setState(
{ imageUrl },
false,
{
type: "setCropModalImageUrl",
imageUrl,
}
);
export const resetToOriginalImage = (): boolean => {
if (!useCropModalStore.getState().originalImageUrl) return false;
useCropModalStore.setState(
state => ({ imageUrl: state.originalImageUrl }),
false,
"resetToOriginalImage"
);
return true;
};

@ -1,17 +1,12 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { QuizQuestionEmoji } from "../model/questionTypes/emoji";
import { QuizQuestionImages } from "../model/questionTypes/images";
import { QuizQuestionVariant } from "../model/questionTypes/variant";
import { QuizQuestionVarImg } from "../model/questionTypes/varimg";
import { devtools, persist } from "zustand/middleware";
import type {
AnyQuizQuestion,
QuizQuestionType,
QuestionVariant,
AnyQuizQuestion,
QuizQuestionType
} from "../model/questionTypes/shared";
import { produce, setAutoFreeze } from "immer";
import { QUIZ_QUESTION_BASE } from "../constants/base";
import { QUIZ_QUESTION_DATE } from "../constants/date";
import { QUIZ_QUESTION_EMOJI } from "../constants/emoji";
@ -24,174 +19,303 @@ import { QUIZ_QUESTION_SELECT } from "../constants/select";
import { QUIZ_QUESTION_TEXT } from "../constants/text";
import { QUIZ_QUESTION_VARIANT } from "../constants/variant";
import { QUIZ_QUESTION_VARIMG } from "../constants/varimg";
import { setAutoFreeze } from "immer";
setAutoFreeze(false);
interface QuestionStore {
listQuestions: Record<string, AnyQuizQuestion[]>;
openedModalSettings: string;
listQuestions: Record<string, AnyQuizQuestion[]>;
openedModalSettings: string;
}
let isFirstPartialize = true;
export const questionStore = create<QuestionStore>()(
persist<QuestionStore>(
() => ({
listQuestions: {},
openedModalSettings: "",
}),
{
name: "question",
partialize: (state: QuestionStore) => {
if (isFirstPartialize) {
isFirstPartialize = false;
persist(
devtools(
() => ({
listQuestions: {},
openedModalSettings: "",
}),
{
name: "Question",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
actionsBlacklist: "ignored",
}
),
{
name: "question",
partialize: (state: QuestionStore) => {
if (isFirstPartialize) {
isFirstPartialize = false;
Object.keys(state.listQuestions).forEach((quizId) => {
[...state.listQuestions[quizId]].forEach(({ id, deleted }) => {
if (deleted) {
const removedItemIndex = state.listQuestions[quizId].findIndex(
(item) => item.id === id
);
Object.keys(state.listQuestions).forEach((quizId) => {
[...state.listQuestions[quizId]].forEach(({ id, deleted }) => {
if (deleted) {
const removedItemIndex = state.listQuestions[quizId].findIndex(
(item) => item.id === id
);
state.listQuestions[quizId].splice(removedItemIndex, 1);
}
});
});
state.listQuestions[quizId].splice(removedItemIndex, 1);
}
});
});
}
return state;
},
merge: (persistedState, currentState) => {
const state = persistedState as QuestionStore;
// replace blob urls with ""
Object.values(state.listQuestions).forEach(questions => {
questions.forEach(question => {
if (question.type === "page") {
if (question.content.picture.startsWith("blob:")) {
question.content.picture = "";
}
}
if (question.type === "images") {
question.content.variants.forEach(variant => {
if (variant.extendedText.startsWith("blob:")) {
variant.extendedText = "";
}
if (variant.originalImageUrl.startsWith("blob:")) {
variant.originalImageUrl = "";
}
});
}
if (question.type === "varimg") {
question.content.variants.forEach(variant => {
if (variant.extendedText.startsWith("blob:")) {
variant.extendedText = "";
}
if (variant.originalImageUrl.startsWith("blob:")) {
variant.originalImageUrl = "";
}
});
}
});
});
return {
...currentState,
...state,
};
},
}
return state;
},
merge: (persistedState, currentState) => {
const state = persistedState as QuestionStore;
// replace blob urls with ""
Object.values(state.listQuestions).forEach((questions) => {
questions.forEach((question) => {
if (
question.type === "page" &&
question.content.picture.startsWith("blob:")
) {
question.content.picture = "";
}
if (question.type === "images") {
question.content.variants.forEach((variant) => {
if (variant.extendedText.startsWith("blob:")) {
variant.extendedText = "";
}
});
}
if (question.type === "varimg") {
question.content.variants.forEach((variant) => {
if (variant.extendedText.startsWith("blob:")) {
variant.extendedText = "";
}
});
}
});
});
return {
...currentState,
...state,
};
},
}
)
)
);
export const updateQuestionsList = <T = AnyQuizQuestion>(
quizId: number,
index: number,
data: Partial<T>
quizId: number,
index: number,
data: Partial<T>
) => {
const questionListClone = { ...questionStore.getState()["listQuestions"] };
questionListClone[quizId][index] = {
...questionListClone[quizId][index],
...data,
};
questionStore.setState({ listQuestions: questionListClone });
const questionListClone = { ...questionStore.getState()["listQuestions"] };
questionListClone[quizId][index] = {
...questionListClone[quizId][index],
...data,
};
questionStore.setState({ listQuestions: questionListClone });
};
export const updateQuestion = <T extends AnyQuizQuestion>(
quizId: number,
questionIndex: number,
recipe: (question: T) => void,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex] as T;
recipe(question);
}, {
type: "updateQuestion",
quizId,
questionIndex,
recipe,
});
export const removeQuestionsByQuizId = (quizId: number) => setProducedState(state => {
delete state.listQuestions[quizId];
}, "removeQuestionsByQuizId");
export const setVariantImageUrl = (
quizId: number,
questionIndex: number,
variantIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (!("variants" in question.content)) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
if (variant.extendedText === url) return;
if (variant.extendedText !== variant.originalImageUrl) URL.revokeObjectURL(variant.extendedText);
variant.extendedText = url;
}, {
type: "setVariantImageUrl",
quizId,
questionIndex,
variantIndex,
url,
});
export const setVariantOriginalImageUrl = (
quizId: number,
questionIndex: number,
variantIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (!("variants" in question.content)) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
if (variant.originalImageUrl === url) return;
URL.revokeObjectURL(variant.originalImageUrl);
variant.originalImageUrl = url;
}, {
type: "setVariantOriginalImageUrl",
quizId,
questionIndex,
variantIndex,
url,
});
export const setPageQuestionPicture = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.type !== "page") return;
if (question.content.picture === url) return;
if (
question.content.picture !== question.content.originalPicture
) URL.revokeObjectURL(question.content.picture);
question.content.picture = url;
});
export const setPageQuestionOriginalPicture = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.type !== "page") return;
if (question.content.originalPicture === url) return;
URL.revokeObjectURL(question.content.originalPicture);
question.content.originalPicture = url;
});
export const setQuestionBackgroundImage = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.content.back === url) return;
if (
question.content.back !== question.content.originalBack
) URL.revokeObjectURL(question.content.back);
question.content.back = url;
})
export const setQuestionOriginalBackgroundImage = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.content.originalBack === url) return;
URL.revokeObjectURL(question.content.originalBack);
question.content.originalBack = url;
})
export const updateQuestionsListDragAndDrop = (
quizId: number,
updatedQuestions: AnyQuizQuestion[]
quizId: number,
updatedQuestions: AnyQuizQuestion[]
) => {
const questionListClone = { ...questionStore.getState()["listQuestions"] };
questionStore.setState({
listQuestions: { ...questionListClone, [quizId]: updatedQuestions },
});
const questionListClone = { ...questionStore.getState()["listQuestions"] };
questionStore.setState({
listQuestions: { ...questionListClone, [quizId]: updatedQuestions },
});
};
export const updateVariants = (
quizId: number,
index: number,
variants: QuestionVariant[]
) => {
const listQuestions = { ...questionStore.getState()["listQuestions"] };
switch (listQuestions[quizId][index].type) {
case "emoji":
const emojiState = listQuestions[quizId][index] as QuizQuestionEmoji;
emojiState.content.variants = variants;
return questionStore.setState({ listQuestions });
export const reorderVariants = (
quizId: number,
questionIndex: number,
sourceIndex: number,
destinationIndex: number,
) => setProducedState(state => {
if (sourceIndex === destinationIndex) return;
case "images":
const imagesState = listQuestions[quizId][index] as QuizQuestionImages;
imagesState.content.variants = variants;
return questionStore.setState({ listQuestions });
const question = state.listQuestions[quizId][questionIndex];
if (!("variants" in question.content)) return;
case "variant":
const variantState = listQuestions[quizId][index] as QuizQuestionVariant;
variantState.content.variants = variants;
return questionStore.setState({ listQuestions });
case "varimg":
const varImgState = listQuestions[quizId][index] as QuizQuestionVarImg;
varImgState.content.variants = variants;
return questionStore.setState({ listQuestions });
}
};
const [removed] = question.content.variants.splice(sourceIndex, 1);
question.content.variants.splice(destinationIndex, 0, removed);
}, {
type: sourceIndex === destinationIndex ? "reorderVariants" : "ignored",
quizId,
questionIndex,
sourceIndex,
destinationIndex,
});
export const createQuestion = (
quizId: number,
questionType: QuizQuestionType = "nonselected",
placeIndex = -1
quizId: number,
questionType: QuizQuestionType = "nonselected",
placeIndex = -1
) => {
const id = getRandom();
const newData = { ...questionStore.getState()["listQuestions"] };
const id = getRandom();
const newData = { ...questionStore.getState()["listQuestions"] };
if (!newData[quizId]) {
newData[quizId] = [];
}
if (!newData[quizId]) {
newData[quizId] = [];
}
const defaultObject = [
QUIZ_QUESTION_BASE,
QUIZ_QUESTION_DATE,
QUIZ_QUESTION_EMOJI,
QUIZ_QUESTION_FILE,
QUIZ_QUESTION_IMAGES,
QUIZ_QUESTION_NUMBER,
QUIZ_QUESTION_PAGE,
QUIZ_QUESTION_RATING,
QUIZ_QUESTION_SELECT,
QUIZ_QUESTION_TEXT,
QUIZ_QUESTION_VARIANT,
QUIZ_QUESTION_VARIMG,
].find((defaultObjectItem) => defaultObjectItem.type === questionType);
const defaultObject = [
QUIZ_QUESTION_BASE,
QUIZ_QUESTION_DATE,
QUIZ_QUESTION_EMOJI,
QUIZ_QUESTION_FILE,
QUIZ_QUESTION_IMAGES,
QUIZ_QUESTION_NUMBER,
QUIZ_QUESTION_PAGE,
QUIZ_QUESTION_RATING,
QUIZ_QUESTION_SELECT,
QUIZ_QUESTION_TEXT,
QUIZ_QUESTION_VARIANT,
QUIZ_QUESTION_VARIMG,
].find((defaultObjectItem) => defaultObjectItem.type === questionType);
if (defaultObject) {
newData[quizId].splice(
placeIndex < 0 ? newData[quizId].length : placeIndex,
0,
{ ...defaultObject, id }
);
if (defaultObject) {
newData[quizId].splice(
placeIndex < 0 ? newData[quizId].length : placeIndex,
0,
{ ...JSON.parse(JSON.stringify(defaultObject)), id }
);
questionStore.setState({ listQuestions: newData });
}
questionStore.setState({ listQuestions: newData });
}
};
export const copyQuestion = (quizId: number, copiedQuestionIndex: number) => {
const listQuestions = { ...questionStore.getState()["listQuestions"] };
const listQuestions = { ...questionStore.getState()["listQuestions"] };
const copiedQuiz = { ...listQuestions[quizId][copiedQuestionIndex] };
listQuestions[quizId].splice(copiedQuestionIndex, 0, {
@ -199,43 +323,51 @@ export const copyQuestion = (quizId: number, copiedQuestionIndex: number) => {
id: getRandom(),
});
questionStore.setState({ listQuestions });
questionStore.setState({ listQuestions });
};
export const removeQuestionForce = (quizId: number, removedId: number) => {
const questionListClone = { ...questionStore.getState()["listQuestions"] };
const removedItemIndex = questionListClone[quizId].findIndex(
({ id }) => id === removedId
);
questionListClone[quizId].splice(removedItemIndex, 1);
questionStore.setState({ listQuestions: questionListClone });
const questionListClone = { ...questionStore.getState()["listQuestions"] };
const removedItemIndex = questionListClone[quizId].findIndex(
({ id }) => id === removedId
);
questionListClone[quizId].splice(removedItemIndex, 1);
questionStore.setState({ listQuestions: questionListClone });
};
export const removeQuestion = (quizId: number, index: number) => {
const questionListClone = { ...questionStore.getState()["listQuestions"] };
questionListClone[quizId][index].deleted = true;
questionStore.setState({ listQuestions: questionListClone });
const questionListClone = { ...questionStore.getState()["listQuestions"] };
questionListClone[quizId][index].deleted = true;
questionStore.setState({ listQuestions: questionListClone });
};
export const resetSomeField = (data: Record<string, string>) => {
questionStore.setState(data);
questionStore.setState(data);
};
export const findQuestionById = (quizId: number) => {
let found = null;
questionStore
.getState()
let found = null;
questionStore
.getState()
["listQuestions"][quizId].some((quiz: AnyQuizQuestion, index: number) => {
if (quiz.id === quizId) {
found = { quiz, index };
return true;
}
if (quiz.id === quizId) {
found = { quiz, index };
return true;
}
return false;
});
return found;
return found;
};
function getRandom() {
const min = Math.ceil(1000000);
const max = Math.floor(10000000);
return Math.floor(Math.random() * (max - min)) + min;
const min = Math.ceil(1000000);
const max = Math.floor(10000000);
return Math.floor(Math.random() * (max - min)) + min;
}
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuestionStore) => void,
action?: A,
) {
questionStore.setState(state => produce(state, recipe), false, action);
}

@ -1,5 +1,6 @@
import {create} from "zustand";
import {persist} from "zustand/middleware";
import { removeQuestionsByQuizId } from "./questions";
interface QuizStore {
listQuizes: { [key: number]: Quizes };
@ -87,6 +88,8 @@ export const quizStore = create<QuizStore>()(
return accumulator;
}, {});
set({listQuizes: newState});
removeQuestionsByQuizId(id);
},
createBlank: () => {
const id = getRandom(1000000, 10000000)
@ -160,4 +163,4 @@ function getRandom(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min;
}
}

@ -0,0 +1,63 @@
import Plus from "@icons/questionsPage/plus";
import { Box, Button, SxProps, Theme } from "@mui/material";
import Image from "../assets/icons/questionsPage/image";
interface Props {
sx?: SxProps<Theme>;
imageSrc?: string;
onImageClick?: () => void;
onPlusClick?: () => void;
}
export default function AddOrEditImageButton({ onImageClick, onPlusClick, sx, imageSrc }: Props) {
return (
<Box sx={{
display: "flex",
height: "40px",
minWidth: "60px",
width: "60px",
borderRadius: "3px",
overflow: "hidden",
...sx,
}}>
<Button
onClick={onImageClick}
sx={{
p: 0,
minWidth: "40px",
flexGrow: 1,
backgroundColor: "#EEE4FC",
}}>
{imageSrc ? (
<img
src={imageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "scale-down",
display: "block",
}}
/>
) : (
<Image sx={{
height: "100%",
width: "100%",
}} />
)}
</Button>
<Button
onClick={onPlusClick}
sx={{
p: 0,
minWidth: "20px",
width: "20px",
}}
>
<Plus />
</Button>
</Box>
);
}

@ -1,163 +1,110 @@
import React, { useState, useRef, useEffect, FC } from "react";
import ReactCrop, { Crop, PixelCrop } from "react-image-crop";
import { CropIcon } from "@icons/CropIcon";
import { ResetIcon } from "@icons/ResetIcon";
import {
Box,
Button,
IconButton,
Modal,
Slider,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { canvasPreview } from "./utils/canvasPreview";
import { useDebounceEffect } from "./utils/useDebounceEffect";
import { ResetIcon } from "@icons/ResetIcon";
import { closeCropModal, resetToOriginalImage, setCropModalImageUrl, useCropModalStore } from "@root/cropModal";
import { FC, useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { CropIcon } from "@icons/CropIcon";
import { canvasPreview } from "./utils/canvasPreview";
import { enqueueSnackbar } from "notistack";
interface Iprops {
opened: boolean;
onClose: React.Dispatch<React.SetStateAction<boolean>>;
picture?: string;
onCropPress?: (imageUrl: string) => void;
}
export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress }) => {
const [imgSrc, setImgSrc] = useState("");
const imgRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const hiddenAnchorRef = useRef<HTMLAnchorElement>(null);
const blobUrlRef = useRef("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [rotate, setRotate] = useState(0);
const [darken, setDarken] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(786));
useEffect(() => {
if (picture) {
setImgSrc(picture);
}
}, [picture]);
const styleModal = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: isMobile ? "343px" : "620px",
bgcolor: "background.paper",
boxShadow: 24,
padding: "20px",
borderRadius: "8px",
};
const styleSlider = {
width: isMobile ? "350px" : "250px",
color: "#7E2AEA",
height: "12px",
"& .MuiSlider-track": {
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: "#F2F3F7",
border: `1px solid #9A9AAF`,
},
"& .MuiSlider-thumb": {
height: 26,
width: 26,
border: `6px solid #7E2AEA`,
backgroundColor: "white",
const styleSlider: SxProps<Theme> = {
color: "#7E2AEA",
height: "12px",
"& .MuiSlider-track": {
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: "#F2F3F7",
border: `1px solid #9A9AAF`,
},
"& .MuiSlider-thumb": {
height: 26,
width: 26,
border: `6px solid #7E2AEA`,
backgroundColor: "white",
boxShadow: `0px 0px 0px 3px white,
0px 4px 4px 3px #C3C8DD`,
"&:focus, &:hover, &.Mui-active, &.Mui-focusVisible": {
boxShadow: `0px 0px 0px 3px white,
0px 4px 4px 3px #C3C8DD`,
"&:focus, &:hover, &.Mui-active, &.Mui-focusVisible": {
boxShadow: `0px 0px 0px 3px white,
0px 4px 4px 3px #C3C8DD`,
},
},
};
},
};
const rotateImage = () => {
const newRotation = (rotate + 90) % 360;
setRotate(newRotation);
};
interface Props {
onSaveImageClick?: (imageUrl: string) => void;
}
const onSelectFile = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setCrop(undefined);
const reader = new FileReader();
reader.addEventListener("load", () =>
setImgSrc(reader.result?.toString() || "")
);
reader.readAsDataURL(event.target.files[0]);
}
};
export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
const theme = useTheme();
const isCropModalOpen = useCropModalStore(state => state.isCropModalOpen);
const imageUrl = useCropModalStore(state => state.imageUrl);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [darken, setDarken] = useState(0);
const [rotate, setRotate] = useState(0);
const [width, setWidth] = useState<number>(0);
const cropImageElementRef = useRef<HTMLImageElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const onDownloadCropClick = () => {
if (!previewCanvasRef.current) {
throw new Error("Crop canvas does not exist");
}
const handleCropClick = async () => {
if (!completedCrop) throw new Error("No completed crop");
if (!cropImageElementRef.current) throw new Error("No image");
const canvasCopy = document.createElement("canvas");
const ctx = canvasCopy.getContext("2d");
canvasCopy.width = previewCanvasRef.current.width;
canvasCopy.height = previewCanvasRef.current.height;
ctx!.filter = `brightness(${100 - darken}%)`;
ctx!.drawImage(previewCanvasRef.current, 0, 0);
if (!ctx) throw new Error("No 2d context");
canvasCopy.width = completedCrop.width;
canvasCopy.height = completedCrop.height;
ctx.filter = `brightness(${100 - darken}%)`;
await canvasPreview(cropImageElementRef.current, canvasCopy, completedCrop, rotate);
canvasCopy.toBlob((blob) => {
if (!blob) {
throw new Error("Failed to create blob");
}
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
hiddenAnchorRef.current!.href = blobUrlRef.current;
hiddenAnchorRef.current!.click();
const newImageUrl = URL.createObjectURL(blob);
setImgSrc(blobUrlRef.current);
onCropPress?.(blobUrlRef.current);
setCropModalImageUrl(newImageUrl);
setCrop(undefined);
setCompletedCrop(undefined);
});
};
useDebounceEffect(
async () => {
if (
completedCrop?.width &&
completedCrop?.height &&
imgRef.current &&
previewCanvasRef.current
) {
canvasPreview(
imgRef.current,
previewCanvasRef.current,
completedCrop,
rotate
);
}
},
100,
[completedCrop, rotate]
);
function handleSaveClick() {
if (imageUrl) onSaveImageClick?.(imageUrl);
setCrop(undefined);
setCompletedCrop(undefined);
closeCropModal();
}
const [width, setWidth] = useState<number>(0);
function handleLoadOriginalImage() {
const isSuccess = resetToOriginalImage();
if (!isSuccess) enqueueSnackbar("Не удалось восстановить оригинал. Приносим глубочайшие извинения");
}
const getImageSize = () => {
if (imgRef.current) {
const imageWidth = imgRef.current.naturalWidth;
const imageHeight = imgRef.current.naturalHeight;
if (cropImageElementRef.current) {
const imageWidth = cropImageElementRef.current.naturalWidth;
const imageHeight = cropImageElementRef.current.naturalHeight;
const aspect = imageWidth / imageHeight;
if (aspect <= 1.333) {
setWidth(240);
}
@ -171,184 +118,173 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
};
return (
<>
<Modal
open={opened}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={styleModal}>
<Box
sx={{
height: "320px",
padding: "10px",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{imgSrc && (
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
maxWidth={500}
minWidth={50}
maxHeight={320}
minHeight={50}
>
<img
onLoad={getImageSize}
ref={imgRef}
alt="Crop me"
src={imgSrc}
style={{
filter: `brightness(${100 - darken}%)`,
transform: ` rotate(${rotate}deg)`,
maxWidth: "580px",
maxHeight: "320px",
}}
width={width}
/>
</ReactCrop>
)}
</Box>
<Box
sx={{
color: "#7E2AEA",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "16xp",
fontWeight: "600",
marginBottom: "50px",
}}
>
<Typography sx={{ color: "#7E2AEA", lineHeight: "0px" }}>
{crop?.width ? Math.round(crop.width) + "px" : ""}
</Typography>
<Typography sx={{ color: "#7E2AEA", lineHeight: "0px" }}>
{crop?.height ? Math.round(crop.height) + "px" : ""}
</Typography>
</Box>
<Box
sx={{
display: isMobile ? "block" : "flex",
alignItems: "end",
justifyContent: "space-between",
}}
>
<ResetIcon
onClick={rotateImage}
style={{ marginBottom: "10px", cursor: "pointer" }}
/>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Размер
</Typography>
<Slider
sx={styleSlider}
value={width}
min={50}
max={580}
step={1}
onChange={(_, newValue) => {
setWidth(newValue as number);
}}
/>
</Box>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Затемнение
</Typography>
<Slider
sx={styleSlider}
value={darken}
min={0}
max={100}
step={1}
onChange={(_, newValue) => setDarken(newValue as number)}
/>
</Box>
</Box>
<Box
sx={{
marginTop: "40px",
width: "100%",
display: "flex",
justifyContent: "end",
}}
>
<input
style={{ display: "none", zIndex: "-999" }}
ref={fileInputRef}
type="file"
accept="image/*"
onChange={onSelectFile}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disableRipple
sx={{
width: "215px",
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
marginRight: "10px",
}}
>
Загрузить оригинал
</Button>
<Button
onClick={onDownloadCropClick}
disableRipple
variant="contained"
sx={{
padding: "10px 20px",
borderRadius: "8px",
background: theme.palette.brightPurple.main,
fontSize: "18px",
}}
>
<CropIcon />
Обрезать
</Button>
</Box>
</Box>
</Modal>
{completedCrop && (
<div>
<canvas
ref={previewCanvasRef}
style={{
display: "none",
zIndex: "-999",
border: "1px solid black",
objectFit: "contain",
width: completedCrop.width,
height: completedCrop.height,
}}
/>
</div>
)}
<div>
<a
href="#hidden"
ref={hiddenAnchorRef}
download
style={{
display: "none",
<Modal
open={isCropModalOpen}
onClose={closeCropModal}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
padding: "20px",
borderRadius: "8px",
width: isMobile ? "343px" : "620px",
}}>
<Box
sx={{
height: "320px",
padding: "10px",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
Hidden download
</a>
</div>
</>
{imageUrl && (
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
maxWidth={500}
minWidth={50}
maxHeight={320}
minHeight={50}
>
<img
onLoad={getImageSize}
ref={cropImageElementRef}
alt="Crop me"
src={imageUrl}
style={{
filter: `brightness(${100 - darken}%)`,
transform: ` rotate(${rotate}deg)`,
maxWidth: "580px",
maxHeight: "320px",
}}
width={width}
/>
</ReactCrop>
)}
</Box>
<Box
sx={{
color: "#7E2AEA",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "16xp",
fontWeight: "600",
marginBottom: "50px",
}}
>
<Typography sx={{ color: "#7E2AEA", lineHeight: "0px" }}>
{crop?.width ? Math.round(crop.width) + "px" : ""}
</Typography>
<Typography sx={{ color: "#7E2AEA", lineHeight: "0px" }}>
{crop?.height ? Math.round(crop.height) + "px" : ""}
</Typography>
</Box>
<Box
sx={{
display: isMobile ? "block" : "flex",
alignItems: "end",
justifyContent: "space-between",
}}
>
<IconButton onClick={() => setRotate(r => (r + 90) % 360)}>
<ResetIcon />
</IconButton>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Размер
</Typography>
<Slider
sx={[styleSlider, {
width: isMobile ? "350px" : "250px",
}]}
value={width}
min={50}
max={580}
step={1}
onChange={(_, newValue) => {
setWidth(newValue as number);
}}
/>
</Box>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Затемнение
</Typography>
<Slider
sx={[styleSlider, {
width: isMobile ? "350px" : "250px",
}]}
value={darken}
min={0}
max={100}
step={1}
onChange={(_, newValue) => setDarken(newValue as number)}
/>
</Box>
</Box>
<Box
sx={{
marginTop: "40px",
width: "100%",
display: "flex",
}}
>
<Button
onClick={handleSaveClick}
disableRipple
sx={{
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
marginRight: "10px",
px: "20px",
}}
>Сохранить</Button>
<Button
onClick={handleLoadOriginalImage}
disableRipple
sx={{
width: "215px",
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
marginRight: "10px",
ml: "auto",
}}
>
Загрузить оригинал
</Button>
<Button
onClick={handleCropClick}
disableRipple
variant="contained"
disabled={!completedCrop}
sx={{
padding: "10px 20px",
borderRadius: "8px",
background: theme.palette.brightPurple.main,
fontSize: "18px",
}}
>
<CropIcon />
Обрезать
</Button>
</Box>
</Box>
</Modal>
);
};

@ -1,13 +1,14 @@
import { Box, Button } from "@mui/material";
import { FC, useState } from "react";
import { FC } from "react";
import { CropModal } from "./CropModal";
import { openCropModal } from "@root/cropModal";
const ImageCrop: FC = () => {
const [opened, setOpened] = useState<boolean>(false);
return (
<Box>
<Button onClick={() => setOpened(true)}>Открыть модалку</Button>
<CropModal opened={opened} onClose={() => setOpened(false)} />
<Button onClick={() => openCropModal("", "")}>Открыть модалку</Button>
<CropModal />
</Box>
);
};

@ -7,15 +7,17 @@ interface QuestionsMiniButtonProps {
icon: ReactNode;
text: string;
onClick: () => void;
dataCy?: string;
}
export default function QuestionsMiniButton({ icon, text, onClick }: QuestionsMiniButtonProps) {
export default function QuestionsMiniButton({ icon, text, onClick, dataCy }: QuestionsMiniButtonProps) {
const theme = useTheme();
return (
<>
<Button
variant="outlined"
data-cy={dataCy}
sx={{
padding: "26px 15px 15px 15px",
display: "flex",

@ -1,10 +1,11 @@
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Box, IconButton } from "@mui/material";
import { toggleQuizPreview, useQuizPreviewStore } from "@root/quizPreview";
import { useLayoutEffect, useRef } from "react";
import { Rnd } from "react-rnd";
import { useWindowSize } from "../../utils/hooks/useWindowSize";
import QuizPreviewLayout from "./QuizPreviewLayout";
import ResizeIcon from "./ResizeIcon";
import VisibilityIcon from "@mui/icons-material/Visibility";
const DRAG_PARENT_MARGIN = 0;
const NAVBAR_HEIGHT = 0;

@ -160,4 +160,5 @@ export default function QuizPreviewLayout() {
</Box>
</Paper>
);
}

@ -23,32 +23,41 @@ export default function Variant({ question }: Props) {
setValue((event.target as HTMLInputElement).value);
};
return (
<FormControl fullWidth>
<FormLabel id="quiz-question-radio-group">{question.title}</FormLabel>
<RadioGroup
aria-labelledby="quiz-question-radio-group"
value={value}
onChange={handleChange}
>
{question.content.variants.map((variant, index) => (
<FormControlLabel
key={index}
value={variant.answer}
control={<Radio />}
label={
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Typography>{variant.answer}</Typography>
<Tooltip title="Подсказка" placement="right">
<Box>
<InfoIcon />
</Box>
</Tooltip>
</Box>
}
/>
))}
</RadioGroup>
</FormControl>
);
return (
<FormControl fullWidth>
<FormLabel
id="quiz-question-radio-group"
data-cy="variant-title"
>{question.title}</FormLabel>
<RadioGroup
aria-labelledby="quiz-question-radio-group"
value={value}
onChange={handleChange}
>
{question.content.variants.map((variant, index) => (
<FormControlLabel
key={index}
value={variant.answer}
control={<Radio
inputProps={{
"data-cy": "variant-radio",
}}
/>}
label={
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Typography
data-cy="variant-answer"
>{variant.answer}</Typography>
<Tooltip title={variant.hints} placement="right">
<Box>
<InfoIcon />
</Box>
</Tooltip>
</Box>
}
/>
))}
</RadioGroup>
</FormControl>
);
}

@ -0,0 +1,35 @@
import { useEffect, useLayoutEffect, useState } from "react";
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: 0,
height: 0,
});
const handleSize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
useEffect(() => {
window.addEventListener("resize", handleSize);
return () => {
window.removeEventListener("resize", handleSize);
};
});
useLayoutEffect(() => {
handleSize();
}, []);
return windowSize;
}

@ -19,9 +19,14 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
},
"include": [
"src"
"src",
],
"exclude": [
"cypress.config.ts",
"cypress",
"node_modules",
]
}
}

672
yarn.lock

File diff suppressed because it is too large Load Diff