Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:41:20 +03:00
co-authored by Claude Opus 4.8
parent 96daddae05
commit c4065962e2
19 changed files with 1165 additions and 6 deletions
@@ -0,0 +1,192 @@
import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
useAdminAchievements,
useCreateAchievement,
useDeleteAchievement,
useUpdateAchievement,
useUploadAchievementIcon,
} from "../../hooks/admin";
import type { AchievementRead } from "../../domain/types";
export function AdminAchievementsPage() {
const { data: items, isLoading } = useAdminAchievements();
const create = useCreateAchievement();
const toast = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [condition, setCondition] = useState("");
if (isLoading) return <Spinner />;
const onCreate = async () => {
if (!name.trim()) return;
try {
await create.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
condition: condition.trim() || undefined,
});
setName("");
setDescription("");
setCondition("");
toast.show("Ачивка создана");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div>
<div className="card">
<h3>Новая ачивка</h3>
<div className="field">
<label className="label">Название</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Великий полководец" />
</div>
<div className="field">
<label className="label">Краткое описание</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="За что выдаётся"
/>
</div>
<div className="field">
<label className="label">Скрипт-условие (задел на будущее, пока не исполняется)</label>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="# условие выдачи — добавится позже"
/>
</div>
<button className="btn btn-primary" onClick={onCreate} disabled={create.isPending || !name.trim()}>
Создать
</button>
</div>
<div className="stack">
{(items ?? []).map((a) => (
<AchievementCard key={a.slug} ach={a} />
))}
{(items ?? []).length === 0 && <div className="muted small">Ачивок пока нет.</div>}
</div>
</div>
);
}
function AchievementCard({ ach }: { ach: AchievementRead }) {
const update = useUpdateAchievement();
const del = useDeleteAchievement();
const uploadIcon = useUploadAchievementIcon();
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition);
const save = async () => {
try {
await update.mutateAsync({
slug: ach.slug,
body: { name: name.trim(), description, condition },
});
setEditing(false);
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onIcon = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await uploadIcon.mutateAsync({ slug: ach.slug, file });
toast.show("Иконка обновлена");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const remove = async () => {
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div className="card" style={{ margin: 0 }}>
<div className="row" style={{ gap: 12, alignItems: "flex-start" }}>
<Avatar url={ach.icon_url} nickname={ach.name} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<div className="stack" style={{ gap: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Название" />
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание"
/>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="Скрипт-условие (задел)"
/>
<div className="row" style={{ gap: 8 }}>
<button className="btn btn-primary small" onClick={save} disabled={update.isPending || !name.trim()}>
Сохранить
</button>
<button className="btn btn-ghost small" onClick={() => setEditing(false)}>
Отмена
</button>
</div>
</div>
) : (
<>
<b>{ach.name}</b>
<div className="muted small">{ach.slug}</div>
{ach.description && <div style={{ marginTop: 4 }}>{ach.description}</div>}
<div className="muted small" style={{ marginTop: 4 }}>
{ach.has_condition ? "условие задано (не исполняется)" : "условие не задано"}
</div>
</>
)}
</div>
</div>
{!editing && (
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="btn small" onClick={() => fileRef.current?.click()} disabled={uploadIcon.isPending}>
Иконка
</button>
<button className="btn small" onClick={() => setEditing(true)}>
Изменить
</button>
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
Удалить
</button>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onIcon}
/>
</div>
)}
</div>
);
}
+3
View File
@@ -32,6 +32,9 @@ export function AdminLayout() {
<NavLink to="/admin/factions" className={tab}>
Фракции
</NavLink>
<NavLink to="/admin/achievements" className={tab}>
Ачивки
</NavLink>
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>