real back

This commit is contained in:
mixa
2026-06-22 22:39:08 +03:00
parent 27600872a8
commit c78212263b
38 changed files with 6884 additions and 479 deletions

View File

@@ -4,7 +4,7 @@ import { Elysia } from 'elysia';
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Возвращен' | 'Архив';
type Visibility = 'Публично' | 'После входа' | 'По роли';
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
@@ -28,6 +28,11 @@ type ContentItem = {
mimeType?: string;
fileName?: string;
fileSize?: number;
moderatorComment?: string;
reviewComment?: string;
ratingAverage?: number;
ratingCount?: number;
myRating?: number;
};
type MediaKind = NonNullable<ContentItem['mediaKind']>;
@@ -40,6 +45,18 @@ type StoredUpload = {
data: Buffer;
};
type NormalizedContentPatch = Partial<ContentItem> & {
title?: string;
category?: string;
lead?: string;
body?: string;
type?: ContentType;
status?: ContentStatus;
tags?: string[];
moderatorComment?: string;
reviewComment?: string;
};
type UserProfile = {
id: string;
name: string;
@@ -76,6 +93,28 @@ const serviceUrls: Record<string, string | undefined> = {
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event'];
const loginAliases: Record<string, string> = {
'admin@dstu.ru': 'demo_admin',
'editor@dstu.ru': 'demo_editor',
'moderator@dstu.ru': 'demo_moderator',
'manager@dstu.ru': 'demo_moderator',
'user@dstu.ru': 'demo_user'
};
const statusAliases: Record<string, ContentStatus> = {
draft: 'Черновик',
moderation: 'На модерации',
pending: 'На модерации',
review: 'На проверке',
published: 'Опубликовано',
returned: 'Возвращен',
archived: 'Архив',
черновик: 'Черновик',
'на модерации': 'На модерации',
'на проверке': 'На проверке',
опубликовано: 'Опубликовано',
возвращен: 'Возвращен',
архив: 'Архив'
};
const demoUser: UserProfile = {
id: 'demo-user-1',
@@ -88,6 +127,7 @@ const demoUser: UserProfile = {
const users: UserProfile[] = [
demoUser,
{ id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] },
{ id: 'demo-user-4', name: 'Демо-модератор', login: 'demo_moderator', roles: ['менеджер'], subscriptions: ['Мероприятия'] },
{ id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] }
];
@@ -298,6 +338,97 @@ function searchContent(query: Record<string, string | undefined>) {
return sorted;
}
function normalizeLogin(value = '') {
const login = value.trim().toLowerCase();
return loginAliases[login] ?? login;
}
function normalizeStatus(value: unknown): ContentStatus | undefined {
if (typeof value !== 'string') {
return undefined;
}
return statusAliases[value.trim().toLowerCase()] ?? (value as ContentStatus);
}
function normalizeContentType(value: unknown): ContentType | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'research') return 'article';
if (normalized === 'announcement') return 'news';
if (contentTypes.includes(normalized as ContentType)) return normalized as ContentType;
return undefined;
}
function normalizeTags(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (typeof value === 'string') {
return parseTags(value);
}
return [];
}
function normalizeContentPatch(payload: Record<string, unknown>): NormalizedContentPatch {
const nextType = normalizeContentType(payload.type);
const nextStatus = normalizeStatus(payload.status);
const nextTags = payload.tags === undefined ? undefined : normalizeTags(payload.tags);
return {
title: typeof payload.title === 'string' ? payload.title.trim() : undefined,
category: typeof payload.category === 'string' ? payload.category.trim() : undefined,
lead: typeof payload.lead === 'string' ? payload.lead : typeof payload.excerpt === 'string' ? payload.excerpt : undefined,
body: typeof payload.body === 'string' ? payload.body : typeof payload.content === 'string' ? payload.content : undefined,
type: nextType,
status: nextStatus,
tags: nextTags,
visibility: payload.visibility as Visibility | undefined,
imageTone: typeof payload.imageTone === 'string' ? payload.imageTone : undefined,
mediaUrl: typeof payload.mediaUrl === 'string' ? payload.mediaUrl : undefined,
mediaKind: payload.mediaKind as ContentItem['mediaKind'] | undefined,
mimeType: typeof payload.mimeType === 'string' ? payload.mimeType : undefined,
fileName: typeof payload.fileName === 'string' ? payload.fileName : undefined,
fileSize: typeof payload.fileSize === 'number' ? payload.fileSize : undefined,
ratingAverage: typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined,
ratingCount: typeof payload.ratingCount === 'number' ? payload.ratingCount : undefined,
myRating: typeof payload.myRating === 'number' ? payload.myRating : undefined,
moderatorComment:
typeof payload.moderatorComment === 'string'
? payload.moderatorComment.trim()
: typeof payload.reviewComment === 'string'
? payload.reviewComment.trim()
: undefined
};
}
function listContent(query: Record<string, string | undefined>, user: UserProfile | null) {
const requestedType = normalizeContentType(query.type);
const requestedStatus = normalizeStatus(query.status);
const requestedAuthor = query.authorId;
const mine = query.mine === 'true';
const limit = Number(query.limit ?? 0);
const exclude = query.exclude;
const term = (query.q ?? '').trim().toLowerCase();
const items = content
.filter((item) => !exclude || item.id !== exclude)
.filter((item) => !requestedType || item.type === requestedType)
.filter((item) => !query.category || item.category === query.category)
.filter((item) => !requestedStatus || item.status === requestedStatus)
.filter((item) => !requestedAuthor || item.author === users.find((entry) => entry.id === requestedAuthor)?.name)
.filter((item) => !mine || (user ? item.author === user.name : false))
.filter((item) => !term || [item.title, item.lead, item.body, item.author, item.category, item.status, ...item.tags].join(' ').toLowerCase().includes(term))
.sort((a, b) => {
if (query.sort === 'popular') return b.views - a.views;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
});
return limit > 0 ? items.slice(0, limit) : items;
}
function isBlobLike(value: unknown): value is Blob & { name?: string; type?: string; size: number } {
return typeof value === 'object' && value !== null && 'arrayBuffer' in value && typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function';
}
@@ -533,14 +664,15 @@ const app = new Elysia({ adapter: WebStandardAdapter })
.get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
.get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
.post('/api/auth/register', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string; name: string }>;
if (!payload.login || !payload.password || payload.password.length < 8) {
const payload = body as Partial<{ login: string; email: string; password: string; name: string }>;
const login = normalizeLogin(payload.login ?? payload.email ?? '');
if (!login || !payload.password || payload.password.length < 8) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
}
const user: UserProfile = {
id: `demo-user-${Date.now()}`,
name: payload.name?.trim() || 'Демо-пользователь',
login: payload.login,
login,
roles: ['пользователь'],
subscriptions: []
};
@@ -551,11 +683,12 @@ const app = new Elysia({ adapter: WebStandardAdapter })
return { token, user };
})
.post('/api/auth/login', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string }>;
if (!payload.login || !payload.password) {
const payload = body as Partial<{ login: string; email: string; password: string }>;
const login = normalizeLogin(payload.login ?? payload.email ?? '');
if (!login || !payload.password) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль');
}
const user = users.find((item) => item.login === payload.login) ?? demoUser;
const user = users.find((item) => item.login === login) ?? demoUser;
const token = `demo-token-${crypto.randomUUID()}`;
tokens.set(token, user);
return { token, user };
@@ -585,7 +718,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
}
return { ok: true };
})
.get('/api/content', () => ({ items: content }))
.get('/api/content', ({ query, request }) => ({ items: listContent(query as Record<string, string | undefined>, readUser(request)) }))
.get('/api/content/:id', ({ params, set }) => {
const item = content.find((entry) => entry.id === params.id);
if (!item) {
@@ -602,7 +735,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
}
const payload = body as Partial<ContentItem>;
const payload = normalizeContentPatch(body as Record<string, unknown>);
if (!payload.title || !payload.category || !payload.type) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
}
@@ -617,14 +750,19 @@ const app = new Elysia({ adapter: WebStandardAdapter })
author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10),
visibility: payload.visibility ?? 'После входа',
status: 'Черновик',
status: payload.status ?? 'Черновик',
views: 0,
imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300',
mediaUrl: payload.mediaUrl,
mediaKind: payload.mediaKind,
mimeType: payload.mimeType,
fileName: payload.fileName,
fileSize: payload.fileSize
fileSize: payload.fileSize,
moderatorComment: payload.moderatorComment,
reviewComment: payload.reviewComment,
ratingAverage: 0,
ratingCount: 0,
myRating: 0
};
content = [item, ...content];
set.status = 201;
@@ -639,7 +777,24 @@ const app = new Elysia({ adapter: WebStandardAdapter })
if (index === -1) {
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
}
content[index] = { ...content[index], ...(body as Partial<ContentItem>) };
const payload = normalizeContentPatch(body as Record<string, unknown>);
const nextRating = typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined;
const nextUserRating = typeof body === 'object' && body !== null && typeof (body as Record<string, unknown>).rating === 'number'
? Number((body as Record<string, unknown>).rating)
: undefined;
content[index] = {
...content[index],
...payload,
lead: payload.lead ?? content[index].lead,
body: payload.body ?? content[index].body,
tags: payload.tags ?? content[index].tags,
status: payload.status ?? content[index].status,
ratingAverage: nextUserRating ?? nextRating ?? content[index].ratingAverage,
ratingCount: nextUserRating ? Math.max(Number(content[index].ratingCount ?? 0) + 1, 1) : content[index].ratingCount,
myRating: nextUserRating ?? content[index].myRating,
reviewComment: payload.moderatorComment ?? payload.reviewComment ?? content[index].reviewComment
};
return { item: content[index] };
})
.delete('/api/content/:id', ({ request, params, set }) => {
@@ -770,11 +925,13 @@ const app = new Elysia({ adapter: WebStandardAdapter })
return { item };
})
.get('/api/analytics/summary', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
return {
materials: content.filter((item) => item.author === auth.user?.name).length,
comments: comments.length,
totalViews: content.reduce((sum, item) => sum + item.views, 0),
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
activeUsers: users.length,
@@ -788,8 +945,18 @@ const app = new Elysia({ adapter: WebStandardAdapter })
}
return {
users: users.length,
content: content.length,
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length,
materials: content.length,
views: content.reduce((sum, item) => sum + item.views, 0),
pending: content.filter((item) => item.status !== 'Опубликовано').length,
viewsByDay: [
{ label: 'Пн', value: 120 },
{ label: 'Вт', value: 180 },
{ label: 'Ср', value: 160 },
{ label: 'Чт', value: 220 },
{ label: 'Пт', value: 260 },
{ label: 'Сб', value: 140 },
{ label: 'Вс', value: 110 }
],
roles: ['администратор', 'редактор', 'менеджер', 'пользователь']
};
})

View File

@@ -5,22 +5,29 @@ Frontend MVP информационной системы управления м
## Запуск
Требуются Node.js 22+ и Corepack.
Требуются Node.js 22+ и npm.
Из корня монорепозитория:
```bash
corepack prepare pnpm@11.0.0 --activate
corepack pnpm install
corepack pnpm dev
npm install
npm run dev
```
Откройте `http://localhost:5173`.
Для запуска только frontend:
```bash
npm run dev:web
```
Откройте `http://localhost:5173`. В dev-режиме Vite проксирует `/api` на `http://localhost:3000`.
## Проверки
```bash
corepack pnpm lint
corepack pnpm test
corepack pnpm build
npm --workspace @fable/web run lint
npm --workspace @fable/web run test
npm --workspace @fable/web run build
```
## Структура
@@ -56,7 +63,7 @@ src/
## Тестовые роли
Пароль может быть любым от 6 символов.
Основной demo-вход: `demo_admin` / `demo_password`.
| Роль | Учётная запись |
| --- | --- |
@@ -77,7 +84,7 @@ src/
- `/admin/users`, `/admin/stats`, `/admin/audit-log`;
- `POST /uploads`.
Адрес API задаётся через `VITE_API_URL`. Пока интерфейс использует локальные mock-данные.
Адрес API можно переопределить через `VITE_API_URL`. По умолчанию интерфейс работает с `/api` и ожидает gateway на `localhost:3000`.
## Безопасность зависимостей
@@ -92,7 +99,6 @@ Lockfile необходимо хранить в репозитории.
## Ограничения MVP
- данные не сохраняются после обновления страницы;
- загрузка файлов и комментарии представлены интерфейсом без backend;
- данные backend по-прежнему демонстрационные и в основном живут в in-memory store;
- внешние изображения загружаются с Unsplash;
- график администратора демонстрационный.

View File

@@ -2,16 +2,56 @@ import { create } from "zustand";
import { authApi } from "../../shared/api/endpoints";
import { tokenStorage } from "../../shared/api/client";
const roleMap = {
admin: "admin",
administrator: "admin",
user: "user",
editor: "editor",
moderator: "moderator",
manager: "moderator",
администратор: "admin",
редактор: "editor",
менеджер: "moderator",
пользователь: "user",
};
function pickRole(user = {}) {
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
const normalizedRoles = roles.map((role) => roleMap[String(role).toLowerCase()] ?? role);
if (normalizedRoles.includes("admin")) return "admin";
if (normalizedRoles.includes("moderator")) return "moderator";
if (normalizedRoles.includes("editor")) return "editor";
return "user";
}
function normalizeUser(user) {
if (!user) return null;
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
const role = pickRole(user);
const login = user.login ?? user.username ?? "";
return {
...user,
login,
email: user.email ?? (login && login.includes("@") ? login : ""),
roles,
role,
};
}
function readStoredUser() {
try {
return JSON.parse(sessionStorage.getItem("user") ?? "null");
return normalizeUser(JSON.parse(sessionStorage.getItem("user") ?? "null"));
} catch {
return null;
}
}
function persistUser(user) {
if (user) sessionStorage.setItem("user", JSON.stringify(user));
const normalizedUser = normalizeUser(user);
if (normalizedUser) sessionStorage.setItem("user", JSON.stringify(normalizedUser));
else sessionStorage.removeItem("user");
}
@@ -20,7 +60,7 @@ function getToken(payload) {
}
function getUser(payload) {
return payload?.user ?? payload?.profile ?? payload;
return normalizeUser(payload?.user ?? payload?.profile ?? payload);
}
export const useSession = create((set, get) => ({
@@ -37,12 +77,13 @@ export const useSession = create((set, get) => ({
return user;
},
login: async ({ email, password }) => {
const response = await authApi.login({ email, password });
login: async ({ identifier, email, login, password }) => {
const credential = identifier ?? login ?? email;
const response = await authApi.login({ login: credential, email: credential, password });
const token = getToken(response);
tokenStorage.set(token);
const user = response.user ?? (await authApi.me());
const user = normalizeUser(response.user ?? (await authApi.me()));
set({ user });
persistUser(user);
return user;
@@ -53,7 +94,7 @@ export const useSession = create((set, get) => ({
set({ initializing: true });
try {
const user = await authApi.me();
const user = normalizeUser(await authApi.me());
set({ user, initializing: false });
persistUser(user);
return user;
@@ -87,7 +128,15 @@ export const useSession = create((set, get) => ({
return user;
},
switchRole: () => get().user,
switchRole: (nextRole) => {
const current = get().user;
if (!current) return null;
const user = normalizeUser({ ...current, role: nextRole });
set({ user });
persistUser(user);
return user;
},
}));
if (typeof window !== "undefined") {

View File

@@ -7,12 +7,32 @@ import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
function normalizeEvent(event) {
const rawDate = event.date ?? event.startsAt ?? event.publishedAt ?? "";
const parsedDate = rawDate ? new Date(rawDate) : null;
const day = parsedDate && !Number.isNaN(parsedDate.getTime())
? parsedDate.toLocaleDateString("ru-RU", { day: "numeric" })
: String(rawDate).split(" ")[0] ?? "--";
const month = parsedDate && !Number.isNaN(parsedDate.getTime())
? parsedDate.toLocaleDateString("ru-RU", { month: "short" })
: String(rawDate).split(" ")[1] ?? "";
return {
...event,
day,
month,
description: event.description ?? event.lead ?? event.body ?? "Описание события появится после загрузки данных.",
time: event.time ?? event.startsAt ?? "Время уточняется",
place: event.place ?? event.location ?? "Место уточняется",
};
}
export function EventsPage() {
const { data: eventsPayload, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.events(),
queryFn: directoriesApi.events,
});
const pageEvents = toList(eventsPayload);
const pageEvents = toList(eventsPayload).map(normalizeEvent);
return (
<div className="page-shell py-10">
@@ -51,10 +71,10 @@ export function EventsPage() {
className="group grid gap-5 py-8 md:grid-cols-[minmax(7rem,1fr)_minmax(0,5fr)_auto] md:items-center"
>
<div className="flex items-baseline gap-2 md:block">
<span className="font-serif text-5xl text-primary">{event.date.split(" ")[0]}</span>
<span className="text-sm uppercase tracking-widest text-muted">
{event.date.split(" ")[1]}
</span>
<span className="font-serif text-5xl text-primary">{event.day}</span>
<span className="text-sm uppercase tracking-widest text-muted">
{event.month}
</span>
</div>
<div>
<Badge tone={index === 0 ? "accent" : "neutral"}>{event.category}</Badge>

View File

@@ -179,8 +179,9 @@ export function HomePage() {
<p className="text-xs font-bold uppercase tracking-widest text-accent">Навигация по темам</p>
<div className="mt-5 grid border-l border-t border-line sm:grid-cols-2 lg:grid-cols-4">
{pageCategories.map((category, index) => {
const name = category.name ?? category.title ?? category.label;
const value = category.slug ?? category.id ?? name;
const name = typeof category === "string" ? category : category.name ?? category.title ?? category.label;
const value = typeof category === "string" ? category : category.slug ?? category.id ?? name;
const description = typeof category === "string" ? "Материалы этой категории доступны в едином каталоге." : category.description;
return (
<Link
key={category.id ?? value}
@@ -192,7 +193,7 @@ export function HomePage() {
</span>
<h3 className="mt-12 font-serif text-2xl">{name}</h3>
<p className="mt-3 text-sm leading-6 text-muted transition group-hover:text-white/65">
{category.description}
{description}
</p>
</Link>
);

View File

@@ -8,7 +8,7 @@ import { Button } from "../shared/ui/Button";
import { Input } from "../shared/ui/Field";
const schema = z.object({
email: z.email("Введите корректный адрес"),
identifier: z.string().min(3, "Введите логин или почту"),
password: z.string().min(6, "Минимум 6 символов"),
});
@@ -22,7 +22,7 @@ export function LoginPage() {
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
defaultValues: { email: "editor@dstu.ru", password: "password" },
defaultValues: { identifier: "demo_admin", password: "demo_password" },
});
const onSubmit = async (data) => {
@@ -52,14 +52,13 @@ export function LoginPage() {
</div>
<h1 className="mt-6 font-serif text-4xl">Вход в систему</h1>
<p className="mt-3 leading-7 text-muted">
Используйте тестовый адрес роли или учетную запись backend.
Используйте демо-логин backend или тестовую почту роли.
</p>
<form className="mt-8 grid gap-5" noValidate onSubmit={handleSubmit(onSubmit)}>
<Input
label="Корпоративная почта"
type="email"
error={errors.email?.message}
{...register("email")}
label="Логин или почта"
error={errors.identifier?.message}
{...register("identifier")}
/>
<Input
label="Пароль"
@@ -72,8 +71,8 @@ export function LoginPage() {
</Button>
</form>
<div className="mt-7 rounded-lg border border-line bg-surface p-4 text-xs leading-6 text-muted">
<b className="text-ink">Тестовые роли:</b> user@dstu.ru, editor@dstu.ru,
moderator@dstu.ru, admin@dstu.ru
<b className="text-ink">Демо-вход:</b> demo_admin / demo_password. Также поддерживаются
user@dstu.ru, editor@dstu.ru, moderator@dstu.ru и admin@dstu.ru.
</div>
</div>
</div>

View File

@@ -12,15 +12,15 @@ describe("LoginPage", () => {
</MemoryRouter>,
);
const email = screen.getByLabelText("Корпоративная почта");
const email = screen.getByLabelText("Логин или почта");
const password = screen.getByLabelText("Пароль");
await user.clear(email);
await user.type(email, "wrong");
await user.type(email, "ab");
await user.clear(password);
await user.type(password, "123");
await user.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText("Введите корректный адрес")).toBeInTheDocument();
expect(await screen.findByText("Введите логин или почту")).toBeInTheDocument();
expect(screen.getByText("Минимум 6 символов")).toBeInTheDocument();
});
});

View File

@@ -18,11 +18,17 @@ const materialTypes = [
];
function normalizeCategories(payload) {
return toList(payload).map((item) => ({
id: item.id ?? item.slug ?? item.name,
name: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
}));
return toList(payload).map((item) => {
if (typeof item === "string") {
return { id: item, name: item, value: item };
}
return {
id: item.id ?? item.slug ?? item.name,
name: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
};
});
}
export function MaterialsPage() {

View File

@@ -13,6 +13,19 @@ function number(value) {
return Number(value ?? 0).toLocaleString("ru-RU");
}
function normalizeUsers(payload) {
return toList(payload).map((user) => {
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
const primaryRole = roles[0] ?? "user";
return {
...user,
email: user.email ?? user.login ?? "-",
role: user.role ?? primaryRole,
};
});
}
function pickChart(summary) {
const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? [];
if (!Array.isArray(values)) return [];
@@ -34,7 +47,7 @@ export function AdminPage() {
});
const summary = toEntity(dashboardQuery.data) ?? {};
const users = toList(usersQuery.data);
const users = normalizeUsers(usersQuery.data);
const popular = normalizeContentList(popularQuery.data);
const chart = pickChart(summary);
const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1);

View File

@@ -89,7 +89,7 @@ export function CabinetHomePage() {
<span className={`mt-1.5 size-2 rounded-full ${item.read || item.isRead ? "bg-line" : "bg-accent"}`} />
<div>
<h3 className="font-semibold">{item.title ?? "Уведомление"}</h3>
<p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.body}</p>
<p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.description ?? item.body}</p>
<p className="mt-2 text-xs text-muted">{formatDate(item.createdAt ?? item.date)}</p>
</div>
</div>

View File

@@ -36,11 +36,17 @@ function formatFileSize(size) {
}
function normalizeCategories(payload) {
return toList(payload).map((item) => ({
id: item.id ?? item.slug ?? item.name,
label: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
}));
return toList(payload).map((item) => {
if (typeof item === "string") {
return { id: item, label: item, value: item };
}
return {
id: item.id ?? item.slug ?? item.name,
label: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
};
});
}
function makePayload(data, photos, status) {

View File

@@ -112,7 +112,7 @@ export function ProfilePage() {
<div className="mt-7 grid gap-5 sm:grid-cols-2">
<Input name="name" label="Имя и фамилия" defaultValue={user.name ?? ""} disabled />
<Input label="Электронная почта" defaultValue={user.email ?? ""} disabled />
<Input label="Электронная почта" defaultValue={user.email ?? user.login ?? ""} disabled />
<Input name="department" label="Подразделение" defaultValue={user.department ?? ""} disabled />
<Input name="phone" label="Телефон" defaultValue={user.phone ?? ""} disabled />
<Textarea

View File

@@ -1,6 +1,6 @@
import axios from "axios";
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000/api";
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
export const tokenStorage = {
get: () => sessionStorage.getItem("accessToken"),
@@ -32,8 +32,8 @@ api.interceptors.response.use(
return Promise.reject({
status: error.response?.status ?? 0,
message: error.response?.data?.message ?? "Не удалось выполнить запрос",
fieldErrors: error.response?.data?.errors ?? error.response?.data?.fieldErrors,
message: error.response?.data?.error?.message ?? error.response?.data?.message ?? "Не удалось выполнить запрос",
fieldErrors: error.response?.data?.error?.errors ?? error.response?.data?.errors ?? error.response?.data?.fieldErrors,
});
},
);

View File

@@ -8,6 +8,9 @@ const labels = {
published: "Опубликован",
returned: "Возвращен",
archived: "В архиве",
Опубликовано: "Опубликовано",
Возвращен: "Возвращен",
Архив: "В архиве",
};
const tones = {
@@ -22,7 +25,9 @@ const tones = {
"На модерации": "warning",
Одобрен: "success",
Опубликован: "success",
Опубликовано: "success",
Возвращен: "danger",
Архив: "neutral",
"В архиве": "neutral",
};

View File

@@ -6,6 +6,16 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/health": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
test: {
globals: true,