Add file attachments to questions (new question form + admin reply)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 18:26:17 +05:00
parent 367764b71e
commit 3b57b14d9b
3 changed files with 203 additions and 11 deletions
+83 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
interface FileAttachment {
name: string;
@@ -58,11 +58,14 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
const [detail, setDetail] = useState<QuestionDetail | null>(null);
const [tab, setTab] = useState<"open" | "closed">("open");
const [replyText, setReplyText] = useState("");
const [replyFiles, setReplyFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState("");
const [listError, setListError] = useState("");
const [listLoading, setListLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchList = useCallback(async () => {
setListError("");
@@ -86,9 +89,36 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
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) {
setSelectedId(id);
setReplyText("");
setReplyFiles([]);
setError("");
const res = await fetch(`/api/questions/${id}`);
if (res.ok) {
@@ -105,14 +135,16 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
}
async function handleReply() {
if (!replyText.trim() || !selectedId) return;
if (uploading) return;
if (!replyText.trim() && replyFiles.length === 0) return;
if (!selectedId) return;
setSending(true);
setError("");
try {
const res = await fetch(`/api/questions/${selectedId}/messages`, {
method: "POST",
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("Ошибка отправки");
const msg = await res.json();
@@ -120,6 +152,7 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
prev ? { ...prev, messages: [...prev.messages, msg] } : null
);
setReplyText("");
setReplyFiles([]);
fetchList();
} catch {
setError("Не удалось отправить ответ");
@@ -326,6 +359,14 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
className="flex flex-col gap-2"
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
@@ -347,16 +388,52 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
color: "var(--foreground)",
}}
/>
<div className="flex justify-end">
{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
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"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: sending || !replyText.trim() ? 0.5 : 1,
opacity: sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) ? 0.5 : 1,
}}
>
{sending ? "..." : "Отправить →"}