Add optional audio response for students in homework submissions
- Course: add allowAudio toggle (per-course setting, off by default) - HomeworkSubmission: add audioUrl field - Student: AudioRecorder in homework form when allowAudio is enabled - Student: show audio player in submission view and curator feedback view - Curator: show student audio on submission detail page - AudioRecorder: accept uploadUrl prop (reused for student/curator) - API: /api/student/audio-upload route for S3 upload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "Course" ADD COLUMN "allowAudio" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "HomeworkSubmission" ADD COLUMN "audioUrl" TEXT;
|
||||||
@@ -96,6 +96,7 @@ model Course {
|
|||||||
description String?
|
description String?
|
||||||
coverImage String?
|
coverImage String?
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
|
allowAudio Boolean @default(false)
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
categoryId String?
|
categoryId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -265,6 +266,7 @@ model HomeworkSubmission {
|
|||||||
files Json?
|
files Json?
|
||||||
status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED
|
status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED
|
||||||
statusAt DateTime?
|
statusAt DateTime?
|
||||||
|
audioUrl String?
|
||||||
submittedAt DateTime @default(now())
|
submittedAt DateTime @default(now())
|
||||||
|
|
||||||
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export async function submitHomework(
|
|||||||
slug: string,
|
slug: string,
|
||||||
lessonId: string,
|
lessonId: string,
|
||||||
text: string,
|
text: string,
|
||||||
files: HomeworkFile[]
|
files: HomeworkFile[],
|
||||||
|
audioUrl?: string | null
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) throw new Error("Unauthorized");
|
if (!session) throw new Error("Unauthorized");
|
||||||
@@ -36,12 +37,12 @@ export async function submitHomework(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
const updated = await prisma.homeworkSubmission.update({
|
const updated = await prisma.homeworkSubmission.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { text, files: files as object[], submittedAt: new Date() },
|
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
|
||||||
});
|
});
|
||||||
submissionId = updated.id;
|
submissionId = updated.id;
|
||||||
} else {
|
} else {
|
||||||
const created = await prisma.homeworkSubmission.create({
|
const created = await prisma.homeworkSubmission.create({
|
||||||
data: { homeworkId, userId: session.user.id, text, files: files as object[] },
|
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
|
||||||
});
|
});
|
||||||
submissionId = created.id;
|
submissionId = created.id;
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
const homeworkSubmission = lesson?.homework && session && !isAdmin
|
const homeworkSubmission = lesson?.homework && session && !isAdmin
|
||||||
? await prisma.homeworkSubmission.findFirst({
|
? await prisma.homeworkSubmission.findFirst({
|
||||||
where: { homeworkId: lesson.homework.id, userId: session.user.id },
|
where: { homeworkId: lesson.homework.id, userId: session.user.id },
|
||||||
include: { feedbacks: { include: { curator: { select: { name: true } } }, orderBy: { createdAt: "desc" } } },
|
include: {
|
||||||
|
feedbacks: {
|
||||||
|
include: { curator: { select: { name: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -145,9 +150,16 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
submission={homeworkSubmission ? {
|
submission={homeworkSubmission ? {
|
||||||
...homeworkSubmission,
|
...homeworkSubmission,
|
||||||
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
|
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
|
||||||
|
audioUrl: homeworkSubmission.audioUrl ?? null,
|
||||||
|
feedbacks: homeworkSubmission.feedbacks.map((fb) => ({
|
||||||
|
...fb,
|
||||||
|
files: (fb.files as { name: string; url: string; size: number }[]) ?? [],
|
||||||
|
audioUrl: fb.audioUrl ?? null,
|
||||||
|
})),
|
||||||
} : null}
|
} : null}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
lessonId={lessonId}
|
lessonId={lessonId}
|
||||||
|
allowAudio={lesson.module.course.allowAudio}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,12 +43,13 @@ export async function updateCourse(courseId: string, formData: FormData) {
|
|||||||
const slug = formData.get("slug") as string;
|
const slug = formData.get("slug") as string;
|
||||||
const description = (formData.get("description") as string) || null;
|
const description = (formData.get("description") as string) || null;
|
||||||
const published = formData.get("published") === "true";
|
const published = formData.get("published") === "true";
|
||||||
|
const allowAudio = formData.get("allowAudio") === "true";
|
||||||
const coverImage = (formData.get("coverImage") as string) || null;
|
const coverImage = (formData.get("coverImage") as string) || null;
|
||||||
const categoryId = (formData.get("categoryId") as string) || null;
|
const categoryId = (formData.get("categoryId") as string) || null;
|
||||||
|
|
||||||
await prisma.course.update({
|
await prisma.course.update({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
data: { title, slug, description, published, coverImage, categoryId },
|
data: { title, slug, description, published, allowAudio, coverImage, categoryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/courses");
|
revalidatePath("/admin/courses");
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
|
||||||
|
const key = `homework-audio/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type || "audio/webm");
|
||||||
|
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
}
|
||||||
@@ -160,6 +160,19 @@ export default async function SubmissionPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Student audio */}
|
||||||
|
{submission.audioUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Аудио студента
|
||||||
|
</p>
|
||||||
|
<audio controls src={submission.audioUrl} style={{ width: "100%", height: 40 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Student files */}
|
{/* Student files */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Course {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
allowAudio: boolean;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ interface Category {
|
|||||||
|
|
||||||
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
|
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
|
||||||
const [published, setPublished] = useState(course.published);
|
const [published, setPublished] = useState(course.published);
|
||||||
|
const [allowAudio, setAllowAudio] = useState(course.allowAudio);
|
||||||
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
|
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
|
||||||
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
|
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -45,6 +47,7 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fd = new FormData(e.currentTarget);
|
const fd = new FormData(e.currentTarget);
|
||||||
fd.set("published", String(published));
|
fd.set("published", String(published));
|
||||||
|
fd.set("allowAudio", String(allowAudio));
|
||||||
fd.set("coverImage", coverImage);
|
fd.set("coverImage", coverImage);
|
||||||
fd.set("categoryId", categoryId);
|
fd.set("categoryId", categoryId);
|
||||||
startTransition(() => updateCourse(course.id, fd));
|
startTransition(() => updateCourse(course.id, fd));
|
||||||
@@ -99,17 +102,33 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
|
|||||||
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
|
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
role="switch"
|
type="button"
|
||||||
aria-checked={published}
|
role="switch"
|
||||||
onClick={() => setPublished(!published)}
|
aria-checked={published}
|
||||||
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
|
onClick={() => setPublished(!published)}
|
||||||
>
|
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
|
||||||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
|
>
|
||||||
</button>
|
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
|
||||||
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
|
</button>
|
||||||
|
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={allowAudio}
|
||||||
|
onClick={() => setAllowAudio(!allowAudio)}
|
||||||
|
className={`relative w-10 h-6 rounded-full transition-colors ${allowAudio ? "bg-blue-500" : "bg-slate-300"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${allowAudio ? "translate-x-4" : ""}`} />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-600">
|
||||||
|
{allowAudio ? "🎤 Аудио-ответ в ДЗ включён" : "Аудио-ответ в ДЗ выключен"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between pt-2">
|
<div className="flex justify-between pt-2">
|
||||||
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useState, useRef } from "react";
|
|||||||
interface AudioRecorderProps {
|
interface AudioRecorderProps {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (url: string | null) => void;
|
onChange: (url: string | null) => void;
|
||||||
|
uploadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioRecorder({ value, onChange }: AudioRecorderProps) {
|
export function AudioRecorder({ value, onChange, uploadUrl = "/api/curator/audio-upload" }: AudioRecorderProps) {
|
||||||
const [state, setState] = useState<"idle" | "recording" | "recorded" | "uploading">("idle");
|
const [state, setState] = useState<"idle" | "recording" | "recorded" | "uploading">("idle");
|
||||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
||||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||||
@@ -46,7 +47,7 @@ export function AudioRecorder({ value, onChange }: AudioRecorderProps) {
|
|||||||
const blob = new Blob(chunks.current, { type: mimeType.current });
|
const blob = new Blob(chunks.current, { type: mimeType.current });
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", blob, "audio-response.webm");
|
form.append("file", blob, "audio-response.webm");
|
||||||
const res = await fetch("/api/curator/audio-upload", { method: "POST", body: form });
|
const res = await fetch(uploadUrl, { method: "POST", body: form });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
onChange(data.url);
|
onChange(data.url);
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
|
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
|
||||||
|
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||||
|
|
||||||
interface HWFile { name: string; url: string; size: number }
|
interface HWFile { name: string; url: string; size: number }
|
||||||
|
|
||||||
interface Feedback {
|
interface Feedback {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
files?: HWFile[];
|
||||||
|
audioUrl?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
curator: { name: string };
|
curator: { name: string };
|
||||||
}
|
}
|
||||||
@@ -16,6 +19,7 @@ interface Submission {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
files: HWFile[];
|
files: HWFile[];
|
||||||
|
audioUrl?: string | null;
|
||||||
submittedAt: Date;
|
submittedAt: Date;
|
||||||
feedbacks: Feedback[];
|
feedbacks: Feedback[];
|
||||||
}
|
}
|
||||||
@@ -25,6 +29,7 @@ interface Props {
|
|||||||
submission: Submission | null;
|
submission: Submission | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
|
allowAudio?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number) {
|
function formatSize(bytes: number) {
|
||||||
@@ -33,9 +38,10 @@ function formatSize(bytes: number) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeworkSection({ homework, submission, slug, lessonId }: Props) {
|
export function HomeworkSection({ homework, submission, slug, lessonId, allowAudio = false }: Props) {
|
||||||
const [text, setText] = useState(submission?.text ?? "");
|
const [text, setText] = useState(submission?.text ?? "");
|
||||||
const [files, setFiles] = useState<HWFile[]>(submission?.files ?? []);
|
const [files, setFiles] = useState<HWFile[]>(submission?.files ?? []);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(submission?.audioUrl ?? null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [editing, setEditing] = useState(!submission);
|
const [editing, setEditing] = useState(!submission);
|
||||||
@@ -72,7 +78,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files));
|
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files, audioUrl));
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,18 +97,44 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
|||||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
||||||
{submission!.text || "—"}
|
{submission!.text || "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{submission!.audioUrl && (
|
||||||
{submission!.feedbacks.map((fb) => (
|
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||||
<div key={fb.id} className="px-4 py-3 space-y-1" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
|
||||||
<div className="flex items-center justify-between">
|
<audio controls src={submission!.audioUrl} style={{ height: 32, width: "100%" }} />
|
||||||
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
|
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
{submission!.feedbacks.map((fb) => {
|
||||||
|
const fbFiles = fb.files ?? [];
|
||||||
|
return (
|
||||||
|
<div key={fb.id} className="px-4 py-3 space-y-2" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
|
||||||
|
{fbFiles.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{fbFiles.map((f) => (
|
||||||
|
<a key={f.url} href={f.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs underline">
|
||||||
|
<span>📎</span><span>{f.name}</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fb.audioUrl && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио от куратора:</p>
|
||||||
|
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -127,6 +159,12 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
|||||||
{submission.text}
|
{submission.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{submission.audioUrl && (
|
||||||
|
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
|
||||||
|
<audio controls src={submission.audioUrl} style={{ height: 32, width: "100%" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{submission.files.length > 0 && (
|
{submission.files.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{submission.files.map((f) => (
|
{submission.files.map((f) => (
|
||||||
@@ -169,12 +207,21 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Audio recorder */}
|
||||||
|
{allowAudio && (
|
||||||
|
<AudioRecorder
|
||||||
|
value={audioUrl}
|
||||||
|
onChange={setAudioUrl}
|
||||||
|
uploadUrl="/api/student/audio-upload"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={pending || (!text.trim() && files.length === 0)}
|
disabled={pending || (!text.trim() && files.length === 0 && !audioUrl)}
|
||||||
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
|
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
|
||||||
style={{ opacity: pending || (!text.trim() && files.length === 0) ? 0.6 : 1 }}
|
style={{ opacity: pending || (!text.trim() && files.length === 0 && !audioUrl) ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
{pending ? "Отправка..." : submission ? "Обновить ответ" : "Сдать работу"}
|
{pending ? "Отправка..." : submission ? "Обновить ответ" : "Сдать работу"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user