Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/api/exercises.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,41 @@ Zeeguu_API.prototype.getSmallerContext = function (contextBookmark, wordBookmark
};
return this._post(`/get_smaller_context`, qs.stringify(payload), callback);
};

Zeeguu_API.prototype.reportExerciseIssue = function (
bookmarkId,
exerciseSource,
reason,
comment,
contextUsed,
callback,
errorCallback
) {
const payload = JSON.stringify({
bookmark_id: bookmarkId,
exercise_source: exerciseSource,
reason,
comment: comment || null,
context_used: contextUsed || null
});

fetch(this._appendSessionToUrl("report_exercise_issue"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: payload
})
.then(response => response.json().then(data => ({ status: response.status, data })))
.then(({ status, data }) => {
if (status >= 200 && status < 300 && data.success) {
if (callback) callback(data);
} else {
if (errorCallback) errorCallback(data);
}
})
.catch(error => {
console.error("Error reporting exercise issue:", error);
if (errorCallback) errorCallback({ error: "Network error" });
});
};
47 changes: 40 additions & 7 deletions src/exercises/ExerciseSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import isEmptyDictionary from "../utils/misc/isEmptyDictionary";
import WordProgressBar from "./progressBars/WordProgressBar";
import { getExerciseTypeName } from "./exerciseTypes/exerciseTypeNames";
import { useFeedbackContext } from "../contexts/FeedbackContext";
import FlagOutlinedIcon from "@mui/icons-material/FlagOutlined";
import ReportExerciseDialog from "./exerciseTypes/ReportExerciseDialog";

const BOOKMARKS_DUE_REVIEW = false;

Expand Down Expand Up @@ -64,6 +66,8 @@ export default function ExerciseSession({ articleID, backButtonAction, toSchedul
const [totalPracticedBookmarksInSession, setTotalPracticedBookmarksInSession] = useState(0);
const [exerciseMessageForAPI, setExerciseMessageForAPI] = useState({});
const [isSessionLoading, setIsSessionLoading] = useState(true); // New loading state
const [reportDialogOpen, setReportDialogOpen] = useState(false);
const [isReported, setIsReported] = useState(false);
const currentIndexRef = useShadowRef(currentIndex);
const hasKeptExercisingRef = useShadowRef(hasKeptExercising);

Expand Down Expand Up @@ -262,6 +266,7 @@ export default function ExerciseSession({ articleID, backButtonAction, toSchedul
setExerciseMessageForAPI({});
setIsCorrect(null);
setShowFeedbackButtons(false);
setIsReported(false); // Reset report state for new exercise
const newIndex = currentIndex + 1;

if (newIndex === fullExerciseProgression.length) {
Expand Down Expand Up @@ -425,18 +430,30 @@ export default function ExerciseSession({ articleID, backButtonAction, toSchedul
<s.ExercisesColumn>
<div id="exerciseTopbar">
<div id="topbarRow">
{userDetails?.name === "Mircea" && (
<div
{/* Report exercise issue button */}
{isReported ? (
<div style={{ display: "flex", alignItems: "center", color: "#999", fontSize: "0.8rem", gap: "0.25rem" }}>
<FlagOutlinedIcon fontSize="small" />
Reported
</div>
) : (
<button
onClick={() => setReportDialogOpen(true)}
title="Report issue with this exercise"
style={{
background: "none",
border: "none",
padding: "0.25rem",
cursor: "pointer",
color: "#ccc",
display: "flex",
alignItems: "center",
fontSize: "0.8rem",
color: "#666",
fontWeight: "500",
}}
onMouseOver={(e) => e.currentTarget.style.color = "#999"}
onMouseOut={(e) => e.currentTarget.style.color = "#ccc"}
>
{getExerciseTypeName(currentExerciseType)}
</div>
<FlagOutlinedIcon fontSize="small" />
</button>
)}

<div style={{ display: "flex", alignItems: "center", marginLeft: "auto" }}>
Expand Down Expand Up @@ -512,6 +529,22 @@ export default function ExerciseSession({ articleID, backButtonAction, toSchedul
</p>
)}
</s.ExercisesColumn>

<ReportExerciseDialog
open={reportDialogOpen}
onClose={(reported) => {
setReportDialogOpen(false);
if (reported) setIsReported(true);
}}
bookmarkId={selectedExerciseBookmark?.id || currentBookmarksToStudy?.[0]?.id}
exerciseSource={getExerciseTypeName(currentExerciseType)}
isExerciseOver={isExerciseOver}
contextUsed={
selectedExerciseBookmark?.context_tokenized
? selectedExerciseBookmark.context_tokenized.map(t => t.text || t).join("")
: selectedExerciseBookmark?.context || currentBookmarksToStudy?.[0]?.context || ""
}
/>
</NarrowColumn>
);
}
2 changes: 2 additions & 0 deletions src/exercises/Exercises.sc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const ExercisesColumn = styled.div`

#exerciseTopbar {
width: 100%;
padding: 0.15rem 0;
}

#topbarRow {
Expand All @@ -25,6 +26,7 @@ const ExercisesColumn = styled.div`
flex-direction: row;
justify-content: space-between;
margin-bottom: 0;
min-height: 1.25rem;
}
`;

Expand Down
24 changes: 24 additions & 0 deletions src/exercises/exerciseTypes/Exercise.sc.js
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,28 @@ let MultipleChoiceContext = styled.div`
}
`;

let ReportButton = styled.button`
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: #ccc;
display: flex;
align-items: center;

&:hover {
color: #999;
}
`;

let ReportedBadge = styled.span`
color: #999;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.25rem;
`;

export {
Exercise,
FeedbackButton,
Expand Down Expand Up @@ -540,6 +562,8 @@ export {
EditSpeakButtonHolder,
OrangeButtonMessage,
MultipleChoiceContext,
ReportButton,
ReportedBadge,
};

export default StyledButton;
174 changes: 174 additions & 0 deletions src/exercises/exerciseTypes/ReportExerciseDialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useState, useContext } from "react";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Chip from "@mui/material/Chip";
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";
import SendIcon from "@mui/icons-material/Send";
import { toast } from "react-toastify";
import { APIContext } from "../../contexts/APIContext";

// Chips shown during exercise (before answering)
const DURING_EXERCISE_OPTIONS = [
{ value: "word_not_shown", label: "Word not shown" },
{ value: "wrong_highlighting", label: "Wrong highlighting" },
{ value: "context_confusing", label: "Context confusing" },
{ value: "other", label: "Other..." },
];

// Chips shown after exercise (after answering)
const AFTER_EXERCISE_OPTIONS = [
{ value: "wrong_translation", label: "Wrong translation" },
{ value: "wrong_highlighting", label: "Wrong highlighting" },
{ value: "context_wrong", label: "Context wrong" },
{ value: "other", label: "Other..." },
];

export default function ReportExerciseDialog({
open,
onClose,
bookmarkId,
exerciseSource,
isExerciseOver,
contextUsed,
}) {
const api = useContext(APIContext);
const [selectedReason, setSelectedReason] = useState(null);
const [comment, setComment] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);

const options = isExerciseOver ? AFTER_EXERCISE_OPTIONS : DURING_EXERCISE_OPTIONS;

function handleChipClick(reason) {
if (reason === "other") {
setSelectedReason("other");
} else {
// Submit immediately for non-other options
submitReport(reason, null);
}
}

function handleSubmitOther() {
if (!comment.trim()) {
toast.error("Please describe the issue");
return;
}
submitReport("other", comment.trim());
}

function submitReport(reason, commentText) {
setIsSubmitting(true);

api.reportExerciseIssue(
bookmarkId,
exerciseSource,
reason,
commentText,
contextUsed,
(response) => {
setIsSubmitting(false);
if (response.already_reported) {
toast.info("You've already reported this exercise");
} else {
toast.success("Thanks for the feedback!");
}
handleClose(true);
},
(error) => {
setIsSubmitting(false);
toast.error("Failed to submit report");
}
);
}

function handleClose(reported = false) {
setSelectedReason(null);
setComment("");
onClose(reported);
}

return (
<Dialog open={open} onClose={() => handleClose(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ pb: 1, pr: 6 }}>
Report a problem with the exercise
<IconButton
aria-label="close"
onClick={() => handleClose(false)}
sx={{
position: "absolute",
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
{selectedReason !== "other" ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{options.map((option) => (
<Chip
key={option.value}
label={option.label}
onClick={() => handleChipClick(option.value)}
variant="outlined"
clickable
disabled={isSubmitting}
sx={{
borderColor: "#ccc",
"&:hover": {
borderColor: "#999",
backgroundColor: "rgba(0,0,0,0.04)",
},
}}
/>
))}
</div>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginTop: "0.5rem" }}>
<TextField
autoFocus
fullWidth
size="small"
placeholder="Describe the issue..."
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmitOther();
}
}}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: "8px",
},
}}
/>
<IconButton
onClick={handleSubmitOther}
disabled={isSubmitting || !comment.trim()}
sx={{
backgroundColor: comment.trim() ? "#f0a000" : "#eee",
color: comment.trim() ? "white" : "#999",
"&:hover": {
backgroundColor: comment.trim() ? "#d89000" : "#ddd",
},
"&.Mui-disabled": {
backgroundColor: "#eee",
color: "#999",
},
}}
>
<SendIcon />
</IconButton>
</div>
)}
</DialogContent>
</Dialog>
);
}