From d32186c1011f9e6496e5a86c8f19874262bf8830 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 19 May 2026 13:42:39 +0500 Subject: [PATCH] Add admin/curator split-view questions page Co-Authored-By: Claude Sonnet 4.6 --- src/app/admin/questions/page.tsx | 11 + src/app/curator/questions/page.tsx | 12 + .../questions/QuestionSplitView.tsx | 353 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 src/app/admin/questions/page.tsx create mode 100644 src/app/curator/questions/page.tsx create mode 100644 src/components/questions/QuestionSplitView.tsx diff --git a/src/app/admin/questions/page.tsx b/src/app/admin/questions/page.tsx new file mode 100644 index 0000000..f630fed --- /dev/null +++ b/src/app/admin/questions/page.tsx @@ -0,0 +1,11 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { QuestionSplitView } from "@/components/questions/QuestionSplitView"; + +export default async function AdminQuestionsPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || session.user.role !== "admin") redirect("/login"); + + return ; +} diff --git a/src/app/curator/questions/page.tsx b/src/app/curator/questions/page.tsx new file mode 100644 index 0000000..42d6c22 --- /dev/null +++ b/src/app/curator/questions/page.tsx @@ -0,0 +1,12 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { QuestionSplitView } from "@/components/questions/QuestionSplitView"; + +export default async function CuratorQuestionsPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + if (session.user.role !== "admin" && session.user.role !== "curator") redirect("/dashboard"); + + return ; +} diff --git a/src/components/questions/QuestionSplitView.tsx b/src/components/questions/QuestionSplitView.tsx new file mode 100644 index 0000000..11f0673 --- /dev/null +++ b/src/components/questions/QuestionSplitView.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +interface FileAttachment { + name: string; + url: string; + size: number; +} + +interface Message { + id: string; + authorId: string; + text: string; + files: FileAttachment[] | null; + isRead: boolean; + createdAt: string; + author: { id: string; name: string; role: string }; +} + +interface QuestionSummary { + id: string; + title: string; + status: "OPEN" | "CLOSED"; + unreadCount: number; + updatedAt: string; + user: { id: string; name: string }; + course: { id: string; title: string } | null; +} + +interface QuestionDetail { + id: string; + title: string; + status: "OPEN" | "CLOSED"; + createdAt: string; + user: { id: string; name: string }; + messages: Message[]; +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleString("ru", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +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 function QuestionSplitView({ currentUserId }: { currentUserId: string }) { + const [questions, setQuestions] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [tab, setTab] = useState<"open" | "closed">("open"); + const [replyText, setReplyText] = useState(""); + const [sending, setSending] = useState(false); + const [closing, setClosing] = useState(false); + const [error, setError] = useState(""); + + const fetchList = useCallback(async () => { + const res = await fetch("/api/questions"); + if (res.ok) { + const data = await res.json(); + setQuestions(data); + } + }, []); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + async function selectQuestion(id: string) { + setSelectedId(id); + setReplyText(""); + setError(""); + const res = await fetch(`/api/questions/${id}`); + if (res.ok) { + const data = await res.json(); + setDetail({ + ...data, + messages: data.messages.map((m: Message & { createdAt: string }) => ({ + ...m, + files: m.files as FileAttachment[] | null, + })), + }); + fetchList(); + } + } + + async function handleReply() { + if (!replyText.trim() || !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() }), + }); + if (!res.ok) throw new Error("Ошибка отправки"); + const msg = await res.json(); + setDetail((prev) => + prev ? { ...prev, messages: [...prev.messages, msg] } : null + ); + setReplyText(""); + fetchList(); + } catch { + setError("Не удалось отправить ответ"); + } finally { + setSending(false); + } + } + + async function handleClose() { + if (!selectedId) return; + setClosing(true); + try { + const res = await fetch(`/api/questions/${selectedId}/close`, { + method: "PATCH", + }); + if (!res.ok) throw new Error("Ошибка закрытия"); + setDetail((prev) => (prev ? { ...prev, status: "CLOSED" } : null)); + fetchList(); + } catch { + setError("Не удалось закрыть вопрос"); + } finally { + setClosing(false); + } + } + + const filtered = questions.filter((q) => + tab === "open" ? q.status === "OPEN" : q.status === "CLOSED" + ); + + return ( +
+ {/* Left panel */} +
+ {/* Tabs */} +
+ {(["open", "closed"] as const).map((t) => ( + + ))} +
+ + {/* Question list */} +
+ {filtered.length === 0 && ( +

+ Нет вопросов +

+ )} + {filtered.map((q) => ( + + ))} +
+
+ + {/* Right panel */} +
+ {!detail ? ( +
+

+ Выберите вопрос +

+
+ ) : ( + <> + {/* Thread header */} +
+
+

+ {detail.title} +

+

+ {detail.user.name} ·{" "} + {new Date(detail.createdAt).toLocaleDateString("ru", { + day: "numeric", + month: "long", + })} +

+
+ {detail.status === "OPEN" && ( + + )} +
+ + {/* Messages */} +
+ {detail.messages.map((msg) => { + const isMine = msg.authorId === currentUserId; + return ( +
+

+ {msg.author.name} · {formatDate(msg.createdAt)} +

+

{msg.text}

+ {msg.files && msg.files.length > 0 && ( +
+ {msg.files.map((f, i) => ( + + 📎 {f.name} + · {formatFileSize(f.size)} + + ))} +
+ )} +
+ ); + })} +
+ + {/* Reply form */} +
+
+ setReplyText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleReply(); + } + }} + disabled={detail.status === "CLOSED"} + placeholder={ + detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..." + } + className="flex-1 text-sm px-3 py-2 outline-none" + style={{ + border: "1px solid var(--border)", + background: "var(--background)", + color: "var(--foreground)", + }} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ + )} +
+
+ ); +}