Compare commits

..

3 Commits

3 changed files with 248 additions and 45 deletions
+113 -15
View File
@@ -1,15 +1,56 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export default function NewQuestionPage() { export default function NewQuestionPage() {
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [text, setText] = useState(""); const [text, setText] = useState("");
const [files, setFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -21,7 +62,7 @@ export default function NewQuestionPage() {
const res = await fetch("/api/questions", { const res = await fetch("/api/questions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, text }), body: JSON.stringify({ title, text, files }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
@@ -37,8 +78,8 @@ export default function NewQuestionPage() {
} }
return ( return (
<div className="max-w-xl mx-auto px-4 py-8"> <div className="max-w-2xl mx-auto px-6 py-10">
<div className="flex items-center gap-3 mb-6"> <div className="mb-8">
<Link <Link
href="/questions" href="/questions"
className="text-sm" className="text-sm"
@@ -48,14 +89,17 @@ export default function NewQuestionPage() {
</Link> </Link>
</div> </div>
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--foreground)" }}>
Новый вопрос Новый вопрос
</h1> </h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
Опишите свой вопрос подробно куратор ответит в ближайшее время.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div> <div>
<label <label
className="block text-sm font-bold mb-1" className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }} style={{ color: "var(--foreground)" }}
> >
Тема вопроса Тема вопроса
@@ -66,18 +110,21 @@ export default function NewQuestionPage() {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Кратко опишите суть вопроса" placeholder="Кратко опишите суть вопроса"
required required
className="w-full text-sm px-3 py-2 outline-none" className="w-full text-sm px-4 py-3 outline-none"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
background: "var(--color-surface)", background: "var(--color-surface)",
color: "var(--foreground)", color: "var(--foreground)",
}} }}
/> />
<p className="text-xs mt-1.5" style={{ color: "var(--muted-foreground)" }}>
Например: «Не получается настроить плагин» или «Вопрос по уроку 3»
</p>
</div> </div>
<div> <div>
<label <label
className="block text-sm font-bold mb-1" className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }} style={{ color: "var(--foreground)" }}
> >
Описание Описание
@@ -85,37 +132,88 @@ export default function NewQuestionPage() {
<textarea <textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="Подробно опишите вопрос или проблему" placeholder="Подробно опишите вопрос или проблему. Что именно не получается? Что уже пробовали? Прикрепите скриншот или ссылку, если нужно."
required required
rows={6} rows={10}
className="w-full text-sm px-3 py-2 outline-none resize-none" className="w-full text-sm px-4 py-3 outline-none resize-y"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
background: "var(--color-surface)", background: "var(--color-surface)",
color: "var(--foreground)", color: "var(--foreground)",
minHeight: "200px",
}} }}
/> />
</div> </div>
{/* File attachments */}
<div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
📎 <span>{f.name}</span>
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs px-3 py-2"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf, md · до 10 МБ
</span>
</div>
</div>
{error && ( {error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}> <p className="text-sm" style={{ color: "var(--destructive)" }}>
{error} {error}
</p> </p>
)} )}
<div className="flex justify-end">
<button <button
type="submit" type="submit"
disabled={loading || !title.trim() || !text.trim()} disabled={loading || uploading || !title.trim() || !text.trim()}
className="self-end text-sm font-bold px-6 py-2" className="text-sm font-bold px-8 py-3"
style={{ style={{
background: "var(--foreground)", background: "var(--foreground)",
color: "var(--background)", color: "var(--background)",
border: "none", border: "none",
opacity: loading ? 0.6 : 1, opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
}} }}
> >
{loading ? "Отправка..." : "Отправить →"} {loading ? "Отправка..." : "Отправить →"}
</button> </button>
</div>
</form> </form>
</div> </div>
); );
+27 -1
View File
@@ -4,6 +4,18 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendQuestionCreatedEmail } from "@/lib/email"; import { sendQuestionCreatedEmail } from "@/lib/email";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
return `${endpoint}/${bucket}/`;
}
export async function GET() { export async function GET() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -59,16 +71,29 @@ export async function POST(req: NextRequest) {
} catch { } catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
} }
const { title, text, courseId } = body as { const { title, text, courseId, files } = body as {
title: string; title: string;
text: string; text: string;
courseId?: string; courseId?: string;
files?: FileAttachment[];
}; };
if (!title?.trim() || !text?.trim()) { if (!title?.trim() || !text?.trim()) {
return NextResponse.json({ error: "title and text are required" }, { status: 400 }); return NextResponse.json({ error: "title and text are required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files
?.filter(
(f) =>
typeof f.name === "string" &&
typeof f.url === "string" &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number"
)
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
const question = await prisma.studentQuestion.create({ const question = await prisma.studentQuestion.create({
data: { data: {
userId: session.user.id, userId: session.user.id,
@@ -78,6 +103,7 @@ export async function POST(req: NextRequest) {
create: { create: {
authorId: session.user.id, authorId: session.user.id,
text: text.trim(), text: text.trim(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
}, },
}, },
}, },
+88 -9
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
interface FileAttachment { interface FileAttachment {
name: string; name: string;
@@ -58,11 +58,14 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
const [detail, setDetail] = useState<QuestionDetail | null>(null); const [detail, setDetail] = useState<QuestionDetail | null>(null);
const [tab, setTab] = useState<"open" | "closed">("open"); const [tab, setTab] = useState<"open" | "closed">("open");
const [replyText, setReplyText] = useState(""); const [replyText, setReplyText] = useState("");
const [replyFiles, setReplyFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [listError, setListError] = useState(""); const [listError, setListError] = useState("");
const [listLoading, setListLoading] = useState(true); const [listLoading, setListLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchList = useCallback(async () => { const fetchList = useCallback(async () => {
setListError(""); setListError("");
@@ -86,9 +89,36 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
fetchList(); fetchList();
}, [fetchList]); }, [fetchList]);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setReplyFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function selectQuestion(id: string) { async function selectQuestion(id: string) {
setSelectedId(id); setSelectedId(id);
setReplyText(""); setReplyText("");
setReplyFiles([]);
setError(""); setError("");
const res = await fetch(`/api/questions/${id}`); const res = await fetch(`/api/questions/${id}`);
if (res.ok) { if (res.ok) {
@@ -105,14 +135,16 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
} }
async function handleReply() { async function handleReply() {
if (!replyText.trim() || !selectedId) return; if (uploading) return;
if (!replyText.trim() && replyFiles.length === 0) return;
if (!selectedId) return;
setSending(true); setSending(true);
setError(""); setError("");
try { try {
const res = await fetch(`/api/questions/${selectedId}/messages`, { const res = await fetch(`/api/questions/${selectedId}/messages`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: replyText.trim() }), body: JSON.stringify({ text: replyText.trim(), files: replyFiles }),
}); });
if (!res.ok) throw new Error("Ошибка отправки"); if (!res.ok) throw new Error("Ошибка отправки");
const msg = await res.json(); const msg = await res.json();
@@ -120,6 +152,7 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
prev ? { ...prev, messages: [...prev.messages, msg] } : null prev ? { ...prev, messages: [...prev.messages, msg] } : null
); );
setReplyText(""); setReplyText("");
setReplyFiles([]);
fetchList(); fetchList();
} catch { } catch {
setError("Не удалось отправить ответ"); setError("Не удалось отправить ответ");
@@ -323,11 +356,18 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
style={{ borderTop: "1px solid var(--border)" }} style={{ borderTop: "1px solid var(--border)" }}
> >
<div <div
className="flex gap-2" className="flex flex-col gap-2"
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }} style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
> >
<input <input
type="text" ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<textarea
value={replyText} value={replyText}
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -340,27 +380,66 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
placeholder={ placeholder={
detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..." detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..."
} }
className="flex-1 text-sm px-3 py-2 outline-none" rows={4}
className="w-full text-sm px-3 py-2 outline-none resize-y"
style={{ style={{
border: "1px solid var(--border)", border: "1px solid var(--border)",
background: "var(--background)", background: "var(--background)",
color: "var(--foreground)", color: "var(--foreground)",
}} }}
/> />
{replyFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{replyFiles.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1 text-xs px-2 py-1"
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
>
📎 {f.name}
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setReplyFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || detail.status === "CLOSED"}
className="text-xs px-2 py-1"
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf · 10 МБ
</span>
</div>
<button <button
onClick={handleReply} onClick={handleReply}
disabled={sending || !replyText.trim() || detail.status === "CLOSED"} disabled={sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) || detail.status === "CLOSED"}
className="text-xs font-bold px-4 py-2" className="text-xs font-bold px-4 py-2"
style={{ style={{
background: "var(--foreground)", background: "var(--foreground)",
color: "var(--background)", color: "var(--background)",
border: "none", border: "none",
opacity: sending ? 0.6 : 1, opacity: sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) ? 0.5 : 1,
}} }}
> >
{sending ? "..." : "Отправить"} {sending ? "..." : "Отправить"}
</button> </button>
</div> </div>
</div>
{error && ( {error && (
<p className="text-xs mt-1" style={{ color: "var(--destructive)" }}> <p className="text-xs mt-1" style={{ color: "var(--destructive)" }}>
{error} {error}