Spaces:
Sleeping
Sleeping
| 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> | |
| </> | |
| ); | |
| } | |