Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,4 +20,5 @@ export const qk = {
|
||||
adminGroups: ["adminGroups"] as const,
|
||||
adminMatches: ["adminMatches"] as const,
|
||||
adminLogs: ["adminLogs"] as const,
|
||||
adminAchievements: ["adminAchievements"] as const,
|
||||
};
|
||||
|
||||
Vendored
+339
@@ -509,6 +509,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/achievements": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Achievements */
|
||||
get: operations["list_achievements_api_achievements_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/achievements/{slug}/icon": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Icon */
|
||||
get: operations["get_icon_api_achievements__slug__icon_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -698,6 +732,59 @@ export interface paths {
|
||||
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/achievements": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Achievements */
|
||||
get: operations["list_achievements_api_admin_achievements_get"];
|
||||
put?: never;
|
||||
/** Create Achievement */
|
||||
post: operations["create_achievement_api_admin_achievements_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/achievements/{slug}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Achievement */
|
||||
delete: operations["delete_achievement_api_admin_achievements__slug__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update Achievement */
|
||||
patch: operations["update_achievement_api_admin_achievements__slug__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/achievements/{slug}/icon": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** Upload Achievement Icon */
|
||||
put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/audit-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -788,6 +875,48 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** AchievementCreate */
|
||||
AchievementCreate: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Condition */
|
||||
condition?: string | null;
|
||||
};
|
||||
/** AchievementRead */
|
||||
AchievementRead: {
|
||||
/** Slug */
|
||||
slug: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/**
|
||||
* Description
|
||||
* @default
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Condition
|
||||
* @default
|
||||
*/
|
||||
condition: string;
|
||||
/**
|
||||
* Has Condition
|
||||
* @default false
|
||||
*/
|
||||
has_condition: boolean;
|
||||
/** Icon Url */
|
||||
icon_url?: string | null;
|
||||
};
|
||||
/** AchievementUpdate */
|
||||
AchievementUpdate: {
|
||||
/** Name */
|
||||
name?: string | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Condition */
|
||||
condition?: string | null;
|
||||
};
|
||||
/** ActiveGroupUpdate */
|
||||
ActiveGroupUpdate: {
|
||||
/** Group Id */
|
||||
@@ -912,6 +1041,11 @@ export interface components {
|
||||
/** Telegram Bot Username */
|
||||
telegram_bot_username?: string | null;
|
||||
};
|
||||
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
|
||||
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_upload_my_avatar_api_users_me_avatar_put */
|
||||
Body_upload_my_avatar_api_users_me_avatar_put: {
|
||||
/** File */
|
||||
@@ -2640,6 +2774,57 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_achievements_api_achievements_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementRead"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_icon_api_achievements__slug__icon_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
admin_login_api_admin_auth_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3017,6 +3202,160 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_achievements_api_admin_achievements_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementRead"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_achievement_api_admin_achievements_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_achievement_api_admin_achievements__slug__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OkResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_achievement_api_admin_achievements__slug__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementUpdate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upload_achievement_icon_api_admin_achievements__slug__icon_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
slug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AchievementRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
audit_logs_api_admin_audit_logs_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||
import { OverallStatsPage } from "../pages/OverallStatsPage";
|
||||
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
|
||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||
@@ -65,6 +66,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "records", element: <AdminRecordsPage /> },
|
||||
{ path: "accounts", element: <AdminAccountsPage /> },
|
||||
{ path: "factions", element: <AdminFactionsPage /> },
|
||||
{ path: "achievements", element: <AdminAchievementsPage /> },
|
||||
{ path: "logs", element: <AdminLogsPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"];
|
||||
export type AdminGroupRead = S["AdminGroupRead"];
|
||||
export type AdminMatchRead = S["AdminMatchRead"];
|
||||
export type AuditLogList = S["AuditLogList"];
|
||||
export type AchievementRead = S["AchievementRead"];
|
||||
export type ParticipantInput = S["ParticipantInput"];
|
||||
export type MatchUpdate = S["MatchUpdate"];
|
||||
export type MatchCreate = S["MatchCreate"];
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { ApiError, api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
export function useAdminMe() {
|
||||
return useQuery({
|
||||
queryKey: qk.adminMe,
|
||||
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
|
||||
|
||||
interface AchievementInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export function useAdminAchievements() {
|
||||
return useQuery({
|
||||
queryKey: qk.adminAchievements,
|
||||
queryFn: async () => unwrap(await api.GET("/api/admin/achievements")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAchievement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: AchievementInput & { name: string }) =>
|
||||
unwrap(await api.POST("/api/admin/achievements", { body })),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAchievement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { slug: string; body: AchievementInput }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/achievements/{slug}", {
|
||||
params: { path: { slug: args.slug } },
|
||||
body: args.body,
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAchievement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (slug: string) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/admin/achievements/{slug}", {
|
||||
params: { path: { slug } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||
});
|
||||
}
|
||||
|
||||
// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie.
|
||||
export function useUploadAchievementIcon() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug, file }: { slug: string; file: File }) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const csrf = readCsrfToken();
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
credentials: "include",
|
||||
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
||||
});
|
||||
if (!r.ok) {
|
||||
let env: { code?: string; message?: string } | undefined;
|
||||
try {
|
||||
env = ((await r.json()) as { error?: typeof env }).error;
|
||||
} catch {
|
||||
/* тело без JSON */
|
||||
}
|
||||
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
|
||||
}
|
||||
return await r.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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