Files
lms-sb/src/components/questions/QuestionSplitView.tsx
T
2026-05-19 13:45:57 +05:00

354 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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="flex flex-col"
style={{ width: "45%", 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 flex-col overflow-hidden" style={{ width: "55%" }}>
{!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 ? "var(--muted)" : "var(--background)",
border: isMine ? "none" : "2px solid #E8F0D8",
}}
>
<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>
);
}