Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
moolcoov
2025-03-03 15:56:53 +03:00
63 changed files with 1030 additions and 511 deletions
@@ -2,18 +2,32 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Task } from '@/shared/types/task';
import { ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface CompetitionHeaderProps {
title: string;
tasks: Task[];
competitionId: string;
setAnswer: (value: string) => void;
setSelectedFile: (file: File | null) => void;
}
const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
title,
tasks,
competitionId
competitionId,
setAnswer,
setSelectedFile
}) => {
const navigate = useNavigate();
const handleTaskSelect = (taskId: string) => {
setAnswer("");
setSelectedFile(null);
console.log("SETTER ERROR")
navigate(`/competition/${competitionId}/tasks/${taskId}`);
}
return (
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4">
@@ -23,7 +37,6 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors font-hse-sans text-sm"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Обратно
</Link>
<h1 className="font-hse-sans text-xl font-semibold text-center flex-1">
@@ -35,16 +48,16 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
{tasks.map((task) => (
<Link
key={task.id}
to={`/competition/${competitionId}/tasks/${task.id}`}
className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)]
rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:brightness-95 flex-shrink-0
`}
>
{task.in_competition_position}
</Link>
<button
key={task.id}
className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)]
rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:brightness-95 flex-shrink-0
`}
onClick={() => handleTaskSelect(task.id)}
>
{task.in_competition_position}
</button>
))}
</div>
</div>
@@ -4,6 +4,7 @@ import CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
import { getCompetition } from "@/shared/api/competitions";
import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TaskType } from "@/shared/types/task";
@@ -15,6 +16,12 @@ const CompetitionSession = () => {
const competitionId = id || "";
const queryClient = useQueryClient();
const competitionQuery = useQuery({
queryKey: ["competition", competitionId],
queryFn: () => getCompetition(competitionId),
enabled: !!competitionId,
});
const tasksQuery = useQuery({
queryKey: ["competitionTasks", competitionId],
queryFn: () => getCompetitionTasks(competitionId),
@@ -46,9 +53,12 @@ const CompetitionSession = () => {
}
});
const competition = competitionQuery.data;
const tasks = tasksQuery.data || [];
const isLoading = tasksQuery.isLoading;
const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null;
const isLoading = tasksQuery.isLoading || competitionQuery.isLoading;
const error = tasksQuery.error || competitionQuery.error
? "Не удалось загрузить данные. Пожалуйста, попробуйте позже."
: null;
const currentTask = tasks.find((t) => t.id === taskId) || null;
@@ -77,12 +87,16 @@ const CompetitionSession = () => {
submitMutation.mutate();
};
const competitionTitle = competition?.title || "Загрузка соревнования...";
return (
<div className="flex min-h-screen flex-col">
<CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап"
title={competitionTitle}
tasks={tasks}
competitionId={competitionId}
setAnswer={setAnswer}
setSelectedFile={setSelectedFile}
/>
<main className="flex-1 bg-[#F8F8F8] pb-8">
@@ -1,14 +1,19 @@
import React from 'react';
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
interface ActionButtonsProps {
onSubmit: () => void;
onHistoryClick: () => void;
isSubmitting?: boolean;
hasSubmissionsLeft?: boolean;
}
const ActionButtons: React.FC<ActionButtonsProps> = ({
onSubmit,
onHistoryClick
onHistoryClick,
isSubmitting = false,
hasSubmissionsLeft = true
}) => {
return (
<div className="flex gap-8">
@@ -16,15 +21,31 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100"
onClick={onHistoryClick}
disabled={isSubmitting}
>
История
</Button>
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
>
Отправить решение
</Button>
{hasSubmissionsLeft ? (
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
"Отправить решение"
)}
</Button>
) : (
<div className="flex-grow text-right text-gray-500 flex items-center justify-end font-hse-sans">
Лимит посылок исчерпан
</div>
)}
</div>
);
};
@@ -8,6 +8,7 @@ interface FileSolutionProps {
fileInputRef: React.RefObject<HTMLInputElement>;
existingFileUrl?: string | null;
onClearExistingFile?: () => void; // New prop to clear existing file URL
firstSolution: boolean
}
const FileSolution: React.FC<FileSolutionProps> = ({
@@ -15,7 +16,8 @@ const FileSolution: React.FC<FileSolutionProps> = ({
setSelectedFile,
fileInputRef,
existingFileUrl = null,
onClearExistingFile
onClearExistingFile,
firstSolution
}) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
@@ -59,9 +61,6 @@ const FileSolution: React.FC<FileSolutionProps> = ({
}
};
const handleSelectNewFile = () => {
fileInputRef.current?.click();
};
const fileName = selectedFile
? selectedFile.name
@@ -69,7 +68,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
? existingFileUrl.split('/').pop() || 'file'
: '';
const hasFile = !!selectedFile || !!existingFileUrl;
const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution);
return (
<>
@@ -87,7 +86,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
<FileIcon size={28} className="text-black mb-2" />
<span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{fileName}</span>
<div className="flex items-center mt-2">
<div className="flex flex-col justify-center mt-2">
{existingFileUrl && !selectedFile && (
<a
href={existingFileUrl}
@@ -99,7 +98,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
</a>
)}
{selectedFile ? (
{selectedFile || existingFileUrl ? (
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
@@ -107,23 +106,6 @@ const FileSolution: React.FC<FileSolutionProps> = ({
>
Очистить
</Button>
) : existingFileUrl ? (
<div className="flex gap-3">
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
onClick={handleSelectNewFile}
>
Выбрать другой файл
</Button>
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
onClick={handleClearFile}
>
Очистить
</Button>
</div>
) : null}
</div>
</div>
@@ -1,9 +1,9 @@
import React from 'react';
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { X, Check } from "lucide-react";
import SolutionStatus from '../SolutionStatus';
import { Solution, TaskType } from '@/shared/types/task';
import { Solution } from '@/shared/types/task';
interface SolutionHistorySheetProps {
isOpen: boolean;
@@ -18,7 +18,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
onOpenChange,
solutions,
maxPoints,
onSolutionSelect
onSolutionSelect,
}) => {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -39,10 +39,10 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
solutions.map((solution, index) => (
<div
key={solution.id || index}
className="w-full cursor-pointer transition-transform hover:scale-[1.01]"
className={`w-full cursor-pointer transition-transform hover:scale-[1.01] relative`}
onClick={() => {
onSolutionSelect(solution);
onOpenChange(false);
onOpenChange(false);
}}
>
<SolutionStatus solution={solution} maxPoints={maxPoints} />
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Task, TaskType, Solution } from '@/shared/types/task';
import { useQuery } from '@tanstack/react-query';
@@ -17,6 +17,7 @@ interface TaskSolutionProps {
selectedFile: File | null;
setSelectedFile: (file: File | null) => void;
onSubmit: () => void;
isSubmitting?: boolean;
}
const TaskSolution: React.FC<TaskSolutionProps> = ({
@@ -26,11 +27,14 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
selectedFile,
setSelectedFile,
onSubmit,
isSubmitting = false
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null);
const [displayedSolution, setDisplayedSolution] = useState<Solution | null>(null);
const { id: competitionId } = useParams<{ id: string }>();
const prevTaskIdRef = useRef<string | null>(null);
const solutionsQuery = useQuery({
queryKey: ['solutionHistory', competitionId, task.id],
@@ -39,44 +43,87 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
});
const solutionHistory = solutionsQuery.data || [];
const maxAttempts = task.max_attempts || -1;
const submissionsUsed = solutionHistory.length;
const submissionsLeft = Math.max(0, maxAttempts - submissionsUsed);
const hasSubmissionsLeft = submissionsLeft > 0;
useEffect(() => {
if (solutionHistory.length > 0 && !displayedSolution) {
const latestSolution = solutionHistory[solutionHistory.length - 1];
setDisplayedSolution(latestSolution);
}
}, [solutionHistory, displayedSolution]);
useEffect(() => {
if (prevTaskIdRef.current !== task.id) {
setDisplayedSolution(null);
setSelectedSolutionUrl(null);
if (solutionHistory.length > 0) {
const latestSolution = solutionHistory[solutionHistory.length - 1];
setDisplayedSolution(latestSolution);
}
prevTaskIdRef.current = task.id;
}
}, [task.id, solutionHistory]);
useEffect(() => {
if (solutionHistory.length > 0 &&
(!displayedSolution ||
(solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id &&
displayedSolution.id === solutionHistory[solutionHistory.length - 2]?.id))) {
setDisplayedSolution(solutionHistory[solutionHistory.length - 1]);
}
}, [solutionHistory, displayedSolution]);
useEffect(() => {
const loadSolutionContent = async () => {
if (!displayedSolution || !displayedSolution.content) return;
try {
if (task.type === TaskType.FILE) {
setSelectedFile(null);
setSelectedSolutionUrl(displayedSolution.content);
} else {
const response = await fetch(displayedSolution.content);
if (!response.ok) {
throw new Error(`Failed to fetch solution content: ${response.status}`);
}
const text = await response.text();
setAnswer(text);
}
} catch (error) {
console.error('Error loading solution content:', error);
}
};
loadSolutionContent();
}, [displayedSolution, task.type, setAnswer, setSelectedFile]);
const handleOpenHistory = () => {
setIsHistoryOpen(true);
};
const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[0] : null;
const handleSolutionSelect = async (solution: Solution) => {
if (!solution.content) return;
try {
if (task.type === TaskType.FILE) {
// For file tasks, just store the URL
setSelectedFile(null); // Clear any selected file first
setSelectedSolutionUrl(solution.content);
} else {
// For INPUT and CODE tasks, fetch the content and set as answer
const response = await fetch(solution.content);
if (!response.ok) {
throw new Error(`Failed to fetch solution content: ${response.status}`);
}
const text = await response.text();
setAnswer(text);
}
} catch (error) {
console.error('Error loading solution content:', error);
}
const handleSolutionSelect = (solution: Solution) => {
setDisplayedSolution(solution);
};
// Function to clear the existing file URL
const handleClearExistingFile = () => {
setSelectedSolutionUrl(null);
};
return (
<div className="md:w-[500px] flex flex-col gap-4">
{latestSolution ? (
<SolutionStatus solution={latestSolution} maxPoints={task.points}/>
{displayedSolution ? (
<>
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
Результат последней посылки:
</div>
<SolutionStatus key={displayedSolution.id} solution={displayedSolution} maxPoints={task.points}/>
</>
) : (
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
Решение еще не отправлено
@@ -84,7 +131,10 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
)}
{task.type === TaskType.INPUT && (
<InputSolution answer={answer} setAnswer={setAnswer} />
<InputSolution
answer={answer}
setAnswer={setAnswer}
/>
)}
{task.type === TaskType.FILE && (
@@ -94,16 +144,45 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
fileInputRef={fileInputRef}
existingFileUrl={selectedSolutionUrl}
onClearExistingFile={handleClearExistingFile}
firstSolution={solutionHistory.length > 0}
/>
)}
{task.type === TaskType.CODE && (
<CodeSolution answer={answer} setAnswer={setAnswer} />
<CodeSolution
answer={answer}
setAnswer={setAnswer}
/>
)}
<div className={`rounded-lg p-3 font-hse-sans text-sm flex items-center
${hasSubmissionsLeft
? 'bg-blue-50 text-blue-700'
: 'bg-red-50 text-red-700'}`}
>
{hasSubmissionsLeft ? (
<>
<span className="font-medium">
Осталось посылок: {submissionsLeft === Infinity ? '∞' : submissionsLeft}
</span>
{maxAttempts !== -1 && (
<span className="text-blue-500 ml-1">
(из {maxAttempts})
</span>
)}
</>
) : (
<span className="font-medium">
Вы использовали все посылки
</span>
)}
</div>
<ActionButtons
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
isSubmitting={isSubmitting}
hasSubmissionsLeft={hasSubmissionsLeft}
/>
<SolutionHistorySheet
+1 -1
View File
@@ -24,7 +24,7 @@ export const submitTaskSolution = async (
// туповатый костыль но для мвп сойдет
if (typeof solution === 'string') {
const textFile = new File([solution], 'solution.txt', { type: 'text/plain' });
const textFile = new File([solution], 'solution_example.txt', { type: 'text/plain' });
formData.append('content', textFile);
} else {
formData.append('content', solution);
@@ -5,6 +5,7 @@ interface Task {
type: TaskType;
in_competition_position: number;
points: number;
max_attempts: number;
}
export interface TaskAttachment {