Compare commits
3 Commits
751c012f3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b57b14d9b | |||
| 367764b71e | |||
| acf7ee49aa |
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="flex justify-end">
|
||||||
type="submit"
|
<button
|
||||||
disabled={loading || !title.trim() || !text.trim()}
|
type="submit"
|
||||||
className="self-end text-sm font-bold px-6 py-2"
|
disabled={loading || uploading || !title.trim() || !text.trim()}
|
||||||
style={{
|
className="text-sm font-bold px-8 py-3"
|
||||||
background: "var(--foreground)",
|
style={{
|
||||||
color: "var(--background)",
|
background: "var(--foreground)",
|
||||||
border: "none",
|
color: "var(--background)",
|
||||||
opacity: loading ? 0.6 : 1,
|
border: "none",
|
||||||
}}
|
opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
|
||||||
>
|
}}
|
||||||
{loading ? "Отправка..." : "Отправить →"}
|
>
|
||||||
</button>
|
{loading ? "Отправка..." : "Отправить →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,26 +380,65 @@ 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)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
{replyFiles.length > 0 && (
|
||||||
onClick={handleReply}
|
<div className="flex flex-wrap gap-1.5">
|
||||||
disabled={sending || !replyText.trim() || detail.status === "CLOSED"}
|
{replyFiles.map((f, i) => (
|
||||||
className="text-xs font-bold px-4 py-2"
|
<div
|
||||||
style={{
|
key={f.url}
|
||||||
background: "var(--foreground)",
|
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||||
color: "var(--background)",
|
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
|
||||||
border: "none",
|
>
|
||||||
opacity: sending ? 0.6 : 1,
|
📎 {f.name}
|
||||||
}}
|
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
|
||||||
>
|
<button
|
||||||
{sending ? "..." : "Отправить"}
|
type="button"
|
||||||
</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 || 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 || uploading || ((!replyText.trim()) && replyFiles.length === 0) ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? "..." : "Отправить →"}
|
||||||
|
</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)" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user