Polish: homework filters, users search/popup, admin comments

Homework (/curator/homework):
- Search by student name/email
- Filter by status (pending/reviewed) and course
- Server-side pagination (20 per page) with URL params

Users (/admin/users):
- Search by name/email, filter by role
- Hover popup on each row: enrolled courses + expiry dates + email
- Pagination (20 per page) with URL params

Comments (/admin/comments):
- New admin page with all active comments
- Search by author or text content
- One-click delete (soft-delete) from the table
- Pagination (30 per page)
- Added "Комментарии" link to admin nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 13:00:57 +05:00
parent dd46a10c20
commit d0ba4bf909
9 changed files with 874 additions and 152 deletions
+1
View File
@@ -9,6 +9,7 @@ const links = [
{ href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", label: "Пользователи" },
{ href: "/curator/homework", label: "ДЗ на проверку" },
{ href: "/admin/comments", label: "Комментарии" },
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
{ href: "/admin/settings", label: "Настройки" },
];
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Trash2, Search } from "lucide-react";
import Link from "next/link";
import { adminDeleteComment } from "@/app/admin/comments/actions";
type Comment = {
id: string;
text: string;
createdAt: Date;
user: { id: string; name: string; email: string };
lesson: {
id: string;
title: string;
module: { course: { slug: string; title: string } };
};
};
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
};
export function CommentsTable({ comments, search }: { comments: Comment[]; search: string }) {
const router = useRouter();
const pathname = usePathname();
const [pending, startTransition] = useTransition();
function updateSearch(value: string) {
const params = new URLSearchParams();
if (value) params.set("search", value);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
function handleDelete(id: string) {
if (!confirm("Удалить комментарий?")) return;
startTransition(async () => {
await adminDeleteComment(id);
});
}
return (
<div>
{/* Search */}
<div className="flex gap-2 mb-4">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
<input
defaultValue={search}
placeholder="Поиск по автору или тексту"
style={{ ...inputStyle, paddingLeft: "2rem", width: 260 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
updateSearch(e.currentTarget.value.trim());
}}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
</div>
{search && (
<button type="button" onClick={() => startTransition(() => router.push(pathname))}
className="text-xs px-3" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
Сбросить
</button>
)}
</div>
{comments.length === 0 ? (
<div className="card-aubade p-10 text-center">
<p className="font-bold">Комментариев нет</p>
</div>
) : (
<div style={{ border: "2px solid var(--border)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "2px solid var(--border)", background: "var(--background)" }}>
{["Автор", "Урок", "Комментарий", "Дата", ""].map((h) => (
<th key={h} className="text-left px-4 py-2.5 text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{comments.map((c) => {
const lessonUrl = `/courses/${c.lesson.module.course.slug}/lessons/${c.lesson.id}`;
return (
<tr key={c.id} style={{ borderBottom: "1px solid var(--border)", opacity: pending ? 0.6 : 1 }}>
<td className="px-4 py-3 whitespace-nowrap">
<Link href={`/admin/users/${c.user.id}`} className="font-medium hover:underline text-sm">
{c.user.name}
</Link>
<p className="text-xs font-mono" style={{ color: "var(--muted-foreground)" }}>{c.user.email}</p>
</td>
<td className="px-4 py-3">
<Link href={lessonUrl} className="text-xs hover:underline" target="_blank">
{c.lesson.title}
</Link>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{c.lesson.module.course.title}
</p>
</td>
<td className="px-4 py-3 max-w-xs">
<p className="text-xs line-clamp-2" style={{ color: "var(--foreground)" }}>{c.text}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs" style={{ color: "var(--muted-foreground)" }}>
{new Date(c.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => handleDelete(c.id)}
title="Удалить"
className="p-1.5 transition-opacity hover:opacity-60"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
<Trash2 size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Search } from "lucide-react";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
};
type Course = { id: string; title: string };
export function HomeworkFilters({ courses }: { courses: Course[] }) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [, startTransition] = useTransition();
function update(key: string, value: string) {
const params = new URLSearchParams(sp.toString());
if (value) params.set(key, value);
else params.delete(key);
params.delete("page"); // reset to page 1 on filter change
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
const status = sp.get("status") ?? "";
const courseId = sp.get("courseId") ?? "";
const search = sp.get("search") ?? "";
return (
<div className="flex flex-wrap gap-2 mb-5">
{/* Search */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
<input
defaultValue={search}
placeholder="Имя или email ученика"
style={{ ...inputStyle, paddingLeft: "2rem", width: 220 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
update("search", e.currentTarget.value.trim());
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
</div>
{/* Status */}
<select
value={status}
onChange={(e) => update("status", e.target.value)}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Все статусы</option>
<option value="pending">Ожидают проверки</option>
<option value="reviewed">Проверено</option>
</select>
{/* Course */}
<select
value={courseId}
onChange={(e) => update("courseId", e.target.value)}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Все курсы</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
{/* Reset */}
{(search || status || courseId) && (
<button
type="button"
onClick={() => {
startTransition(() => router.push(pathname));
}}
className="text-xs px-3"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Сбросить
</button>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Search } from "lucide-react";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
};
export function UsersSearch({ initialSearch, initialRole }: { initialSearch: string; initialRole: string }) {
const router = useRouter();
const pathname = usePathname();
const [, startTransition] = useTransition();
function update(search: string, role: string) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
return (
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
<input
defaultValue={initialSearch}
placeholder="Поиск по имени или email"
style={{ ...inputStyle, paddingLeft: "2rem", width: 240 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
update(e.currentTarget.value.trim(), initialRole);
}}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
</div>
<select
defaultValue={initialRole}
onChange={(e) => update(initialSearch, e.target.value)}
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="student">Ученики</option>
<option value="curator">Кураторы</option>
<option value="admin">Администраторы</option>
</select>
{(initialSearch || initialRole) && (
<button
type="button"
onClick={() => startTransition(() => router.push(pathname))}
className="text-xs px-3"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Сбросить
</button>
)}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
type Enrollment = {
courseId: string;
courseTitle: string;
expiresAt: Date | null;
};
type UserRow = {
id: string;
name: string;
email: string;
role: string;
emailVerified: boolean;
createdAt: Date;
enrollmentCount: number;
enrollments: Enrollment[];
};
const roleLabel: Record<string, string> = {
admin: "Администратор",
curator: "Куратор",
student: "Ученик",
};
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
admin: "default",
curator: "secondary",
student: "outline",
};
function UserPopup({ user }: { user: UserRow }) {
const now = new Date();
return (
<div
className="absolute z-50 right-0 top-full mt-1 w-72 p-4 space-y-3 text-sm"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "4px 4px 0 0 var(--foreground)",
}}
>
{/* Contact */}
<div className="space-y-0.5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Контакты</p>
<p className="font-mono text-xs">{user.email}</p>
</div>
{/* Courses */}
{user.enrollments.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Курсы ({user.enrollments.length})
</p>
{user.enrollments.map((e) => {
const expired = e.expiresAt && new Date(e.expiresAt) < now;
return (
<div key={e.courseId} className="flex items-start justify-between gap-2">
<p className="text-xs flex-1 truncate">{e.courseTitle}</p>
<span
className="text-xs shrink-0"
style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}
>
{e.expiresAt
? expired
? "просрочен"
: `до ${new Date(e.expiresAt).toLocaleDateString("ru-RU")}`
: "бессрочно"}
</span>
</div>
);
})}
</div>
) : (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Курсов нет</p>
)}
<Link
href={`/admin/users/${user.id}`}
className="block text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Открыть профиль
</Link>
</div>
);
}
export function UsersTable({ users }: { users: UserRow[] }) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<div className="bg-white border border-slate-200 rounded-2xl overflow-visible">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
{["Пользователь", "Роль", "Курсов", "Email подтверждён", "Зарегистрирован", ""].map((h) => (
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-b last:border-0"
style={{ borderColor: "var(--border)" }}
>
<td className="px-5 py-3">
<Link
href={`/admin/users/${user.id}`}
className="font-medium hover:underline"
style={{ color: "var(--foreground)" }}
>
{user.name}
</Link>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</td>
<td className="px-5 py-3">
<Badge variant={roleVariant[user.role] ?? "outline"}>
{roleLabel[user.role] ?? user.role}
</Badge>
</td>
<td className="px-5 py-3 text-sm text-slate-600">{user.enrollmentCount}</td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
{user.emailVerified ? "Да" : "Нет"}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-400">
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
</td>
{/* Hover popup trigger */}
<td className="px-3 py-3 relative">
<div
className="relative inline-block"
onMouseEnter={() => setHoveredId(user.id)}
onMouseLeave={() => setHoveredId(null)}
>
<button
type="button"
className="text-xs px-2 py-1"
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
>
···
</button>
{hoveredId === user.id && <UserPopup user={user} />}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}