Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user