Test / components /studio /AdminPanel.tsx
TrikozikGames's picture
Add source files
eddc354
Raw
History Blame Contribute Delete
15.2 kB
import { useCallback, useEffect, useState } from "react";
import { Check, Copy, FolderOpen, KeyRound, MessageSquare, RotateCcw, Trash2, UserPlus, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
createUser,
deleteUser,
listUsers,
resetPassword,
roleLabel,
statusLabel,
type StoredUser,
} from "@/lib/auth-service";
import {
countProjectsByOwner,
formatStamp,
listProjects,
removeProjectsOfOwner,
type StoredProject,
} from "@/lib/studio-storage";
import { listFeedback, markFeedbackRead, removeFeedback, type FeedbackItem } from "@/lib/feedback-service";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
};
export default function AdminPanel({ open, onOpenChange }: Props) {
const [users, setUsers] = useState<StoredUser[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [createOpen, setCreateOpen] = useState(false);
const [newLogin, setNewLogin] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [credentials, setCredentials] = useState<{ username: string; password: string } | null>(null);
const [toDelete, setToDelete] = useState<StoredUser | null>(null);
const [viewing, setViewing] = useState<{ user: StoredUser; items: StoredProject[] } | null>(null);
const [feedback, setFeedback] = useState<FeedbackItem[]>([]);
const refresh = useCallback(() => {
setUsers(listUsers());
setCounts(countProjectsByOwner());
setFeedback(listFeedback());
}, []);
useEffect(() => {
if (open) refresh();
}, [open, refresh]);
const totalProjects = Object.values(counts).reduce((a, b) => a + b, 0);
const activeUsers = users.filter((u) => u.status === "active").length;
const pendingUsers = users.length - activeUsers;
const copy = (value: string) => {
void navigator.clipboard?.writeText(value);
};
const submitCreate = () => {
const result = createUser(newLogin);
if (!result.ok) {
setCreateError(result.error);
return;
}
setCreateError(null);
setCreateOpen(false);
setNewLogin("");
setCredentials({ username: result.user.username, password: result.tempPassword });
refresh();
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 font-display text-2xl">
<Users className="h-6 w-6 text-accent" /> Панель админа
</DialogTitle>
<DialogDescription>Управление пользователями студии и обзор сохранённых схем.</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-5">
{[
{ label: "Пользователей", value: users.length },
{ label: "Активных", value: activeUsers },
{ label: "Ожидают входа", value: pendingUsers },
{ label: "Всего схем", value: totalProjects },
{ label: "Новых пожеланий", value: feedback.filter((f) => !f.read).length },
].map((s) => (
<div key={s.label} className="rounded-2xl border border-border bg-secondary p-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="font-display text-3xl leading-tight">{s.value}</p>
</div>
))}
</div>
<section className="rounded-2xl border border-border">
<h3 className="flex items-center gap-2 border-b border-border px-4 py-2 font-display text-lg">
<MessageSquare className="h-5 w-5 text-accent" /> Пожелания пользователей
</h3>
{feedback.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">Пока нет сообщений.</p>
) : (
<ul className="max-h-64 divide-y divide-border overflow-y-auto">
{feedback.map((f) => (
<li key={f.id} className="flex items-start gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">
{f.username} · {formatStamp(f.createdAt)}
{!f.read && <Badge className="ml-2 align-middle">новое</Badge>}
</p>
<p className="whitespace-pre-wrap text-sm">{f.text}</p>
</div>
<div className="flex shrink-0 gap-1">
{!f.read && (
<Button
variant="outline"
size="icon"
className="h-8 w-8"
title="Отметить прочитанным"
onClick={() => setFeedback(markFeedbackRead(f.id))}
>
<Check className="h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive"
title="Удалить сообщение"
onClick={() => setFeedback(removeFeedback(f.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
<div>
<Button size="lg" className="h-12 gap-2" onClick={() => setCreateOpen(true)}>
<UserPlus className="h-5 w-5" /> Создать пользователя
</Button>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Логин</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Создан</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Схемы</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{roleLabel(u.role)}</TableCell>
<TableCell className="text-muted-foreground">{formatStamp(u.createdAt)}</TableCell>
<TableCell>
<Badge variant={u.status === "active" ? "secondary" : "outline"}>{statusLabel(u.status)}</Badge>
</TableCell>
<TableCell>{counts[u.id] ?? 0}</TableCell>
<TableCell>
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
className="h-10 gap-1"
title="Сбросить пароль и выдать временный"
onClick={() => {
const password = resetPassword(u.id);
setCredentials({ username: u.username, password });
refresh();
}}
>
<RotateCcw className="h-4 w-4" /> Сбросить пароль
</Button>
<Button
variant="outline"
size="sm"
className="h-10 gap-1"
title="Просмотреть проекты пользователя"
onClick={() => setViewing({ user: u, items: listProjects(u.id) })}
>
<FolderOpen className="h-4 w-4" /> Просмотреть проекты
</Button>
<Button
variant="destructive"
size="sm"
className="h-10 gap-1"
disabled={u.role === "admin"}
title={
u.role === "admin" ? "Администратора удалить нельзя" : "Удалить пользователя и его схемы"
}
onClick={() => setToDelete(u)}
>
<Trash2 className="h-4 w-4" /> Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</DialogContent>
</Dialog>
{/* Создание пользователя */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="font-display text-2xl">Создать пользователя</DialogTitle>
<DialogDescription>
Укажите логин — система выдаст одноразовый временный пароль для первого входа.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-user-login">Логин</Label>
<Input
id="new-user-login"
autoFocus
value={newLogin}
onChange={(e) => {
setNewLogin(e.target.value);
setCreateError(null);
}}
className="h-12"
/>
{createError && <p className="text-sm text-destructive">{createError}</p>}
</div>
<Button size="lg" className="h-12 w-full" onClick={submitCreate}>
<UserPlus className="h-5 w-5" /> Создать и выдать пароль
</Button>
</DialogContent>
</Dialog>
{/* Показ временного пароля */}
<Dialog open={!!credentials} onOpenChange={(v) => !v && setCredentials(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 font-display text-2xl">
<KeyRound className="h-6 w-6 text-accent" /> Временный пароль
</DialogTitle>
<DialogDescription>
Передайте пароль пользователю «{credentials?.username}». При первом входе он задаст постоянный пароль.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-xl border border-border bg-secondary px-4 py-3 text-center text-lg tracking-widest">
{credentials?.password}
</code>
<Button
variant="outline"
size="lg"
className="h-12 gap-2"
title="Скопировать пароль"
onClick={() => credentials && copy(credentials.password)}
>
<Copy className="h-5 w-5" />
</Button>
</div>
<Button size="lg" className="h-12 w-full" onClick={() => setCredentials(null)}>
Готово
</Button>
</DialogContent>
</Dialog>
{/* Проекты пользователя */}
<Dialog open={!!viewing} onOpenChange={(v) => !v && setViewing(null)}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-display text-2xl">Проекты «{viewing?.user.username}»</DialogTitle>
<DialogDescription>Схемы, сохранённые этим пользователем.</DialogDescription>
</DialogHeader>
{viewing && viewing.items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">У пользователя пока нет схем.</p>
) : (
<div className="grid gap-4 sm:grid-cols-3">
{viewing?.items.map((p) => (
<article key={p.id} className="overflow-hidden rounded-2xl border border-border bg-card">
<img
src={p.thumb}
alt={`Превью схемы «${p.title}»`}
className="aspect-[4/3] w-full bg-secondary object-cover"
/>
<div className="space-y-1 p-3">
<h3 className="truncate font-medium">{p.title}</h3>
<p className="text-xs text-muted-foreground">
{formatStamp(p.updatedAt)} · элементов: {p.elements}
</p>
</div>
</article>
))}
</div>
)}
</DialogContent>
</Dialog>
<AlertDialog open={!!toDelete} onOpenChange={(v) => !v && setToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
Пользователь «{toDelete?.username}» и все его схемы будут удалены безвозвратно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (toDelete) {
removeProjectsOfOwner(toDelete.id);
deleteUser(toDelete.id);
}
setToDelete(null);
refresh();
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}