Add admin/curator split-view questions page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <QuestionSplitView currentUserId={session.user.id} />;
|
||||||
|
}
|
||||||
@@ -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 <QuestionSplitView currentUserId={session.user.id} />;
|
||||||
|
}
|
||||||
@@ -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<QuestionSummary[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<QuestionDetail | null>(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 (
|
||||||
|
<div className="flex h-[calc(100vh-56px)]">
|
||||||
|
{/* Left panel */}
|
||||||
|
<div
|
||||||
|
className="w-72 shrink-0 flex flex-col"
|
||||||
|
style={{ borderRight: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex p-2 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
{(["open", "closed"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className="text-xs px-3 py-1 rounded-sm font-medium"
|
||||||
|
style={
|
||||||
|
tab === t
|
||||||
|
? { background: "var(--foreground)", color: "var(--background)" }
|
||||||
|
: { background: "var(--muted)", color: "var(--muted-foreground)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t === "open"
|
||||||
|
? `Открытые ${questions.filter((q) => q.status === "OPEN").length}`
|
||||||
|
: "Закрытые"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="text-xs p-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Нет вопросов
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filtered.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => selectQuestion(q.id)}
|
||||||
|
className="w-full text-left p-3 block"
|
||||||
|
style={{
|
||||||
|
borderLeft:
|
||||||
|
selectedId === q.id
|
||||||
|
? "3px solid var(--foreground)"
|
||||||
|
: "3px solid transparent",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
background:
|
||||||
|
q.unreadCount > 0
|
||||||
|
? "#E8F0D8"
|
||||||
|
: selectedId === q.id
|
||||||
|
? "var(--muted)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold truncate" style={{ color: "var(--foreground)" }}>
|
||||||
|
{q.user.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{q.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "#999" }}>
|
||||||
|
{formatDate(q.updatedAt)}
|
||||||
|
{q.unreadCount > 0 && (
|
||||||
|
<span style={{ color: "#c00", marginLeft: 4 }}>🔴 новое</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{!detail ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Выберите вопрос
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Thread header */}
|
||||||
|
<div
|
||||||
|
className="flex items-start justify-between p-4 shrink-0"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold" style={{ color: "var(--foreground)" }}>
|
||||||
|
{detail.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{detail.user.name} ·{" "}
|
||||||
|
{new Date(detail.createdAt).toLocaleDateString("ru", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{detail.status === "OPEN" && (
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={closing}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-sm shrink-0"
|
||||||
|
style={{
|
||||||
|
background: "var(--muted)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{closing ? "..." : "✓ Закрыть вопрос"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
|
||||||
|
{detail.messages.map((msg) => {
|
||||||
|
const isMine = msg.authorId === currentUserId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="max-w-[85%] px-3 py-2 rounded-sm text-sm"
|
||||||
|
style={{
|
||||||
|
alignSelf: isMine ? "flex-end" : "flex-start",
|
||||||
|
background: isMine ? "#E8F0D8" : "var(--muted)",
|
||||||
|
border: isMine ? "2px solid var(--border)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{msg.author.name} · {formatDate(msg.createdAt)}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: "var(--foreground)" }}>{msg.text}</p>
|
||||||
|
{msg.files && msg.files.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 mt-2">
|
||||||
|
{msg.files.map((f, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={f.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--background)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
width: "fit-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📎 {f.name}
|
||||||
|
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply form */}
|
||||||
|
<div
|
||||||
|
className="p-3 shrink-0"
|
||||||
|
style={{ borderTop: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex gap-2"
|
||||||
|
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => 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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleReply}
|
||||||
|
disabled={sending || !replyText.trim() || detail.status === "CLOSED"}
|
||||||
|
className="text-xs font-bold px-4 py-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--foreground)",
|
||||||
|
color: "var(--background)",
|
||||||
|
border: "none",
|
||||||
|
opacity: sending ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? "..." : "Отправить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--destructive)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user