Compare commits
7 Commits
e5ba94cb33
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b57b14d9b | |||
| 367764b71e | |||
| acf7ee49aa | |||
| 751c012f3d | |||
| 7084806aac | |||
| b2fa98051f | |||
| 4f5b5c535a |
@@ -1,15 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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) {
|
||||
e.preventDefault();
|
||||
@@ -21,7 +62,7 @@ export default function NewQuestionPage() {
|
||||
const res = await fetch("/api/questions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, text }),
|
||||
body: JSON.stringify({ title, text, files }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -37,8 +78,8 @@ export default function NewQuestionPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="max-w-2xl mx-auto px-6 py-10">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/questions"
|
||||
className="text-sm"
|
||||
@@ -48,14 +89,17 @@ export default function NewQuestionPage() {
|
||||
</Link>
|
||||
</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>
|
||||
<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>
|
||||
<label
|
||||
className="block text-sm font-bold mb-1"
|
||||
className="block text-sm font-bold mb-2"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Тема вопроса
|
||||
@@ -66,18 +110,21 @@ export default function NewQuestionPage() {
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Кратко опишите суть вопроса"
|
||||
required
|
||||
className="w-full text-sm px-3 py-2 outline-none"
|
||||
className="w-full text-sm px-4 py-3 outline-none"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||
Например: «Не получается настроить плагин» или «Вопрос по уроку 3»
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-bold mb-1"
|
||||
className="block text-sm font-bold mb-2"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Описание
|
||||
@@ -85,37 +132,88 @@ export default function NewQuestionPage() {
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Подробно опишите вопрос или проблему"
|
||||
placeholder="Подробно опишите вопрос или проблему. Что именно не получается? Что уже пробовали? Прикрепите скриншот или ссылку, если нужно."
|
||||
required
|
||||
rows={6}
|
||||
className="w-full text-sm px-3 py-2 outline-none resize-none"
|
||||
rows={10}
|
||||
className="w-full text-sm px-4 py-3 outline-none resize-y"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--foreground)",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
/>
|
||||
</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 && (
|
||||
<p className="text-sm" style={{ color: "var(--destructive)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !title.trim() || !text.trim()}
|
||||
className="self-end text-sm font-bold px-6 py-2"
|
||||
style={{
|
||||
background: "var(--foreground)",
|
||||
color: "var(--background)",
|
||||
border: "none",
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Отправка..." : "Отправить →"}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || uploading || !title.trim() || !text.trim()}
|
||||
className="text-sm font-bold px-8 py-3"
|
||||
style={{
|
||||
background: "var(--foreground)",
|
||||
color: "var(--background)",
|
||||
border: "none",
|
||||
opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Отправка..." : "Отправить →"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function deleteComment(commentId: string): Promise<{ ok: boolean }> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||
|
||||
await prisma.lessonComment.update({
|
||||
where: { id: commentId },
|
||||
data: { deleted: true },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/comments");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { CommentsTable } from "@/components/admin/comments-table";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = { title: "Комментарии" };
|
||||
|
||||
@@ -12,6 +15,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export default async function AdminCommentsPage({ searchParams }: Props) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") redirect("/dashboard");
|
||||
|
||||
const { page = "1", search = "" } = await searchParams;
|
||||
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||
@@ -106,7 +112,7 @@ export default async function AdminCommentsPage({ searchParams }: Props) {
|
||||
{currentPage < totalPages && (
|
||||
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||
)}
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sendCourseAccessEmail } from "@/lib/email";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function grantCourseAccess(
|
||||
userId: string,
|
||||
courseId: string,
|
||||
expiresAt: Date | null
|
||||
): Promise<{ ok: true } | { error: string }> {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const [user, course] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
||||
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
|
||||
]);
|
||||
|
||||
if (!user) return { error: "Пользователь не найден" };
|
||||
if (!course) return { error: "Курс не найден" };
|
||||
|
||||
const existing = await prisma.courseEnrollment.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
|
||||
await prisma.courseEnrollment.upsert({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
update: { expiresAt },
|
||||
create: { userId, courseId, expiresAt },
|
||||
});
|
||||
|
||||
await prisma.accessLog.create({
|
||||
data: {
|
||||
courseId,
|
||||
userId,
|
||||
action: "granted",
|
||||
method: "quick",
|
||||
grantedById: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email only on new enrollment (not on update)
|
||||
if (!existing) {
|
||||
await sendCourseAccessEmail(user.email, user.name ?? user.email, course.title).catch(
|
||||
(e) => console.error("[enroll-action] sendCourseAccessEmail:", e)
|
||||
);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error("[enroll-action] grantCourseAccess:", e);
|
||||
return { error: "Произошла ошибка. Попробуйте ещё раз." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublishedCourses(): Promise<{ id: string; title: string }[]> {
|
||||
await requireAdmin();
|
||||
return prisma.course.findMany({
|
||||
where: { published: true },
|
||||
select: { id: true, title: true },
|
||||
orderBy: { title: "asc" },
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { UsersSearch } from "@/components/admin/users-search";
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string }>;
|
||||
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string; emailVerified?: string }>;
|
||||
}
|
||||
|
||||
export default async function UsersPage({ searchParams }: Props) {
|
||||
const { search = "", role = "", page = "1", balance = "" } = await searchParams;
|
||||
const { search = "", role = "", page = "1", balance = "", emailVerified = "" } = await searchParams;
|
||||
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||
|
||||
@@ -37,6 +37,8 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
}
|
||||
: {}),
|
||||
...(role ? { role } : {}),
|
||||
...(emailVerified === "true" ? { emailVerified: true } : {}),
|
||||
...(emailVerified === "false" ? { emailVerified: false } : {}),
|
||||
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
|
||||
};
|
||||
|
||||
@@ -78,6 +80,7 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (role) params.set("role", role);
|
||||
if (emailVerified) params.set("emailVerified", emailVerified);
|
||||
if (balance) params.set("balance", balance);
|
||||
params.set("page", String(p));
|
||||
return `/admin/users?${params.toString()}`;
|
||||
@@ -101,7 +104,7 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
|
||||
{/* Filters */}
|
||||
<Suspense>
|
||||
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} />
|
||||
<UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
|
||||
</Suspense>
|
||||
|
||||
<UsersTable users={tableUsers} />
|
||||
@@ -132,7 +135,7 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
{currentPage < totalPages && (
|
||||
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||
)}
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,18 @@ import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -59,16 +71,29 @@ export async function POST(req: NextRequest) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const { title, text, courseId } = body as {
|
||||
const { title, text, courseId, files } = body as {
|
||||
title: string;
|
||||
text: string;
|
||||
courseId?: string;
|
||||
files?: FileAttachment[];
|
||||
};
|
||||
|
||||
if (!title?.trim() || !text?.trim()) {
|
||||
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({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
@@ -78,6 +103,7 @@ export async function POST(req: NextRequest) {
|
||||
create: {
|
||||
authorId: session.user.id,
|
||||
text: text.trim(),
|
||||
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { HomeworkFilters } from "@/components/admin/homework-filters";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
q?: string;
|
||||
status?: string;
|
||||
courseId?: string;
|
||||
page?: string;
|
||||
@@ -15,20 +14,22 @@ interface Props {
|
||||
}
|
||||
|
||||
export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
const { search = "", status = "", courseId = "", page = "1" } = await searchParams;
|
||||
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||
const sp = await searchParams;
|
||||
const q = sp.q ?? "";
|
||||
const status = sp.status ?? "";
|
||||
const courseId = sp.courseId ?? "";
|
||||
const currentPage = Math.max(1, parseInt(sp.page ?? "1") || 1);
|
||||
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||
|
||||
// Build where clause
|
||||
const where = {
|
||||
...(search
|
||||
...(q
|
||||
? {
|
||||
user: {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" as const } },
|
||||
{ email: { contains: search, mode: "insensitive" as const } },
|
||||
],
|
||||
},
|
||||
OR: [
|
||||
{ user: { name: { contains: q, mode: "insensitive" as const } } },
|
||||
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||
{ homework: { lesson: { title: { contains: q, mode: "insensitive" as const } } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(courseId
|
||||
@@ -38,10 +39,8 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(status === "pending" ? { status: "PENDING" } : {}),
|
||||
...(status === "reviewing" ? { status: "REVIEWING" } : {}),
|
||||
...(status === "approved" ? { status: "APPROVED" } : {}),
|
||||
...(status === "rejected" ? { status: "REJECTED" } : {}),
|
||||
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
|
||||
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
|
||||
};
|
||||
|
||||
const [submissions, total, courses] = await Promise.all([
|
||||
@@ -71,17 +70,25 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
|
||||
|
||||
function pageUrl(p: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (q) params.set("q", q);
|
||||
if (status) params.set("status", status);
|
||||
if (courseId) params.set("courseId", courseId);
|
||||
params.set("page", String(p));
|
||||
return `/curator/homework?${params.toString()}`;
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "inherit",
|
||||
color: "var(--foreground)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<div className="mb-6">
|
||||
@@ -92,12 +99,59 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
Домашние задания
|
||||
</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки
|
||||
{total} {total === 1 ? "работа" : "работ"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Suspense>
|
||||
<HomeworkFilters courses={courses} />
|
||||
<form method="GET" className="flex flex-wrap gap-2 mb-5">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Поиск по ученику или уроку"
|
||||
style={{ ...inputStyle, width: 260 }}
|
||||
/>
|
||||
|
||||
<select
|
||||
name="courseId"
|
||||
defaultValue={courseId}
|
||||
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
|
||||
>
|
||||
<option value="">Все курсы</option>
|
||||
{courses.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={status}
|
||||
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="pending">Без ответа</option>
|
||||
<option value="reviewed">С отзывом</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1 text-xs font-medium"
|
||||
style={{ border: "2px solid var(--foreground)", background: "var(--foreground)", color: "var(--background)", cursor: "pointer" }}
|
||||
>
|
||||
Найти
|
||||
</button>
|
||||
|
||||
{(q || status || courseId) && (
|
||||
<Link
|
||||
href="/curator/homework"
|
||||
className="px-3 py-1 text-xs"
|
||||
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Сбросить
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
</Suspense>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
@@ -111,19 +165,16 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{submissions.map((s) => {
|
||||
const statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = {
|
||||
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" },
|
||||
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" },
|
||||
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" },
|
||||
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)", border: "oklch(0.75 0.1 27)" },
|
||||
};
|
||||
const st = statusMap[s.status] ?? statusMap.PENDING;
|
||||
const hasReview = s.feedbacks.length > 0;
|
||||
const reviewBadge = hasReview
|
||||
? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" }
|
||||
: { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/curator/homework/${s.id}`}
|
||||
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80"
|
||||
style={{ border: `2px solid ${st.border}`, display: "flex" }}
|
||||
style={{ border: "2px solid var(--border)", display: "flex" }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{s.user.name}</p>
|
||||
@@ -137,9 +188,9 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 font-medium"
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
style={{ background: reviewBadge.bg, color: reviewBadge.color }}
|
||||
>
|
||||
{st.label}
|
||||
{reviewBadge.label}
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
|
||||
@@ -186,7 +237,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||
)}
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
стр. {currentPage} из {totalPages}
|
||||
Страница {currentPage} из {totalPages} · Всего: {total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useEffect, useRef } from "react";
|
||||
import { grantCourseAccess, getPublishedCourses } from "@/app/admin/users/enroll-action";
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export function QuickEnrollButton({ userId, userName }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loadingCourses, setLoadingCourses] = useState(false);
|
||||
const [courseId, setCourseId] = useState("");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load courses when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingCourses(true);
|
||||
getPublishedCourses()
|
||||
.then((data) => {
|
||||
setCourses(data);
|
||||
if (data.length > 0) setCourseId(data[0].id);
|
||||
})
|
||||
.finally(() => setLoadingCourses(false));
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
setStatus("idle");
|
||||
setErrorMsg("");
|
||||
setExpiresAt("");
|
||||
setCourseId("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (pending) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!courseId) return;
|
||||
const expiry = expiresAt ? new Date(expiresAt) : null;
|
||||
startTransition(async () => {
|
||||
const result = await grantCourseAccess(userId, courseId, expiry);
|
||||
if ("error" in result) {
|
||||
setStatus("error");
|
||||
setErrorMsg(result.error);
|
||||
} else {
|
||||
setStatus("success");
|
||||
setTimeout(() => setOpen(false), 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="text-xs px-2 py-1 transition-colors"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
background: "transparent",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={`Выдать доступ к курсу — ${userName}`}
|
||||
>
|
||||
+ Доступ
|
||||
</button>
|
||||
|
||||
{/* Modal overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "rgba(50,50,50,0.45)" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="w-full max-w-sm p-6 space-y-4"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "2px solid var(--foreground)",
|
||||
boxShadow: "6px 6px 0 0 var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Выдать доступ
|
||||
</p>
|
||||
<p className="text-sm font-semibold mt-0.5" style={{ color: "var(--foreground)" }}>
|
||||
{userName}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={pending}
|
||||
className="text-xs leading-none"
|
||||
style={{ color: "var(--muted-foreground)", fontSize: "18px", lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Course select */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Курс
|
||||
</label>
|
||||
{loadingCourses ? (
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Загрузка…
|
||||
</p>
|
||||
) : courses.length === 0 ? (
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Нет опубликованных курсов
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
value={courseId}
|
||||
onChange={(e) => setCourseId(e.target.value)}
|
||||
disabled={pending}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
padding: "0.4rem 0.5rem",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
outline: "none",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
>
|
||||
{courses.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expiry date (optional) */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Срок доступа (необязательно)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
disabled={pending}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
color: expiresAt ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
padding: "0.4rem 0.5rem",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
outline: "none",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
Оставьте пустым для бессрочного доступа
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{status === "error" && (
|
||||
<p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{status === "success" && (
|
||||
<p className="text-xs font-semibold" style={{ color: "#3A6A3A" }}>
|
||||
Доступ выдан
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending || loadingCourses || !courseId || status === "success"}
|
||||
className="btn-aubade btn-aubade-accent text-xs px-4 py-2"
|
||||
>
|
||||
{pending ? "Выдаю…" : "Выдать"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={pending}
|
||||
className="btn-aubade text-xs px-4 py-2"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,20 +16,23 @@ const inputStyle: React.CSSProperties = {
|
||||
export function UsersSearch({
|
||||
initialSearch,
|
||||
initialRole,
|
||||
initialEmailVerified,
|
||||
initialBalance,
|
||||
}: {
|
||||
initialSearch: string;
|
||||
initialRole: string;
|
||||
initialEmailVerified: string;
|
||||
initialBalance: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function update(search: string, role: string, balance: string) {
|
||||
function update(search: string, role: string, emailVerified: string, balance: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (role) params.set("role", role);
|
||||
if (emailVerified) params.set("emailVerified", emailVerified);
|
||||
if (balance) params.set("balance", balance);
|
||||
startTransition(() => router.push(`${pathname}?${params.toString()}`));
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export function UsersSearch({
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
update(e.currentTarget.value.trim(), initialRole, initialBalance);
|
||||
update(e.currentTarget.value.trim(), initialRole, initialEmailVerified, initialBalance);
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
|
||||
/>
|
||||
@@ -53,7 +56,7 @@ export function UsersSearch({
|
||||
|
||||
<select
|
||||
defaultValue={initialRole}
|
||||
onChange={(e) => update(initialSearch, e.target.value, initialBalance)}
|
||||
onChange={(e) => update(initialSearch, e.target.value, initialEmailVerified, initialBalance)}
|
||||
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
@@ -64,9 +67,21 @@ export function UsersSearch({
|
||||
<option value="admin">Администраторы</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
defaultValue={initialEmailVerified}
|
||||
onChange={(e) => update(initialSearch, initialRole, e.target.value, initialBalance)}
|
||||
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
>
|
||||
<option value="">Любой статус</option>
|
||||
<option value="true">Email подтверждён</option>
|
||||
<option value="false">Не подтверждён</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")}
|
||||
onClick={() => update(initialSearch, initialRole, initialEmailVerified, initialBalance === "nonzero" ? "" : "nonzero")}
|
||||
className="text-xs px-3"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
@@ -78,7 +93,7 @@ export function UsersSearch({
|
||||
С балансом
|
||||
</button>
|
||||
|
||||
{(initialSearch || initialRole || initialBalance) && (
|
||||
{(initialSearch || initialRole || initialEmailVerified || initialBalance) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startTransition(() => router.push(pathname))}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { QuickEnrollButton } from "@/components/admin/quick-enroll-modal";
|
||||
|
||||
type Enrollment = {
|
||||
courseId: string;
|
||||
@@ -175,6 +176,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
|
||||
<td className="px-3 py-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
|
||||
<QuickEnrollButton userId={user.id} userName={user.name ?? user.email} />
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={() => setHoveredId(user.id)}
|
||||
|
||||
@@ -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("Не удалось отправить ответ");
|
||||
@@ -323,11 +356,18 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
className="flex flex-col gap-2"
|
||||
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
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)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -340,26 +380,65 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string })
|
||||
placeholder={
|
||||
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={{
|
||||
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>
|
||||
{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 || 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>
|
||||
{error && (
|
||||
<p className="text-xs mt-1" style={{ color: "var(--destructive)" }}>
|
||||
|
||||
Reference in New Issue
Block a user